فهرست منبع

More docs docs docs

Philipp Heckel 4 سال پیش
والد
کامیت
1552d8103e
7فایلهای تغییر یافته به همراه211 افزوده شده و 121 حذف شده
  1. 4 3
      client/client.go
  2. 6 0
      client/options.go
  3. 2 7
      cmd/access.go
  4. 54 22
      cmd/publish.go
  5. 89 40
      cmd/user.go
  6. 50 49
      docs/config.md
  7. 6 0
      util/util.go

+ 4 - 3
client/client.go

@@ -5,6 +5,7 @@ import (
 	"bufio"
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"heckel.io/ntfy/util"
 	"io"
@@ -105,13 +106,13 @@ func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishO
 		return nil, err
 	}
 	defer resp.Body.Close()
-	if resp.StatusCode != http.StatusOK {
-		return nil, fmt.Errorf("unexpected response %d from server", resp.StatusCode)
-	}
 	b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
 	if err != nil {
 		return nil, err
 	}
+	if resp.StatusCode != http.StatusOK {
+		return nil, errors.New(strings.TrimSpace(string(b)))
+	}
 	m, err := toMessage(string(b), topicURL, "")
 	if err != nil {
 		return nil, err

+ 6 - 0
client/options.go

@@ -2,6 +2,7 @@ package client
 
 import (
 	"fmt"
+	"heckel.io/ntfy/util"
 	"net/http"
 	"strings"
 	"time"
@@ -70,6 +71,11 @@ func WithEmail(email string) PublishOption {
 	return WithHeader("X-Email", email)
 }
 
+// WithBasicAuth adds the Authorization header for basic auth to the request
+func WithBasicAuth(user, pass string) PublishOption {
+	return WithHeader("Authorization", util.BasicAuth(user, pass))
+}
+
 // WithNoCache instructs the server not to cache the message server-side
 func WithNoCache() PublishOption {
 	return WithHeader("X-Cache", "no")

+ 2 - 7
cmd/access.go

@@ -8,12 +8,6 @@ import (
 	"heckel.io/ntfy/util"
 )
 
-/*
-
-
-
- */
-
 const (
 	userEveryone = "everyone"
 )
@@ -46,7 +40,8 @@ Usage:
   ntfy access USERNAME TOPIC PERMISSION  # Allow/deny access for USERNAME to TOPIC
 
 Arguments:
-  USERNAME     an existing user, as created with 'ntfy user add'
+  USERNAME     an existing user, as created with 'ntfy user add', or "everyone"/"*"
+               to define access rules for anonymous/unauthenticated clients
   TOPIC        name of a topic with optional wildcards, e.g. "mytopic*"
   PERMISSION   one of the following:
                - read-write (alias: rw) 

+ 54 - 22
cmd/publish.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"github.com/urfave/cli/v2"
 	"heckel.io/ntfy/client"
+	"heckel.io/ntfy/util"
 	"io"
 	"os"
 	"path/filepath"
@@ -15,23 +16,25 @@ var cmdPublish = &cli.Command{
 	Name:      "publish",
 	Aliases:   []string{"pub", "send", "trigger"},
 	Usage:     "Send message via a ntfy server",
-	UsageText: "ntfy send [OPTIONS..] TOPIC [MESSAGE]",
+	UsageText: "ntfy send [OPTIONS..] TOPIC [MESSAGE]\n   NTFY_TOPIC=.. ntfy send [OPTIONS..] -P [MESSAGE]",
 	Action:    execPublish,
 	Category:  categoryClient,
 	Flags: []cli.Flag{
-		&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
-		&cli.StringFlag{Name: "title", Aliases: []string{"t"}, Usage: "message title"},
-		&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
-		&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, Usage: "comma separated list of tags and emojis"},
-		&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, Usage: "delay/schedule message"},
-		&cli.StringFlag{Name: "click", Aliases: []string{"U"}, Usage: "URL to open when notification is clicked"},
-		&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, Usage: "URL to send as an external attachment"},
-		&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, Usage: "Filename for the attachment"},
-		&cli.StringFlag{Name: "file", Aliases: []string{"f"}, Usage: "File to upload as an attachment"},
-		&cli.StringFlag{Name: "email", Aliases: []string{"e-mail", "mail", "e"}, Usage: "also send to e-mail address"},
-		&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, Usage: "do not cache message server-side"},
-		&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, Usage: "do not forward message to Firebase"},
-		&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, Usage: "do print message"},
+		&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
+		&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
+		&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
+		&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"},
+		&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"},
+		&cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"},
+		&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
+		&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "Filename for the attachment"},
+		&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "File to upload as an attachment"},
+		&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
+		&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
+		&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
+		&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
+		&cli.BoolFlag{Name: "env-topic", Aliases: []string{"P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
+		&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do print message"},
 	},
 	Description: `Publish a message to a ntfy server.
 
@@ -46,9 +49,12 @@ Examples:
   ntfy pub --click="https://reddit.com" redd 'New msg'    # Opens Reddit when notification is clicked
   ntfy pub --attach="http://some.tld/file.zip" files      # Send ZIP archive from URL as attachment
   ntfy pub --file=flower.jpg flowers 'Nice!'              # Send image.jpg as attachment
+  ntfy pub -u phil:mypass secret Psst                     # Publish with username/password
+  NTFY_USER=phil:mypass ntfy pub secret Psst              # Use env variables to set username/password
+  NTFY_TOPIC=mytopic ntfy pub -P "some message""          # Use NTFY_TOPIC variable as topic 
   cat flower.jpg | ntfy pub --file=- flowers 'Nice!'      # Same as above, send image.jpg as attachment
   ntfy trigger mywebhook                                  # Sending without message, useful for webhooks
-
+ 
 Please also check out the docs on publishing messages. Especially for the --tags and --delay options, 
 it has incredibly useful information: https://ntfy.sh/docs/publish/.
 
@@ -57,9 +63,6 @@ or ~/.config/ntfy/client.yml for all other users.`,
 }
 
 func execPublish(c *cli.Context) error {
-	if c.NArg() < 1 {
-		return errors.New("must specify topic, type 'ntfy publish --help' for help")
-	}
 	conf, err := loadConfig(c)
 	if err != nil {
 		return err
@@ -73,13 +76,25 @@ func execPublish(c *cli.Context) error {
 	filename := c.String("filename")
 	file := c.String("file")
 	email := c.String("email")
+	user := c.String("user")
 	noCache := c.Bool("no-cache")
 	noFirebase := c.Bool("no-firebase")
+	envTopic := c.Bool("env-topic")
 	quiet := c.Bool("quiet")
-	topic := c.Args().Get(0)
-	message := ""
-	if c.NArg() > 1 {
-		message = strings.Join(c.Args().Slice()[1:], " ")
+	var topic, message string
+	if envTopic {
+		topic = os.Getenv("NTFY_TOPIC")
+		if c.NArg() > 0 {
+			message = strings.Join(c.Args().Slice(), " ")
+		}
+	} else {
+		if c.NArg() < 1 {
+			return errors.New("must specify topic, type 'ntfy publish --help' for help")
+		}
+		topic = c.Args().Get(0)
+		if c.NArg() > 1 {
+			message = strings.Join(c.Args().Slice()[1:], " ")
+		}
 	}
 	var options []client.PublishOption
 	if title != "" {
@@ -112,6 +127,23 @@ func execPublish(c *cli.Context) error {
 	if noFirebase {
 		options = append(options, client.WithNoFirebase())
 	}
+	if user != "" {
+		var pass string
+		parts := strings.SplitN(user, ":", 2)
+		if len(parts) == 2 {
+			user = parts[0]
+			pass = parts[1]
+		} else {
+			fmt.Fprint(c.App.ErrWriter, "Enter Password: ")
+			p, err := util.ReadPassword(c.App.Reader)
+			if err != nil {
+				return err
+			}
+			pass = string(p)
+			fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
+		}
+		options = append(options, client.WithBasicAuth(user, pass))
+	}
 	var body io.Reader
 	if file == "" {
 		body = strings.NewReader(message)

+ 89 - 40
cmd/user.go

@@ -11,67 +11,108 @@ import (
 	"strings"
 )
 
-/*
-
----
-dabbling for CLI
-	ntfy user allow phil mytopic
-	ntfy user allow phil mytopic --read-only
-	ntfy user deny phil mytopic
-	ntfy user list
-	   phil (admin)
-	   - read-write access to everything
-	   ben (user)
-	   - read-write access to a topic alerts
-	   - read access to
-       everyone (no user)
-       - read-only access to topic announcements
-
-*/
-
 var flagsUser = userCommandFlags()
 var cmdUser = &cli.Command{
 	Name:      "user",
-	Usage:     "Manage users and access to topics",
-	UsageText: "ntfy user [add|del|...] ...",
+	Usage:     "Manage/show users",
+	UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...",
 	Flags:     flagsUser,
 	Before:    initConfigFileInputSource("config", flagsUser),
 	Category:  categoryServer,
 	Subcommands: []*cli.Command{
 		{
-			Name:    "add",
-			Aliases: []string{"a"},
-			Usage:   "add user to auth database",
-			Action:  execUserAdd,
+			Name:      "add",
+			Aliases:   []string{"a"},
+			Usage:     "add user",
+			UsageText: "ntfy user add [--role=admin|user] USERNAME",
+			Action:    execUserAdd,
 			Flags: []cli.Flag{
 				&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(auth.RoleUser), Usage: "user role"},
 			},
+			Description: `Add a new user to the ntfy user database.
+
+A user can be either a regular user, or an admin. A regular user has no read or write access (unless
+granted otherwise by the auth-default-access setting). An admin user has read and write access to all
+topics.
+
+Examples:
+  ntfy user add phil                 # Add regular user phil  
+  ntfy user add --role=admin phil    # Add admin user phil
+`,
 		},
 		{
-			Name:    "remove",
-			Aliases: []string{"del", "rm"},
-			Usage:   "remove user from auth database",
-			Action:  execUserDel,
+			Name:      "remove",
+			Aliases:   []string{"del", "rm"},
+			Usage:     "remove user",
+			UsageText: "ntfy user remove USERNAME",
+			Action:    execUserDel,
+			Description: `Remove a user from the ntfy user database.
+
+Example:
+  ntfy user del phil
+`,
 		},
 		{
-			Name:    "change-pass",
-			Aliases: []string{"chp"},
-			Usage:   "change user password",
-			Action:  execUserChangePass,
+			Name:      "change-pass",
+			Aliases:   []string{"chp"},
+			Usage:     "change user password",
+			UsageText: "ntfy user change-pass USERNAME",
+			Action:    execUserChangePass,
+			Description: `Change the password for the given user.
+
+The new password will be read from STDIN, and it'll be confirmed by typing
+it twice. 
+
+Example:
+    ntfy user change-pass phil
+`,
 		},
 		{
-			Name:    "change-role",
-			Aliases: []string{"chr"},
-			Usage:   "change user role",
-			Action:  execUserChangeRole,
+			Name:      "change-role",
+			Aliases:   []string{"chr"},
+			Usage:     "change user role",
+			UsageText: "ntfy user change-role USERNAME ROLE",
+			Action:    execUserChangeRole,
+			Description: `Change the role for the given user to admin or user.
+
+This command can be used to change the role of a user either from a regular user
+to an admin user, or the other way around:
+
+- admin: an admin has read/write access to all topics
+- user: a regular user only has access to what was explicitly granted via 'ntfy access'
+
+When changing the role of a user to "admin", all access control entries for that 
+user are removed, since they are no longer necessary.
+
+Example:
+  ntfy user change-role phil admin   # Make user phil an admin 
+  ntfy user change-role phil user    # Remove admin role from user phil 
+`,
 		},
 		{
 			Name:    "list",
-			Aliases: []string{"chr"},
-			Usage:   "change user role",
+			Aliases: []string{"l"},
+			Usage:   "list users",
 			Action:  execUserList,
 		},
 	},
+	Description: `Manage users of the ntfy server.
+
+This is a server-only command. It directly manages the user.db as defined in the server config
+file server.yml. The command only works if 'auth-file' is properly defined. Please also refer
+to the related command 'ntfy access'.
+
+The command allows you to add/remove/change users in the ntfy user database, as well as change 
+passwords or roles.
+
+Examples:
+  ntfy user list                     # Shows list of users                        
+  ntfy user add phil                 # Add regular user phil  
+  ntfy user add --role=admin phil    # Add admin user phil
+  ntfy user del phil                 # Delete user phil
+  ntfy user change-pass phil         # Change password for user phil
+  ntfy user change-role phil admin   # Make user phil an admin 
+`,
 }
 
 func execUserAdd(c *cli.Context) error {
@@ -79,6 +120,8 @@ func execUserAdd(c *cli.Context) error {
 	role := auth.Role(c.String("role"))
 	if username == "" {
 		return errors.New("username expected, type 'ntfy user add --help' for help")
+	} else if username == userEveryone {
+		return errors.New("username not allowed")
 	} else if !auth.AllowedRole(role) {
 		return errors.New("role must be either 'user' or 'admin'")
 	}
@@ -101,6 +144,8 @@ func execUserDel(c *cli.Context) error {
 	username := c.Args().Get(0)
 	if username == "" {
 		return errors.New("username expected, type 'ntfy user del --help' for help")
+	} else if username == userEveryone {
+		return errors.New("username not allowed")
 	}
 	manager, err := createAuthManager(c)
 	if err != nil {
@@ -117,6 +162,8 @@ func execUserChangePass(c *cli.Context) error {
 	username := c.Args().Get(0)
 	if username == "" {
 		return errors.New("username expected, type 'ntfy user change-pass --help' for help")
+	} else if username == userEveryone {
+		return errors.New("username not allowed")
 	}
 	password, err := readPassword(c)
 	if err != nil {
@@ -138,6 +185,8 @@ func execUserChangeRole(c *cli.Context) error {
 	role := auth.Role(c.Args().Get(1))
 	if username == "" || !auth.AllowedRole(role) {
 		return errors.New("username and new role expected, type 'ntfy user change-role --help' for help")
+	} else if username == userEveryone {
+		return errors.New("username not allowed")
 	}
 	manager, err := createAuthManager(c)
 	if err != nil {
@@ -169,11 +218,11 @@ func createAuthManager(c *cli.Context) (auth.Manager, error) {
 		return nil, errors.New("option auth-file not set; auth is unconfigured for this server")
 	} else if !util.FileExists(authFile) {
 		return nil, errors.New("auth-file does not exist; please start the server at least once to create it")
-	} else if !util.InStringList([]string{"read-write", "read-only", "deny-all"}, authDefaultAccess) {
+	} else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) {
 		return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only' or 'deny-all'")
 	}
 	authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"
-	authDefaultWrite := authDefaultAccess == "read-write"
+	authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only"
 	return auth.NewSQLiteAuth(authFile, authDefaultRead, authDefaultWrite)
 }
 

+ 50 - 49
docs/config.md

@@ -122,7 +122,7 @@ Please also refer to the [rate limiting](#rate-limiting) settings below, specifi
 and `visitor-attachment-daily-bandwidth-limit`. Setting these conservatively is necessary to avoid abuse.
 
 ## Access control
-By default, the ntfy server is open for everyone, meaning everyone can read and write to any topic. To restrict access
+By default, the ntfy server is open for everyone, meaning **everyone can read and write to any topic**. To restrict access
 to your own server, you can optionally configure authentication and authorization. 
 
 ntfy's auth is implemented with a simple SQLite-based backend. It implements two roles (`user` and `admin`) and per-topic
@@ -135,10 +135,13 @@ To set up auth, simply configure the following two options:
 * `auth-default-access` defines the default/fallback access if no access control entry is found; it can be
   set to `read-write` (default), `read-only`, `write-only` or `deny-all`.
 
-Once configured, you can use the `ntfy user` command to add/modify/delete users (with either a `user` or an `admin` role).
-To control granular access to specific topics, you can use the `ntfy access` command to modify the access control list.
+### Managing users + access
+Once configured, you can use the `ntfy user` command to add/modify/delete users, and  the `ntfy access` command
+to modify the access control list to allow/deny access to specific topic or topic patterns.
 
-### Example: private instance
+XXXXXXXXXXXXXXXXXXXx
+
+### Example: Private instance
 The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`:
 
 ``` yaml
@@ -156,75 +159,73 @@ User phil added with role admin
 ```
 
 Once you've done that, you can publish and subscribe using [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) 
-with the given username/password. Here's a simple example:
+with the given username/password. Be sure to use HTTPS to avoid eavesdropping and exposing your password. Here's a simple example:
 
 === "Command line (curl)"
     ```
     curl \
         -u phil:mypass \
         -d "Look ma, with auth" \
-        ntfy.example.com/secrets
+        https://ntfy.example.com/mysecrets
     ```
 
 === "ntfy CLI"
     ```
-    ntfy publish ntfy.example.com/mytopic "Look ma, with auth"
-
-    XXXXXXXXXXX
-    XXXXXXXXXXX
-    XXXXXXXXXXX
-    XXXXXXXXXXX
-    XXXXXXXXXXX
-    XXXXXXXXXXX
-    XXXXXXXXXXX
-    XXXXXXXXXXX
-    XXXXXXXXXXX
-    XXXXXXXXXXX
-    XXXXXXXXXXX
-    XXXXXXXXXXX
-    XXXXXXXXXXX
-    XXXXXXXXXXX
-    XXXXXXXXXXX
+    ntfy publish \
+        -u phil:mypass \
+        ntfy.example.com/mysecrets \
+        "Look ma, with auth"
     ```
 
 === "HTTP"
     ``` http
-    POST /mytopic HTTP/1.1
-    Host: ntfy.sh
+    POST /mysecrets HTTP/1.1
+    Host: ntfy.example.com
     Authorization: Basic cGhpbDpteXBhc3M=
 
-    Backup successful 😀
+    Look ma, with auth
     ```
+
 === "JavaScript"
-``` javascript
-fetch('https://ntfy.sh/mytopic', {
-method: 'POST', // PUT works too
-body: 'Backup successful 😀'
-})
-```
+    ``` javascript
+    fetch('https://ntfy.example.com/mysecrets', {
+        method: 'POST', // PUT works too
+        body: 'Look ma, with auth',
+        headers: {
+            'Authorization': 'Basic cGhpbDpteXBhc3M='
+        }
+    })
+    ```
 
 === "Go"
-``` go
-http.Post("https://ntfy.sh/mytopic", "text/plain",
-strings.NewReader("Backup successful 😀"))
-```
+    ``` go
+    req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets",
+        strings.NewReader("Look ma, with auth"))
+    req.Header.Set("Authorization", "Basic cGhpbDpteXBhc3M=")
+    http.DefaultClient.Do(req)
+    ```
 
 === "Python"
-``` python
-requests.post("https://ntfy.sh/mytopic",
-data="Backup successful 😀".encode(encoding='utf-8'))
-```
+    ``` python
+    requests.post("https://ntfy.example.com/mysecrets",
+        data="Look ma, with auth",
+        headers={
+            "Authorization": "Basic cGhpbDpteXBhc3M="
+        })
+    ```
 
 === "PHP"
-``` php-inline
-file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
-'http' => [
-'method' => 'POST', // PUT also works
-'header' => 'Content-Type: text/plain',
-'content' => 'Backup successful 😀'
-]
-]));
-```
+    ``` php-inline
+    file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([
+        'http' => [
+            'method' => 'POST', // PUT also works
+            'header' => 
+                'Content-Type: text/plain\r\n' .
+                'Authorization: Basic cGhpbDpteXBhc3M=',
+            'content' => 'Look ma, with auth'
+        ]
+    ]));
+    ```
 
 ## E-mail notifications
 To allow forwarding messages via e-mail, you can configure an **SMTP server for outgoing messages**. Once configured, 

+ 6 - 0
util/util.go

@@ -1,6 +1,7 @@
 package util
 
 import (
+	"encoding/base64"
 	"errors"
 	"fmt"
 	"github.com/gabriel-vasile/mimetype"
@@ -240,3 +241,8 @@ func ReadPassword(in io.Reader) ([]byte, error) {
 
 	return password, nil
 }
+
+// BasicAuth encodes the Authorization header value for basic auth
+func BasicAuth(user, pass string) string {
+	return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", user, pass))))
+}