From d714af43c9c5e51637299f1c5374198f05b8f2ec Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 3 Feb 2022 20:07:23 -0500 Subject: [PATCH] More docs, more tests, more docs --- cmd/access.go | 27 ++++++----- cmd/user.go | 9 +++- cmd/user_test.go | 40 ++++++++++++++++- docs/config.md | 17 +++---- docs/subscribe/api.md | 102 ++++++++++++++++++++++++++++++------------ docs/subscribe/cli.md | 20 ++++----- 6 files changed, 155 insertions(+), 60 deletions(-) diff --git a/cmd/access.go b/cmd/access.go index 95451512..7ffd1608 100644 --- a/cmd/access.go +++ b/cmd/access.go @@ -35,7 +35,7 @@ The command allows you to show the access control list, as well as change it, de it is called. Usage: - ntfy access # Shows the entire access control list + ntfy access # Shows access control list (alias: 'ntfy user list') ntfy access USERNAME # Shows access control entries for USERNAME ntfy access USERNAME TOPIC PERMISSION # Allow/deny access for USERNAME to TOPIC @@ -50,7 +50,7 @@ Arguments: - deny (alias: none) Examples: - ntfy access # Shows entire access control list + ntfy access # Shows access control list (alias: 'ntfy user list') ntfy access phil # Shows access for user phil ntfy access phil mytopic rw # Allow read-write access to mytopic for user phil ntfy access everyone mytopic rw # Allow anonymous read-write access to mytopic @@ -82,6 +82,9 @@ func execUserAccess(c *cli.Context) error { } return resetAccess(c, manager, username, topic) } else if perms == "" { + if topic != "" { + return errors.New("invalid syntax, please check 'ntfy access --help' for usage details") + } return showAccess(c, manager, username) } return changeAccess(c, manager, username, topic, perms) @@ -97,13 +100,13 @@ func changeAccess(c *cli.Context, manager auth.Manager, username string, topic s return err } if read && write { - fmt.Fprintf(c.App.Writer, "Granted read-write access to topic %s\n\n", topic) + fmt.Fprintf(c.App.ErrWriter, "Granted read-write access to topic %s\n\n", topic) } else if read { - fmt.Fprintf(c.App.Writer, "Granted read-only access to topic %s\n\n", topic) + fmt.Fprintf(c.App.ErrWriter, "Granted read-only access to topic %s\n\n", topic) } else if write { - fmt.Fprintf(c.App.Writer, "Granted write-only access to topic %s\n\n", topic) + fmt.Fprintf(c.App.ErrWriter, "Granted write-only access to topic %s\n\n", topic) } else { - fmt.Fprintf(c.App.Writer, "Revoked all access to topic %s\n\n", topic) + fmt.Fprintf(c.App.ErrWriter, "Revoked all access to topic %s\n\n", topic) } return showUserAccess(c, manager, username) } @@ -121,7 +124,7 @@ func resetAllAccess(c *cli.Context, manager auth.Manager) error { if err := manager.ResetAccess("", ""); err != nil { return err } - fmt.Fprintln(c.App.Writer, "Reset access for all users") + fmt.Fprintln(c.App.ErrWriter, "Reset access for all users") return nil } @@ -129,7 +132,7 @@ func resetUserAccess(c *cli.Context, manager auth.Manager, username string) erro if err := manager.ResetAccess(username, ""); err != nil { return err } - fmt.Fprintf(c.App.Writer, "Reset access for user %s\n\n", username) + fmt.Fprintf(c.App.ErrWriter, "Reset access for user %s\n\n", username) return showUserAccess(c, manager, username) } @@ -137,7 +140,7 @@ func resetUserTopicAccess(c *cli.Context, manager auth.Manager, username string, if err := manager.ResetAccess(username, topic); err != nil { return err } - fmt.Fprintf(c.App.Writer, "Reset access for user %s and topic %s\n\n", username, topic) + fmt.Fprintf(c.App.ErrWriter, "Reset access for user %s and topic %s\n\n", username, topic) return showUserAccess(c, manager, username) } @@ -158,7 +161,9 @@ func showAllAccess(c *cli.Context, manager auth.Manager) error { func showUserAccess(c *cli.Context, manager auth.Manager, username string) error { users, err := manager.User(username) - if err != nil { + if err == auth.ErrNotFound { + return fmt.Errorf("user %s does not exist", username) + } else if err != nil { return err } return showUsers(c, manager, []*auth.User{users}) @@ -166,7 +171,7 @@ func showUserAccess(c *cli.Context, manager auth.Manager, username string) error func showUsers(c *cli.Context, manager auth.Manager, users []*auth.User) error { for _, user := range users { - fmt.Fprintf(c.App.Writer, "User %s (%s)\n", user.Name, user.Role) + fmt.Fprintf(c.App.ErrWriter, "User %s (%s)\n", user.Name, user.Role) if user.Role == auth.RoleAdmin { fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n") } else if len(user.Grants) > 0 { diff --git a/cmd/user.go b/cmd/user.go index ae74e0f1..62f703b9 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -99,6 +99,13 @@ Example: Usage: "Shows a list of users", Before: inheritRootReaderFunc, Action: execUserList, + Description: `Shows a list of all configured users, including the everyone ('*') user. + +This is a server-only command. It directly reads from the user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined. + +This command is an alias to calling 'ntfy access' (display access control list). +`, }, }, Description: `Manage users of the ntfy server. @@ -111,7 +118,7 @@ The command allows you to add/remove/change users in the ntfy user database, as passwords or roles. Examples: - ntfy user list # Shows list of users + ntfy user list # Shows list of users (alias: 'ntfy access') 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 diff --git a/cmd/user_test.go b/cmd/user_test.go index 3d8a5d77..46a433d2 100644 --- a/cmd/user_test.go +++ b/cmd/user_test.go @@ -28,7 +28,7 @@ func TestCLI_User_Add_Exists(t *testing.T) { require.Nil(t, runUserCommand(app, conf, "add", "phil")) require.Contains(t, stderr.String(), "user phil added with role user") - app, stdin, _, stderr = newTestApp() + app, stdin, _, _ = newTestApp() stdin.WriteString("mypass\nmypass") err := runUserCommand(app, conf, "add", "phil") require.Error(t, err) @@ -73,6 +73,44 @@ func TestCLI_User_ChangePass(t *testing.T) { require.Contains(t, stderr.String(), "changed password for user phil") } +func TestCLI_User_ChangeRole(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + // Add user + app, stdin, _, stderr := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "phil")) + require.Contains(t, stderr.String(), "user phil added with role user") + + // Change role + app, _, _, stderr = newTestApp() + require.Nil(t, runUserCommand(app, conf, "change-role", "phil", "admin")) + require.Contains(t, stderr.String(), "changed role for user phil to admin") +} + +func TestCLI_User_Delete(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + // Add user + app, stdin, _, stderr := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "phil")) + require.Contains(t, stderr.String(), "user phil added with role user") + + // Delete user + app, _, _, stderr = newTestApp() + require.Nil(t, runUserCommand(app, conf, "del", "phil")) + require.Contains(t, stderr.String(), "user phil removed") + + // Delete user again (does not exist) + app, _, _, _ = newTestApp() + err := runUserCommand(app, conf, "del", "phil") + require.Error(t, err) + require.Contains(t, err.Error(), "user phil does not exist") +} + func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) { conf = server.NewConfig() conf.AuthFile = filepath.Join(t.TempDir(), "user.db") diff --git a/docs/config.md b/docs/config.md index 2bc19884..5b9d45a0 100644 --- a/docs/config.md +++ b/docs/config.md @@ -155,7 +155,7 @@ user with `ntfy user add --role=admin ...` and be done with all this (see [examp **Example commands** (type `ntfy user --help` or `ntfy user COMMAND --help` for more details): ``` -ntfy user list # Shows list of users +ntfy user list # Shows list of users (alias: 'ntfy access') 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 @@ -164,13 +164,13 @@ ntfy user change-role phil admin # Make user phil an admin ``` ### Access control list (ACL) -The access control list (ACL) **manages access to topics for non-admin users, and for anonymous access**. Each entry -represents the access permissions for a user to a specific topic or topic pattern. +The access control list (ACL) **manages access to topics for non-admin users, and for anonymous access (`everyone`/`*`)**. +Each entry represents the access permissions for a user to a specific topic or topic pattern. The ACL can be displayed or modified with the `ntfy access` command: ``` -ntfy access # Shows the entire access control list +ntfy access # Shows access control list (alias: 'ntfy user list') ntfy access USERNAME # Shows access control entries for USERNAME ntfy access USERNAME TOPIC PERMISSION # Allow/deny access for USERNAME to TOPIC ``` @@ -225,10 +225,11 @@ to topic `garagedoor` and all topics starting with the word `alerts` (wildcards) ### 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 -auth-file "/var/lib/ntfy/user.db" -auth-default-access: "deny-all" -``` +=== "/etc/ntfy/server.yml" + ``` yaml + auth-file "/var/lib/ntfy/user.db" + auth-default-access: "deny-all" + ``` After that, simply create an `admin` user: diff --git a/docs/subscribe/api.md b/docs/subscribe/api.md index d16a3f9e..53aef7ea 100644 --- a/docs/subscribe/api.md +++ b/docs/subscribe/api.md @@ -278,12 +278,12 @@ $ curl "ntfy.sh/alerts/json?priority=high&tags=zfs-error" Available filters (all case-insensitive): -| Filter variable | Alias | Example | Description | -|---|---|---|---| -| `message` | `X-Message`, `m` | `ntfy.sh/mytopic?message=lalala` | Only return messages that match this exact message string | -| `title` | `X-Title`, `t` | `ntfy.sh/mytopic?title=some+title` | Only return messages that match this exact title string | -| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) | -| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?tags=error,alert` | Only return messages that match *all listed tags* (comma-separated) | +| Filter variable | Alias | Example | Description | +|-----------------|---------------------------|------------------------------------|-------------------------------------------------------------------------| +| `message` | `X-Message`, `m` | `ntfy.sh/mytopic?message=lalala` | Only return messages that match this exact message string | +| `title` | `X-Title`, `t` | `ntfy.sh/mytopic?title=some+title` | Only return messages that match this exact title string | +| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) | +| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?tags=error,alert` | Only return messages that match *all listed tags* (comma-separated) | ### Subscribe to multiple topics It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics @@ -296,37 +296,70 @@ $ curl -s ntfy.sh/mytopic1,mytopic2/json {"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"} ``` +### Authentication +Depending on whether the server is configured to support [access control](../config.md#access-control), some topics +may be read/write protected so that only users with the correct credentials can subscribe or publish to them. +To publish/subscribe to protected topics, you can use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) +with a valid username/password. For your self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing +your password. + +``` +curl -u phil:mypass -s "https://ntfy.example.com/mytopic/json" +``` + ## JSON message format Both the [`/json` endpoint](#subscribe-as-json-stream) and the [`/sse` endpoint](#subscribe-as-sse-stream) return a JSON format of the message. It's very straight forward: -| Field | Required | Type | Example | Description | -|---|---|---|---|---| -| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier | -| `time` | ✔️ | *int* | `1635528741` | Message date time, as Unix time stamp | -| `event` | ✔️ | `open`, `keepalive` or `message` | `message` | Message type, typically you'd be only interested in `message` | -| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events | -| `message` | - | *string* | `Some message` | Message body; always present in `message` events | -| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/` | -| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis | -| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max | +**Message**: + +| Field | Required | Type | Example | Description | +|--------------|----------|---------------------------------------------------|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------| +| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier | +| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp | +| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` | +| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events | +| `message` | - | *string* | `Some message` | Message body; always present in `message` events | +| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/` | +| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis | +| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max | +| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) | +| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) | + +**Attachment** (part of the message, see [attachments](../publish.md#attachments) for details): + +| Field | Required | Type | Example | Description | +|-----------|----------|-------------|--------------------------------|-----------------------------------------------------------------------------------------------------------| +| `name` | ✔️ | *string* | `attachment.jpg` | Name of the attachment, can be overridden with `X-Filename`, see [attachments](../publish.md#attachments) | +| `url` | ✔️ | *URL* | `https://example.com/file.jpg` | URL of the attachment | +| `type` | -️ | *mime type* | `image/jpeg` | Mime type of the attachment, only defined if attachment was uploaded to ntfy server | +| `size` | -️ | *number* | `33848` | Size of the attachment in bytes, only defined if attachment was uploaded to ntfy server | +| `expires` | -️ | *number* | `1635528741` | Attachment expiry date as Unix time stamp, only defined if attachment was uploaded to ntfy server | Here's an example for each message type: === "Notification message" ``` json { - "id": "wze9zgqK41", - "time": 1638542110, + "id": "sPs71M8A2T", + "time": 1643935928, "event": "message", - "topic": "phil_alerts", + "topic": "mytopic", "priority": 5, "tags": [ "warning", "skull" ], + "click": "https://homecam.mynet.lan/incident/1234", + "attachment": { + "name": "camera.jpg", + "type": "image/png", + "size": 33848, + "expires": 1643946728, + "url": "https://ntfy.sh/file/sPs71M8A2T.png" + }, "title": "Unauthorized access detected", - "message": "Remote access to phils-laptop detected. Act right away." + "message": "Movement detected in the yard. You better go check" } ``` @@ -362,15 +395,26 @@ Here's an example for each message type: } ``` + +=== "Poll request message" + ``` json + { + "id": "371sevb0pD", + "time": 1638542275, + "event": "poll_request", + "topic": "phil_alerts" + } + ``` + ## List of all parameters -The following is a list of all parameters that can be passed when subscribing to a message. Parameter names are **case-insensitive**, +The following is a list of all parameters that can be passed **when subscribing to a message**. Parameter names are **case-insensitive**, and can be passed as **HTTP headers** or **query parameters in the URL**. They are listed in the table in their canonical form. -| Parameter | Aliases (case-insensitive) | Description | -|---|---|---| -| `poll` | `X-Poll`, `po` | Return cached messages and close connection | -| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list | -| `message` | `X-Message`, `m` | Filter: Only return messages that match this exact message string | -| `title` | `X-Title`, `t` | Filter: Only return messages that match this exact title string | -| `priority` | `X-Priority`, `prio`, `p` | Filter: Only return messages that match *any priority listed* (comma-separated) | -| `tags` | `X-Tags`, `tag`, `ta` | Filter: Only return messages that match *all listed tags* (comma-separated) | +| Parameter | Aliases (case-insensitive) | Description | +|-------------|----------------------------|---------------------------------------------------------------------------------| +| `poll` | `X-Poll`, `po` | Return cached messages and close connection | +| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list | +| `message` | `X-Message`, `m` | Filter: Only return messages that match this exact message string | +| `title` | `X-Title`, `t` | Filter: Only return messages that match this exact title string | +| `priority` | `X-Priority`, `prio`, `p` | Filter: Only return messages that match *any priority listed* (comma-separated) | +| `tags` | `X-Tags`, `tag`, `ta` | Filter: Only return messages that match *all listed tags* (comma-separated) | diff --git a/docs/subscribe/cli.md b/docs/subscribe/cli.md index 488398b9..2d3f83b4 100644 --- a/docs/subscribe/cli.md +++ b/docs/subscribe/cli.md @@ -103,16 +103,16 @@ The message fields are passed to the command as environment variables and can be these are environment variables, you typically don't have to worry about quoting too much, as long as you enclose them in double-quotes, you should be fine: -| Variable | Aliases | Description | -|---|---|--- -| `$NTFY_ID` | `$id` | Unique message ID | -| `$NTFY_TIME` | `$time` | Unix timestamp of the message delivery | -| `$NTFY_TOPIC` | `$topic` | Topic name | -| `$NTFY_MESSAGE` | `$message`, `$m` | Message body | -| `$NTFY_TITLE` | `$title`, `$t` | Message title | -| `$NTFY_PRIORITY` | `$priority`, `$prio`, `$p` | Message priority (1=min, 5=max) | -| `$NTFY_TAGS` | `$tags`, `$tag`, `$ta` | Message tags (comma separated list) | -| `$NTFY_RAW` | `$raw` | Raw JSON message | +| Variable | Aliases | Description | +|------------------|----------------------------|----------------------------------------| +| `$NTFY_ID` | `$id` | Unique message ID | +| `$NTFY_TIME` | `$time` | Unix timestamp of the message delivery | +| `$NTFY_TOPIC` | `$topic` | Topic name | +| `$NTFY_MESSAGE` | `$message`, `$m` | Message body | +| `$NTFY_TITLE` | `$title`, `$t` | Message title | +| `$NTFY_PRIORITY` | `$priority`, `$prio`, `$p` | Message priority (1=min, 5=max) | +| `$NTFY_TAGS` | `$tags`, `$tag`, `$ta` | Message tags (comma separated list) | +| `$NTFY_RAW` | `$raw` | Raw JSON message | ### Subscribe to multiple topics ```