diff --git a/client/client.go b/client/client.go index 575c285..31991c0 100644 --- a/client/client.go +++ b/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 diff --git a/client/options.go b/client/options.go index ccf6985..1d3b7ac 100644 --- a/client/options.go +++ b/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") diff --git a/cmd/access.go b/cmd/access.go index d8a6191..3a2ebb7 100644 --- a/cmd/access.go +++ b/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) diff --git a/cmd/publish.go b/cmd/publish.go index b22ddbd..2012ea2 100644 --- a/cmd/publish.go +++ b/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) diff --git a/cmd/user.go b/cmd/user.go index d72bd6a..044ad70 100644 --- a/cmd/user.go +++ b/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) } diff --git a/docs/config.md b/docs/config.md index c233a77..79c3fbd 100644 --- a/docs/config.md +++ b/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, diff --git a/util/util.go b/util/util.go index ae4384e..47a5877 100644 --- a/util/util.go +++ b/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)))) +}