diff --git a/.goreleaser.yml b/.goreleaser.yml index fa423a86..f0cf08f6 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,76 +1,70 @@ +version: 2 before: hooks: - go mod download - go mod tidy builds: - - - id: ntfy_linux_amd64 + - id: ntfy_linux_amd64 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [amd64] - - - id: ntfy_linux_armv6 + goos: [ linux ] + goarch: [ amd64 ] + - id: ntfy_linux_armv6 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [arm] - goarm: [6] - - - id: ntfy_linux_armv7 + goos: [ linux ] + goarch: [ arm ] + goarm: [ 6 ] + - id: ntfy_linux_armv7 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [arm] - goarm: [7] - - - id: ntfy_linux_arm64 + goos: [ linux ] + goarch: [ arm ] + goarm: [ 7 ] + - id: ntfy_linux_arm64 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - CC=aarch64-linux-gnu-gcc # apt install gcc-aarch64-linux-gnu - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [arm64] - - - id: ntfy_windows_amd64 + goos: [ linux ] + goarch: [ arm64 ] + - id: ntfy_windows_amd64 binary: ntfy env: - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3 - tags: [noserver] # don't include server files + tags: [ noserver ] # don't include server files ldflags: - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [windows] - goarch: [amd64] - - - id: ntfy_darwin_all + goos: [ windows ] + goarch: [ amd64 ] + - id: ntfy_darwin_all binary: ntfy env: - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3 - tags: [noserver] # don't include server files + tags: [ noserver ] # don't include server files ldflags: - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [darwin] - goarch: [amd64, arm64] # will be combined to "universal binary" (see below) + goos: [ darwin ] + goarch: [ amd64, arm64 ] # will be combined to "universal binary" (see below) nfpms: - - - package_name: ntfy + - package_name: ntfy homepage: https://heckel.io/ntfy maintainer: Philipp C. Heckel description: Simple pub-sub notification service @@ -106,9 +100,8 @@ nfpms: preremove: "scripts/prerm.sh" postremove: "scripts/postrm.sh" archives: - - - id: ntfy_linux - builds: + - id: ntfy_linux + ids: - ntfy_linux_amd64 - ntfy_linux_armv6 - ntfy_linux_armv7 @@ -122,19 +115,17 @@ archives: - client/client.yml - client/ntfy-client.service - client/user/ntfy-client.service - - - id: ntfy_windows - builds: + - id: ntfy_windows + ids: - ntfy_windows_amd64 - format: zip + formats: [ zip ] wrap_in_directory: true files: - LICENSE - README.md - client/client.yml - - - id: ntfy_darwin - builds: + - id: ntfy_darwin + ids: - ntfy_darwin_all wrap_in_directory: true files: @@ -142,14 +133,13 @@ archives: - README.md - client/client.yml universal_binaries: - - - id: ntfy_darwin_all + - id: ntfy_darwin_all replace: true name_template: ntfy checksum: name_template: 'checksums.txt' snapshot: - name_template: "{{ .Tag }}-next" + version_template: "{{ .Tag }}-next" changelog: sort: asc filters: diff --git a/Makefile b/Makefile index 4355423e..df131c7a 100644 --- a/Makefile +++ b/Makefile @@ -220,7 +220,7 @@ cli-deps-static-sites: touch server/docs/index.html server/site/app.html cli-deps-all: - go install github.com/goreleaser/goreleaser@latest + go install github.com/goreleaser/goreleaser/v2@latest cli-deps-gcc-armv6-armv7: which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; } @@ -232,7 +232,7 @@ cli-deps-update: go get -u go install honnef.co/go/tools/cmd/staticcheck@latest go install golang.org/x/lint/golint@latest - go install github.com/goreleaser/goreleaser@latest + go install github.com/goreleaser/goreleaser/v2@latest cli-build-results: cat dist/config.yaml @@ -301,7 +301,7 @@ release: clean cli-deps release-checks docs web check goreleaser release --clean release-snapshot: clean cli-deps docs web check - goreleaser release --snapshot --skip-publish --clean + goreleaser release --snapshot --clean release-checks: $(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-)) diff --git a/README.md b/README.md index 07d983f6..9942e138 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,9 @@ account costs. Even small donations are very much appreciated. Thank you to our commercial sponsors, who help keep the service running and the development going: -
- + + + And a big fat **Thank You** to the individuals who have sponsored ntfy in the past, or are still sponsoring ntfy: @@ -252,3 +253,4 @@ Third-party libraries and resources: * [Statically linking go-sqlite3](https://www.arp242.net/static-go.html) * [Linked tabs in mkdocs](https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs) * [webpush-go](https://github.com/SherClockHolmes/webpush-go) (MIT) is used to send web push notifications +* [Sprig](https://github.com/Masterminds/sprig) (MIT) is used to add template parsing functions diff --git a/client/options.go b/client/options.go index 027b7fb5..f4711834 100644 --- a/client/options.go +++ b/client/options.go @@ -77,6 +77,12 @@ func WithMarkdown() PublishOption { return WithHeader("X-Markdown", "yes") } +// WithTemplate instructs the server to use a specific template for the message. If templateName is is "yes" or "1", +// the server will interpret the message and title as a template. +func WithTemplate(templateName string) PublishOption { + return WithHeader("X-Template", templateName) +} + // WithFilename sets a filename for the attachment, and/or forces the HTTP body to interpreted as an attachment func WithFilename(filename string) PublishOption { return WithHeader("X-Filename", filename) diff --git a/cmd/access.go b/cmd/access.go index c6be94b5..51d367a3 100644 --- a/cmd/access.go +++ b/cmd/access.go @@ -105,8 +105,10 @@ func changeAccess(c *cli.Context, manager *user.Manager, username string, topic return err } u, err := manager.User(username) - if err == user.ErrUserNotFound { + if errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) + } else if err != nil { + return err } else if u.Role == user.RoleAdmin { return fmt.Errorf("user %s is an admin user, access control entries have no effect", username) } @@ -114,13 +116,13 @@ func changeAccess(c *cli.Context, manager *user.Manager, username string, topic return err } if permission.IsReadWrite() { - fmt.Fprintf(c.App.ErrWriter, "granted read-write access to topic %s\n\n", topic) + fmt.Fprintf(c.App.Writer, "granted read-write access to topic %s\n\n", topic) } else if permission.IsRead() { - fmt.Fprintf(c.App.ErrWriter, "granted read-only access to topic %s\n\n", topic) + fmt.Fprintf(c.App.Writer, "granted read-only access to topic %s\n\n", topic) } else if permission.IsWrite() { - fmt.Fprintf(c.App.ErrWriter, "granted write-only access to topic %s\n\n", topic) + fmt.Fprintf(c.App.Writer, "granted write-only access to topic %s\n\n", topic) } else { - fmt.Fprintf(c.App.ErrWriter, "revoked all access to topic %s\n\n", topic) + fmt.Fprintf(c.App.Writer, "revoked all access to topic %s\n\n", topic) } return showUserAccess(c, manager, username) } @@ -138,7 +140,7 @@ func resetAllAccess(c *cli.Context, manager *user.Manager) error { if err := manager.ResetAccess("", ""); err != nil { return err } - fmt.Fprintln(c.App.ErrWriter, "reset access for all users") + fmt.Fprintln(c.App.Writer, "reset access for all users") return nil } @@ -146,7 +148,7 @@ func resetUserAccess(c *cli.Context, manager *user.Manager, username string) err if err := manager.ResetAccess(username, ""); err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "reset access for user %s\n\n", username) + fmt.Fprintf(c.App.Writer, "reset access for user %s\n\n", username) return showUserAccess(c, manager, username) } @@ -154,7 +156,7 @@ func resetUserTopicAccess(c *cli.Context, manager *user.Manager, username string if err := manager.ResetAccess(username, topic); err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "reset access for user %s and topic %s\n\n", username, topic) + fmt.Fprintf(c.App.Writer, "reset access for user %s and topic %s\n\n", username, topic) return showUserAccess(c, manager, username) } @@ -175,7 +177,7 @@ func showAllAccess(c *cli.Context, manager *user.Manager) error { func showUserAccess(c *cli.Context, manager *user.Manager, username string) error { users, err := manager.User(username) - if err == user.ErrUserNotFound { + if errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } else if err != nil { return err @@ -193,34 +195,42 @@ func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error if u.Tier != nil { tier = u.Tier.Name } - fmt.Fprintf(c.App.ErrWriter, "user %s (role: %s, tier: %s)\n", u.Name, u.Role, tier) + provisioned := "" + if u.Provisioned { + provisioned = ", server config" + } + fmt.Fprintf(c.App.Writer, "user %s (role: %s, tier: %s%s)\n", u.Name, u.Role, tier, provisioned) if u.Role == user.RoleAdmin { - fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n") + fmt.Fprintf(c.App.Writer, "- read-write access to all topics (admin role)\n") } else if len(grants) > 0 { for _, grant := range grants { - if grant.Allow.IsReadWrite() { - fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s\n", grant.TopicPattern) - } else if grant.Allow.IsRead() { - fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s\n", grant.TopicPattern) - } else if grant.Allow.IsWrite() { - fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s\n", grant.TopicPattern) + grantProvisioned := "" + if grant.Provisioned { + grantProvisioned = " (server config)" + } + if grant.Permission.IsReadWrite() { + fmt.Fprintf(c.App.Writer, "- read-write access to topic %s%s\n", grant.TopicPattern, grantProvisioned) + } else if grant.Permission.IsRead() { + fmt.Fprintf(c.App.Writer, "- read-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned) + } else if grant.Permission.IsWrite() { + fmt.Fprintf(c.App.Writer, "- write-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned) } else { - fmt.Fprintf(c.App.ErrWriter, "- no access to topic %s\n", grant.TopicPattern) + fmt.Fprintf(c.App.Writer, "- no access to topic %s%s\n", grant.TopicPattern, grantProvisioned) } } } else { - fmt.Fprintf(c.App.ErrWriter, "- no topic-specific permissions\n") + fmt.Fprintf(c.App.Writer, "- no topic-specific permissions\n") } if u.Name == user.Everyone { access := manager.DefaultAccess() if access.IsReadWrite() { - fmt.Fprintln(c.App.ErrWriter, "- read-write access to all (other) topics (server config)") + fmt.Fprintln(c.App.Writer, "- read-write access to all (other) topics (server config)") } else if access.IsRead() { - fmt.Fprintln(c.App.ErrWriter, "- read-only access to all (other) topics (server config)") + fmt.Fprintln(c.App.Writer, "- read-only access to all (other) topics (server config)") } else if access.IsWrite() { - fmt.Fprintln(c.App.ErrWriter, "- write-only access to all (other) topics (server config)") + fmt.Fprintln(c.App.Writer, "- write-only access to all (other) topics (server config)") } else { - fmt.Fprintln(c.App.ErrWriter, "- no access to any (other) topics (server config)") + fmt.Fprintln(c.App.Writer, "- no access to any (other) topics (server config)") } } } diff --git a/cmd/access_test.go b/cmd/access_test.go index 81c9f2b9..8810b6b3 100644 --- a/cmd/access_test.go +++ b/cmd/access_test.go @@ -13,9 +13,9 @@ func TestCLI_Access_Show(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) - app, _, _, stderr := newTestApp() + app, _, stdout, _ := newTestApp() require.Nil(t, runAccessCommand(app, conf)) - require.Contains(t, stderr.String(), "user * (role: anonymous, tier: none)\n- no topic-specific permissions\n- no access to any (other) topics (server config)") + require.Contains(t, stdout.String(), "user * (role: anonymous, tier: none)\n- no topic-specific permissions\n- no access to any (other) topics (server config)") } func TestCLI_Access_Grant_And_Publish(t *testing.T) { @@ -30,7 +30,7 @@ func TestCLI_Access_Grant_And_Publish(t *testing.T) { require.Nil(t, runAccessCommand(app, conf, "ben", "sometopic", "read")) require.Nil(t, runAccessCommand(app, conf, "everyone", "announcements", "read")) - app, _, _, stderr := newTestApp() + app, _, stdout, _ := newTestApp() require.Nil(t, runAccessCommand(app, conf)) expected := `user phil (role: admin, tier: none) - read-write access to all topics (admin role) @@ -41,7 +41,7 @@ user * (role: anonymous, tier: none) - read-only access to topic announcements - no access to any (other) topics (server config) ` - require.Equal(t, expected, stderr.String()) + require.Equal(t, expected, stdout.String()) // See if access permissions match app, _, _, _ = newTestApp() diff --git a/cmd/publish.go b/cmd/publish.go index aaec35e9..f3139a63 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -32,6 +32,7 @@ var flagsPublish = append( &cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"}, &cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"}, &cli.BoolFlag{Name: "markdown", Aliases: []string{"md"}, EnvVars: []string{"NTFY_MARKDOWN"}, Usage: "Message is formatted as Markdown"}, + &cli.StringFlag{Name: "template", Aliases: []string{"tpl"}, EnvVars: []string{"NTFY_TEMPLATE"}, Usage: "use templates to transform JSON message body"}, &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"}, @@ -69,6 +70,7 @@ Examples: ntfy pub --icon="http://some.tld/icon.png" 'Icon!' # Send notification with custom icon 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 + echo 'message' | ntfy publish mytopic # Send message from stdin ntfy pub -u phil:mypass secret Psst # Publish with username/password ntfy pub --wait-pid 1234 mytopic # Wait for process 1234 to exit before publishing ntfy pub --wait-cmd mytopic rsync -av ./ /tmp/a # Run command and publish after it completes @@ -97,6 +99,7 @@ func execPublish(c *cli.Context) error { actions := c.String("actions") attach := c.String("attach") markdown := c.Bool("markdown") + template := c.String("template") filename := c.String("filename") file := c.String("file") email := c.String("email") @@ -145,6 +148,9 @@ func execPublish(c *cli.Context) error { if markdown { options = append(options, client.WithMarkdown()) } + if template != "" { + options = append(options, client.WithTemplate(template)) + } if filename != "" { options = append(options, client.WithFilename(filename)) } @@ -254,6 +260,15 @@ func parseTopicMessageCommand(c *cli.Context) (topic string, message string, com if c.String("message") != "" { message = c.String("message") } + if message == "" && isStdinRedirected() { + var data []byte + data, err = io.ReadAll(io.LimitReader(c.App.Reader, 1024*1024)) + if err != nil { + log.Debug("Failed to read from stdin: %s", err.Error()) + return + } + message = strings.TrimSpace(string(data)) + } return } @@ -312,3 +327,12 @@ func runAndWaitForCommand(command []string) (message string, err error) { log.Debug("Command succeeded after %s: %s", runtime, prettyCmd) return fmt.Sprintf("Command succeeded after %s: %s", runtime, prettyCmd), nil } + +func isStdinRedirected() bool { + stat, err := os.Stdin.Stat() + if err != nil { + log.Debug("Failed to stat stdin: %s", err.Error()) + return false + } + return (stat.Mode() & os.ModeCharDevice) == 0 +} diff --git a/cmd/serve.go b/cmd/serve.go index 576e72f0..33d0ed78 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -5,13 +5,6 @@ package cmd import ( "errors" "fmt" - "github.com/stripe/stripe-go/v74" - "github.com/urfave/cli/v2" - "github.com/urfave/cli/v2/altsrc" - "heckel.io/ntfy/v2/log" - "heckel.io/ntfy/v2/server" - "heckel.io/ntfy/v2/user" - "heckel.io/ntfy/v2/util" "io/fs" "math" "net" @@ -22,19 +15,23 @@ import ( "strings" "syscall" "time" + + "github.com/stripe/stripe-go/v74" + "github.com/urfave/cli/v2" + "github.com/urfave/cli/v2/altsrc" + "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/server" + "heckel.io/ntfy/v2/user" + "heckel.io/ntfy/v2/util" ) func init() { commands = append(commands, cmdServe) } -const ( - defaultServerConfigFile = "/etc/ntfy/server.yml" -) - var flagsServe = append( append([]cli.Flag{}, flagsDefault...), - &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, Usage: "config file"}, + &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: server.DefaultConfigFile, Usage: "config file"}, altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used as HTTP listen address"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used as HTTPS listen address"}), @@ -51,10 +48,14 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), + altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-users", Aliases: []string{"auth_users"}, EnvVars: []string{"NTFY_AUTH_USERS"}, Usage: "pre-provisioned declarative users"}), + altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-access", Aliases: []string{"auth_access"}, EnvVars: []string{"NTFY_AUTH_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}), + altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-tokens", Aliases: []string{"auth_tokens"}, EnvVars: []string{"NTFY_AUTH_TOKENS"}, Usage: "pre-provisioned declarative access tokens"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultAttachmentExpiryDuration), Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "template-dir", Aliases: []string{"template_dir"}, EnvVars: []string{"NTFY_TEMPLATE_DIR"}, Value: server.DefaultTemplateDir, Usage: "directory to load named message templates from"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: util.FormatDuration(server.DefaultKeepaliveInterval), Usage: "interval of keepalive messages"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: util.FormatDuration(server.DefaultManagerInterval), Usage: "interval of for message pruning and stats printing"}), altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "disallowed-topics", Aliases: []string{"disallowed_topics"}, EnvVars: []string{"NTFY_DISALLOWED_TOPICS"}, Usage: "topics that are not allowed to be used"}), @@ -79,6 +80,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "message-delay-limit", Aliases: []string{"message_delay_limit"}, EnvVars: []string{"NTFY_MESSAGE_DELAY_LIMIT"}, Value: util.FormatDuration(server.DefaultMessageDelayMax), Usage: "max duration a message can be scheduled into the future"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), + altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultVisitorAttachmentTotalSizeLimit), Usage: "total storage limit used for attachments per visitor"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}), @@ -87,10 +89,11 @@ var flagsServe = append( altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorEmailLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}), - altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv4", Aliases: []string{"visitor_prefix_bits_ipv4"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV4"}, Value: server.DefaultVisitorPrefixBitsIPv4, Usage: "number of bits of the IPv4 address to use for rate limiting (default: 32, full address)"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv6", Aliases: []string{"visitor_prefix_bits_ipv6"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV6"}, Value: server.DefaultVisitorPrefixBitsIPv6, Usage: "number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-addresses", Aliases: []string{"proxy_trusted_addresses"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_ADDRESSES"}, Value: "", Usage: "comma-separated list of trusted IP addresses to remove from forwarded header"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-hosts", Aliases: []string{"proxy_trusted_hosts"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_HOSTS"}, Value: "", Usage: "comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "billing-contact", Aliases: []string{"billing_contact"}, EnvVars: []string{"NTFY_BILLING_CONTACT"}, Value: "", Usage: "e-mail or website to display in upgrade dialog (only if payments are enabled)"}), @@ -154,10 +157,14 @@ func execServe(c *cli.Context) error { authFile := c.String("auth-file") authStartupQueries := c.String("auth-startup-queries") authDefaultAccess := c.String("auth-default-access") + authUsersRaw := c.StringSlice("auth-users") + authAccessRaw := c.StringSlice("auth-access") + authTokensRaw := c.StringSlice("auth-tokens") attachmentCacheDir := c.String("attachment-cache-dir") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") attachmentExpiryDurationStr := c.String("attachment-expiry-duration") + templateDir := c.String("template-dir") keepaliveIntervalStr := c.String("keepalive-interval") managerIntervalStr := c.String("manager-interval") disallowedTopics := c.StringSlice("disallowed-topics") @@ -191,9 +198,11 @@ func execServe(c *cli.Context) error { visitorMessageDailyLimit := c.Int("visitor-message-daily-limit") visitorEmailLimitBurst := c.Int("visitor-email-limit-burst") visitorEmailLimitReplenishStr := c.String("visitor-email-limit-replenish") + visitorPrefixBitsIPv4 := c.Int("visitor-prefix-bits-ipv4") + visitorPrefixBitsIPv6 := c.Int("visitor-prefix-bits-ipv6") behindProxy := c.Bool("behind-proxy") proxyForwardedHeader := c.String("proxy-forwarded-header") - proxyTrustedAddresses := util.SplitNoEmpty(c.String("proxy-trusted-addresses"), ",") + proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",") stripeSecretKey := c.String("stripe-secret-key") stripeWebhookKey := c.String("stripe-webhook-key") billingContact := c.String("billing-contact") @@ -324,6 +333,10 @@ func execServe(c *cli.Context) error { return errors.New("web push expiry warning duration cannot be higher than web push expiry duration") } else if behindProxy && proxyForwardedHeader == "" { return errors.New("if behind-proxy is set, proxy-forwarded-header must also be set") + } else if visitorPrefixBitsIPv4 < 1 || visitorPrefixBitsIPv4 > 32 { + return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32") + } else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 { + return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 128") } // Backwards compatibility @@ -337,11 +350,23 @@ func execServe(c *cli.Context) error { webRoot = "/" + webRoot } - // Default auth permissions + // Convert default auth permission, read provisioned users authDefault, err := user.ParsePermission(authDefaultAccess) if err != nil { return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") } + authUsers, err := parseUsers(authUsersRaw) + if err != nil { + return err + } + authAccess, err := parseAccess(authUsers, authAccessRaw) + if err != nil { + return err + } + authTokens, err := parseTokens(authUsers, authTokensRaw) + if err != nil { + return err + } // Special case: Unset default if listenHTTP == "-" { @@ -349,14 +374,24 @@ func execServe(c *cli.Context) error { } // Resolve hosts - visitorRequestLimitExemptIPs := make([]netip.Prefix, 0) + visitorRequestLimitExemptPrefixes := make([]netip.Prefix, 0) for _, host := range visitorRequestLimitExemptHosts { - ips, err := parseIPHostPrefix(host) + prefixes, err := parseIPHostPrefix(host) if err != nil { log.Warn("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error()) continue } - visitorRequestLimitExemptIPs = append(visitorRequestLimitExemptIPs, ips...) + visitorRequestLimitExemptPrefixes = append(visitorRequestLimitExemptPrefixes, prefixes...) + } + + // Parse trusted prefixes + trustedProxyPrefixes := make([]netip.Prefix, 0) + for _, host := range proxyTrustedHosts { + prefixes, err := parseIPHostPrefix(host) + if err != nil { + return fmt.Errorf("cannot resolve trusted proxy host %s: %s", host, err.Error()) + } + trustedProxyPrefixes = append(trustedProxyPrefixes, prefixes...) } // Stripe things @@ -387,10 +422,14 @@ func execServe(c *cli.Context) error { conf.AuthFile = authFile conf.AuthStartupQueries = authStartupQueries conf.AuthDefault = authDefault + conf.AuthUsers = authUsers + conf.AuthAccess = authAccess + conf.AuthTokens = authTokens conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit conf.AttachmentExpiryDuration = attachmentExpiryDuration + conf.TemplateDir = templateDir conf.KeepaliveInterval = keepaliveInterval conf.ManagerInterval = managerInterval conf.DisallowedTopics = disallowedTopics @@ -412,18 +451,20 @@ func execServe(c *cli.Context) error { conf.MessageDelayMax = messageDelayLimit conf.TotalTopicLimit = totalTopicLimit conf.VisitorSubscriptionLimit = visitorSubscriptionLimit + conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit conf.VisitorAttachmentDailyBandwidthLimit = visitorAttachmentDailyBandwidthLimit conf.VisitorRequestLimitBurst = visitorRequestLimitBurst conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish - conf.VisitorRequestExemptIPAddrs = visitorRequestLimitExemptIPs + conf.VisitorRequestExemptPrefixes = visitorRequestLimitExemptPrefixes conf.VisitorMessageDailyLimit = visitorMessageDailyLimit conf.VisitorEmailLimitBurst = visitorEmailLimitBurst conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish - conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting + conf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4 + conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6 conf.BehindProxy = behindProxy conf.ProxyForwardedHeader = proxyForwardedHeader - conf.ProxyTrustedAddresses = proxyTrustedAddresses + conf.ProxyTrustedPrefixes = trustedProxyPrefixes conf.StripeSecretKey = stripeSecretKey conf.StripeWebhookKey = stripeWebhookKey conf.BillingContact = billingContact @@ -433,7 +474,6 @@ func execServe(c *cli.Context) error { conf.EnableMetrics = enableMetrics conf.MetricsListenHTTP = metricsListenHTTP conf.ProfileListenHTTP = profileListenHTTP - conf.Version = c.App.Version conf.WebPushPrivateKey = webPushPrivateKey conf.WebPushPublicKey = webPushPublicKey conf.WebPushFile = webPushFile @@ -441,6 +481,7 @@ func execServe(c *cli.Context) error { conf.WebPushStartupQueries = webPushStartupQueries conf.WebPushExpiryDuration = webPushExpiryDuration conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration + conf.Version = c.App.Version // Set up hot-reloading of config go sigHandlerConfigReload(config) @@ -473,7 +514,7 @@ func sigHandlerConfigReload(config string) { } func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) { - // Try parsing as prefix, e.g. 10.0.1.0/24 + // Try parsing as prefix, e.g. 10.0.1.0/24 or 2001:db8::/32 prefix, err := netip.ParsePrefix(host) if err == nil { prefixes = append(prefixes, prefix.Masked()) @@ -497,6 +538,112 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) { return } +func parseUsers(usersRaw []string) ([]*user.User, error) { + users := make([]*user.User, 0) + for _, userLine := range usersRaw { + parts := strings.Split(userLine, ":") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid auth-users: %s, expected format: 'name:hash:role'", userLine) + } + username := strings.TrimSpace(parts[0]) + passwordHash := strings.TrimSpace(parts[1]) + role := user.Role(strings.TrimSpace(parts[2])) + if !user.AllowedUsername(username) { + return nil, fmt.Errorf("invalid auth-users: %s, username invalid", userLine) + } else if err := user.ValidPasswordHash(passwordHash); err != nil { + return nil, fmt.Errorf("invalid auth-users: %s, %s", userLine, err.Error()) + } else if !user.AllowedRole(role) { + return nil, fmt.Errorf("invalid auth-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role) + } + users = append(users, &user.User{ + Name: username, + Hash: passwordHash, + Role: role, + Provisioned: true, + }) + } + return users, nil +} + +func parseAccess(users []*user.User, accessRaw []string) (map[string][]*user.Grant, error) { + access := make(map[string][]*user.Grant) + for _, accessLine := range accessRaw { + parts := strings.Split(accessLine, ":") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid auth-access: %s, expected format: 'user:topic:permission'", accessLine) + } + username := strings.TrimSpace(parts[0]) + if username == userEveryone { + username = user.Everyone + } + u, exists := util.Find(users, func(u *user.User) bool { + return u.Name == username + }) + if username != user.Everyone { + if !exists { + return nil, fmt.Errorf("invalid auth-access: %s, user %s is not provisioned", accessLine, username) + } else if !user.AllowedUsername(username) { + return nil, fmt.Errorf("invalid auth-access: %s, username %s invalid", accessLine, username) + } else if u.Role != user.RoleUser { + return nil, fmt.Errorf("invalid auth-access: %s, user %s is not a regular user, only regular users can have ACL entries", accessLine, username) + } + } + topic := strings.TrimSpace(parts[1]) + if !user.AllowedTopicPattern(topic) { + return nil, fmt.Errorf("invalid auth-access: %s, topic pattern %s invalid", accessLine, topic) + } + permission, err := user.ParsePermission(strings.TrimSpace(parts[2])) + if err != nil { + return nil, fmt.Errorf("invalid auth-access: %s, permission %s invalid, %s", accessLine, parts[2], err.Error()) + } + if _, exists := access[username]; !exists { + access[username] = make([]*user.Grant, 0) + } + access[username] = append(access[username], &user.Grant{ + TopicPattern: topic, + Permission: permission, + Provisioned: true, + }) + } + return access, nil +} + +func parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Token, error) { + tokens := make(map[string][]*user.Token) + for _, tokenLine := range tokensRaw { + parts := strings.Split(tokenLine, ":") + if len(parts) < 2 || len(parts) > 3 { + return nil, fmt.Errorf("invalid auth-tokens: %s, expected format: 'user:token[:label]'", tokenLine) + } + username := strings.TrimSpace(parts[0]) + _, exists := util.Find(users, func(u *user.User) bool { + return u.Name == username + }) + if !exists { + return nil, fmt.Errorf("invalid auth-tokens: %s, user %s is not provisioned", tokenLine, username) + } else if !user.AllowedUsername(username) { + return nil, fmt.Errorf("invalid auth-tokens: %s, username %s invalid", tokenLine, username) + } + token := strings.TrimSpace(parts[1]) + if !user.ValidToken(token) { + return nil, fmt.Errorf("invalid auth-tokens: %s, token %s invalid, use 'ntfy token generate' to generate a random token", tokenLine, token) + } + var label string + if len(parts) > 2 { + label = parts[2] + } + if _, exists := tokens[username]; !exists { + tokens[username] = make([]*user.Token, 0) + } + tokens[username] = append(tokens[username], &user.Token{ + Value: token, + Label: label, + Provisioned: true, + }) + } + return tokens, nil +} + func reloadLogLevel(inputSource altsrc.InputSourceContext) error { newLevelStr, err := inputSource.String("log-level") if err != nil { diff --git a/cmd/serve_test.go b/cmd/serve_test.go index 748adbd8..339423b6 100644 --- a/cmd/serve_test.go +++ b/cmd/serve_test.go @@ -14,9 +14,461 @@ import ( "github.com/stretchr/testify/require" "heckel.io/ntfy/v2/client" "heckel.io/ntfy/v2/test" + "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) +func TestParseUsers_Success(t *testing.T) { + tests := []struct { + name string + input []string + expected []*user.User + }{ + { + name: "single user", + input: []string{"alice:$2a$10$abcdefghijklmnopqrstuvwxyz:user"}, + expected: []*user.User{ + { + Name: "alice", + Hash: "$2a$10$abcdefghijklmnopqrstuvwxyz", + Role: user.RoleUser, + Provisioned: true, + }, + }, + }, + { + name: "multiple users with different roles", + input: []string{ + "alice:$2a$10$abcdefghijklmnopqrstuvwxyz:user", + "bob:$2b$10$abcdefghijklmnopqrstuvwxyz:admin", + }, + expected: []*user.User{ + { + Name: "alice", + Hash: "$2a$10$abcdefghijklmnopqrstuvwxyz", + Role: user.RoleUser, + Provisioned: true, + }, + { + Name: "bob", + Hash: "$2b$10$abcdefghijklmnopqrstuvwxyz", + Role: user.RoleAdmin, + Provisioned: true, + }, + }, + }, + { + name: "empty input", + input: []string{}, + expected: []*user.User{}, + }, + { + name: "user with special characters in name", + input: []string{"alice.test+123@example.com:$2y$10$abcdefghijklmnopqrstuvwxyz:user"}, + expected: []*user.User{ + { + Name: "alice.test+123@example.com", + Hash: "$2y$10$abcdefghijklmnopqrstuvwxyz", + Role: user.RoleUser, + Provisioned: true, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseUsers(tt.input) + require.NoError(t, err) + require.Len(t, result, len(tt.expected)) + + for i, expectedUser := range tt.expected { + assert.Equal(t, expectedUser.Name, result[i].Name) + assert.Equal(t, expectedUser.Hash, result[i].Hash) + assert.Equal(t, expectedUser.Role, result[i].Role) + assert.Equal(t, expectedUser.Provisioned, result[i].Provisioned) + } + }) + } +} + +func TestParseUsers_Errors(t *testing.T) { + tests := []struct { + name string + input []string + error string + }{ + { + name: "invalid format - too few parts", + input: []string{"alice:hash"}, + error: "invalid auth-users: alice:hash, expected format: 'name:hash:role'", + }, + { + name: "invalid format - too many parts", + input: []string{"alice:hash:role:extra"}, + error: "invalid auth-users: alice:hash:role:extra, expected format: 'name:hash:role'", + }, + { + name: "invalid username", + input: []string{"alice@#$%:$2a$10$abcdefghijklmnopqrstuvwxyz:user"}, + error: "invalid auth-users: alice@#$%:$2a$10$abcdefghijklmnopqrstuvwxyz:user, username invalid", + }, + { + name: "invalid password hash - wrong prefix", + input: []string{"alice:plaintext:user"}, + error: "invalid auth-users: alice:plaintext:user, password hash but be a bcrypt hash, use 'ntfy user hash' to generate", + }, + { + name: "invalid role", + input: []string{"alice:$2a$10$abcdefghijklmnopqrstuvwxyz:invalid"}, + error: "invalid auth-users: alice:$2a$10$abcdefghijklmnopqrstuvwxyz:invalid, role invalid is not allowed, allowed roles are 'admin' or 'user'", + }, + { + name: "empty username", + input: []string{":$2a$10$abcdefghijklmnopqrstuvwxyz:user"}, + error: "invalid auth-users: :$2a$10$abcdefghijklmnopqrstuvwxyz:user, username invalid", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseUsers(tt.input) + require.Error(t, err) + require.Nil(t, result) + assert.Contains(t, err.Error(), tt.error) + }) + } +} + +func TestParseAccess_Success(t *testing.T) { + users := []*user.User{ + {Name: "alice", Role: user.RoleUser}, + {Name: "bob", Role: user.RoleUser}, + } + + tests := []struct { + name string + users []*user.User + input []string + expected map[string][]*user.Grant + }{ + { + name: "single access entry", + users: users, + input: []string{"alice:mytopic:read-write"}, + expected: map[string][]*user.Grant{ + "alice": { + { + TopicPattern: "mytopic", + Permission: user.PermissionReadWrite, + Provisioned: true, + }, + }, + }, + }, + { + name: "multiple access entries for same user", + users: users, + input: []string{ + "alice:topic1:read-only", + "alice:topic2:write-only", + }, + expected: map[string][]*user.Grant{ + "alice": { + { + TopicPattern: "topic1", + Permission: user.PermissionRead, + Provisioned: true, + }, + { + TopicPattern: "topic2", + Permission: user.PermissionWrite, + Provisioned: true, + }, + }, + }, + }, + { + name: "access for everyone", + users: users, + input: []string{"everyone:publictopic:read-only"}, + expected: map[string][]*user.Grant{ + user.Everyone: { + { + TopicPattern: "publictopic", + Permission: user.PermissionRead, + Provisioned: true, + }, + }, + }, + }, + { + name: "wildcard topic pattern", + users: users, + input: []string{"alice:topic*:read-write"}, + expected: map[string][]*user.Grant{ + "alice": { + { + TopicPattern: "topic*", + Permission: user.PermissionReadWrite, + Provisioned: true, + }, + }, + }, + }, + { + name: "empty input", + users: users, + input: []string{}, + expected: map[string][]*user.Grant{}, + }, + { + name: "deny-all permission", + users: users, + input: []string{"alice:secretopic:deny-all"}, + expected: map[string][]*user.Grant{ + "alice": { + { + TopicPattern: "secretopic", + Permission: user.PermissionDenyAll, + Provisioned: true, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseAccess(tt.users, tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseAccess_Errors(t *testing.T) { + users := []*user.User{ + {Name: "alice", Role: user.RoleUser}, + {Name: "admin", Role: user.RoleAdmin}, + } + + tests := []struct { + name string + users []*user.User + input []string + error string + }{ + { + name: "invalid format - too few parts", + users: users, + input: []string{"alice:topic"}, + error: "invalid auth-access: alice:topic, expected format: 'user:topic:permission'", + }, + { + name: "invalid format - too many parts", + users: users, + input: []string{"alice:topic:read:extra"}, + error: "invalid auth-access: alice:topic:read:extra, expected format: 'user:topic:permission'", + }, + { + name: "user not provisioned", + users: users, + input: []string{"charlie:topic:read"}, + error: "invalid auth-access: charlie:topic:read, user charlie is not provisioned", + }, + { + name: "admin user cannot have ACL entries", + users: users, + input: []string{"admin:topic:read"}, + error: "invalid auth-access: admin:topic:read, user admin is not a regular user, only regular users can have ACL entries", + }, + { + name: "invalid topic pattern", + users: users, + input: []string{"alice:topic-with-invalid-chars!:read"}, + error: "invalid auth-access: alice:topic-with-invalid-chars!:read, topic pattern topic-with-invalid-chars! invalid", + }, + { + name: "invalid permission", + users: users, + input: []string{"alice:topic:invalid-permission"}, + error: "invalid auth-access: alice:topic:invalid-permission, permission invalid-permission invalid", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseAccess(tt.users, tt.input) + require.Error(t, err) + require.Nil(t, result) + assert.Contains(t, err.Error(), tt.error) + }) + } +} + +func TestParseTokens_Success(t *testing.T) { + users := []*user.User{ + {Name: "alice"}, + {Name: "bob"}, + } + + tests := []struct { + name string + users []*user.User + input []string + expected map[string][]*user.Token + }{ + { + name: "single token without label", + users: users, + input: []string{"alice:tk_abcdefghijklmnopqrstuvwxyz123"}, + expected: map[string][]*user.Token{ + "alice": { + { + Value: "tk_abcdefghijklmnopqrstuvwxyz123", + Label: "", + Provisioned: true, + }, + }, + }, + }, + { + name: "single token with label", + users: users, + input: []string{"alice:tk_abcdefghijklmnopqrstuvwxyz123:My Phone"}, + expected: map[string][]*user.Token{ + "alice": { + { + Value: "tk_abcdefghijklmnopqrstuvwxyz123", + Label: "My Phone", + Provisioned: true, + }, + }, + }, + }, + { + name: "multiple tokens for same user", + users: users, + input: []string{ + "alice:tk_abcdefghijklmnopqrstuvwxyz123:Phone", + "alice:tk_zyxwvutsrqponmlkjihgfedcba987:Laptop", + }, + expected: map[string][]*user.Token{ + "alice": { + { + Value: "tk_abcdefghijklmnopqrstuvwxyz123", + Label: "Phone", + Provisioned: true, + }, + { + Value: "tk_zyxwvutsrqponmlkjihgfedcba987", + Label: "Laptop", + Provisioned: true, + }, + }, + }, + }, + { + name: "tokens for multiple users", + users: users, + input: []string{ + "alice:tk_abcdefghijklmnopqrstuvwxyz123:Phone", + "bob:tk_zyxwvutsrqponmlkjihgfedcba987:Tablet", + }, + expected: map[string][]*user.Token{ + "alice": { + { + Value: "tk_abcdefghijklmnopqrstuvwxyz123", + Label: "Phone", + Provisioned: true, + }, + }, + "bob": { + { + Value: "tk_zyxwvutsrqponmlkjihgfedcba987", + Label: "Tablet", + Provisioned: true, + }, + }, + }, + }, + { + name: "empty input", + users: users, + input: []string{}, + expected: map[string][]*user.Token{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseTokens(tt.users, tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseTokens_Errors(t *testing.T) { + users := []*user.User{ + {Name: "alice"}, + } + + tests := []struct { + name string + users []*user.User + input []string + error string + }{ + { + name: "invalid format - too few parts", + users: users, + input: []string{"alice"}, + error: "invalid auth-tokens: alice, expected format: 'user:token[:label]'", + }, + { + name: "invalid format - too many parts", + users: users, + input: []string{"alice:token:label:extra:parts"}, + error: "invalid auth-tokens: alice:token:label:extra:parts, expected format: 'user:token[:label]'", + }, + { + name: "user not provisioned", + users: users, + input: []string{"charlie:tk_abcdefghijklmnopqrstuvwxyz123"}, + error: "invalid auth-tokens: charlie:tk_abcdefghijklmnopqrstuvwxyz123, user charlie is not provisioned", + }, + { + name: "invalid token format", + users: users, + input: []string{"alice:invalid-token"}, + error: "invalid auth-tokens: alice:invalid-token, token invalid-token invalid, use 'ntfy token generate' to generate a random token", + }, + { + name: "token too short", + users: users, + input: []string{"alice:tk_short"}, + error: "invalid auth-tokens: alice:tk_short, token tk_short invalid, use 'ntfy token generate' to generate a random token", + }, + { + name: "token without prefix", + users: users, + input: []string{"alice:abcdefghijklmnopqrstuvwxyz12345"}, + error: "invalid auth-tokens: alice:abcdefghijklmnopqrstuvwxyz12345, token abcdefghijklmnopqrstuvwxyz12345 invalid, use 'ntfy token generate' to generate a random token", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseTokens(tt.users, tt.input) + require.Error(t, err) + require.Nil(t, result) + assert.Contains(t, err.Error(), tt.error) + }) + } +} + func TestCLI_Serve_Unix_Curl(t *testing.T) { sockFile := filepath.Join(t.TempDir(), "ntfy.sock") configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system diff --git a/cmd/tier.go b/cmd/tier.go index 3b45eaa7..de34576e 100644 --- a/cmd/tier.go +++ b/cmd/tier.go @@ -182,7 +182,7 @@ func execTierAdd(c *cli.Context) error { } if tier, _ := manager.Tier(code); tier != nil { if c.Bool("ignore-exists") { - fmt.Fprintf(c.App.ErrWriter, "tier %s already exists (exited successfully)\n", code) + fmt.Fprintf(c.App.Writer, "tier %s already exists (exited successfully)\n", code) return nil } return fmt.Errorf("tier %s already exists", code) @@ -234,7 +234,7 @@ func execTierAdd(c *cli.Context) error { if err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "tier added\n\n") + fmt.Fprintf(c.App.Writer, "tier added\n\n") printTier(c, tier) return nil } @@ -315,7 +315,7 @@ func execTierChange(c *cli.Context) error { if err := manager.UpdateTier(tier); err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "tier updated\n\n") + fmt.Fprintf(c.App.Writer, "tier updated\n\n") printTier(c, tier) return nil } @@ -335,7 +335,7 @@ func execTierDel(c *cli.Context) error { if err := manager.RemoveTier(code); err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "tier %s removed\n", code) + fmt.Fprintf(c.App.Writer, "tier %s removed\n", code) return nil } @@ -359,16 +359,16 @@ func printTier(c *cli.Context, tier *user.Tier) { if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID != "" { prices = fmt.Sprintf("%s / %s", tier.StripeMonthlyPriceID, tier.StripeYearlyPriceID) } - fmt.Fprintf(c.App.ErrWriter, "tier %s (id: %s)\n", tier.Code, tier.ID) - fmt.Fprintf(c.App.ErrWriter, "- Name: %s\n", tier.Name) - fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit) - fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds())) - fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit) - fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit) - fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit) - fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSizeHuman(tier.AttachmentFileSizeLimit)) - fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit)) - fmt.Fprintf(c.App.ErrWriter, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds())) - fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSizeHuman(tier.AttachmentBandwidthLimit)) - fmt.Fprintf(c.App.ErrWriter, "- Stripe prices (monthly/yearly): %s\n", prices) + fmt.Fprintf(c.App.Writer, "tier %s (id: %s)\n", tier.Code, tier.ID) + fmt.Fprintf(c.App.Writer, "- Name: %s\n", tier.Name) + fmt.Fprintf(c.App.Writer, "- Message limit: %d\n", tier.MessageLimit) + fmt.Fprintf(c.App.Writer, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds())) + fmt.Fprintf(c.App.Writer, "- Email limit: %d\n", tier.EmailLimit) + fmt.Fprintf(c.App.Writer, "- Phone call limit: %d\n", tier.CallLimit) + fmt.Fprintf(c.App.Writer, "- Reservation limit: %d\n", tier.ReservationLimit) + fmt.Fprintf(c.App.Writer, "- Attachment file size limit: %s\n", util.FormatSizeHuman(tier.AttachmentFileSizeLimit)) + fmt.Fprintf(c.App.Writer, "- Attachment total size limit: %s\n", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit)) + fmt.Fprintf(c.App.Writer, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds())) + fmt.Fprintf(c.App.Writer, "- Attachment daily bandwidth limit: %s\n", util.FormatSizeHuman(tier.AttachmentBandwidthLimit)) + fmt.Fprintf(c.App.Writer, "- Stripe prices (monthly/yearly): %s\n", prices) } diff --git a/cmd/tier_test.go b/cmd/tier_test.go index 145f273e..8ca2b768 100644 --- a/cmd/tier_test.go +++ b/cmd/tier_test.go @@ -12,21 +12,21 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) - app, _, _, stderr := newTestApp() + app, _, stdout, _ := newTestApp() require.Nil(t, runTierCommand(app, conf, "add", "--name", "Pro", "--message-limit", "1234", "pro")) - require.Contains(t, stderr.String(), "tier added\n\ntier pro (id: ti_") + require.Contains(t, stdout.String(), "tier added\n\ntier pro (id: ti_") err := runTierCommand(app, conf, "add", "pro") require.NotNil(t, err) require.Equal(t, "tier pro already exists", err.Error()) - app, _, _, stderr = newTestApp() + app, _, stdout, _ = newTestApp() require.Nil(t, runTierCommand(app, conf, "list")) - require.Contains(t, stderr.String(), "tier pro (id: ti_") - require.Contains(t, stderr.String(), "- Name: Pro") - require.Contains(t, stderr.String(), "- Message limit: 1234") + require.Contains(t, stdout.String(), "tier pro (id: ti_") + require.Contains(t, stdout.String(), "- Name: Pro") + require.Contains(t, stdout.String(), "- Message limit: 1234") - app, _, _, stderr = newTestApp() + app, _, stdout, _ = newTestApp() require.Nil(t, runTierCommand(app, conf, "change", "--message-limit=999", "--message-expiry-duration=2d", @@ -40,18 +40,18 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) { "--stripe-yearly-price-id=price_992", "pro", )) - require.Contains(t, stderr.String(), "- Message limit: 999") - require.Contains(t, stderr.String(), "- Message expiry duration: 48h") - require.Contains(t, stderr.String(), "- Email limit: 91") - require.Contains(t, stderr.String(), "- Reservation limit: 98") - require.Contains(t, stderr.String(), "- Attachment file size limit: 100.0 MB") - require.Contains(t, stderr.String(), "- Attachment expiry duration: 24h") - require.Contains(t, stderr.String(), "- Attachment total size limit: 10.0 GB") - require.Contains(t, stderr.String(), "- Stripe prices (monthly/yearly): price_991 / price_992") + require.Contains(t, stdout.String(), "- Message limit: 999") + require.Contains(t, stdout.String(), "- Message expiry duration: 48h") + require.Contains(t, stdout.String(), "- Email limit: 91") + require.Contains(t, stdout.String(), "- Reservation limit: 98") + require.Contains(t, stdout.String(), "- Attachment file size limit: 100.0 MB") + require.Contains(t, stdout.String(), "- Attachment expiry duration: 24h") + require.Contains(t, stdout.String(), "- Attachment total size limit: 10.0 GB") + require.Contains(t, stdout.String(), "- Stripe prices (monthly/yearly): price_991 / price_992") - app, _, _, stderr = newTestApp() + app, _, stdout, _ = newTestApp() require.Nil(t, runTierCommand(app, conf, "remove", "pro")) - require.Contains(t, stderr.String(), "tier pro removed") + require.Contains(t, stdout.String(), "tier pro removed") } func runTierCommand(app *cli.App, conf *server.Config, args ...string) error { diff --git a/cmd/token.go b/cmd/token.go index cb92a130..b0393b88 100644 --- a/cmd/token.go +++ b/cmd/token.go @@ -72,6 +72,15 @@ Example: This is a server-only command. It directly reads from user.db as defined in the server config file server.yml. The command only works if 'auth-file' is properly defined.`, }, + { + Name: "generate", + Usage: "Generates a random token", + Action: execTokenGenerate, + Description: `Randomly generate a token to be used in provisioned tokens. + +This command only generates the token value, but does not persist it anywhere. +The output can be used in the 'auth-tokens' config option.`, + }, }, Description: `Manage access tokens for individual users. @@ -112,19 +121,19 @@ func execTokenAdd(c *cli.Context) error { return err } u, err := manager.User(username) - if err == user.ErrUserNotFound { + if errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } else if err != nil { return err } - token, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified()) + token, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified(), false) if err != nil { return err } if expires.Unix() == 0 { - fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, never expires\n", token.Value, u.Name) + fmt.Fprintf(c.App.Writer, "token %s created for user %s, never expires\n", token.Value, u.Name) } else { - fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, expires %v\n", token.Value, u.Name, expires.Format(time.UnixDate)) + fmt.Fprintf(c.App.Writer, "token %s created for user %s, expires %v\n", token.Value, u.Name, expires.Format(time.UnixDate)) } return nil } @@ -141,7 +150,7 @@ func execTokenDel(c *cli.Context) error { return err } u, err := manager.User(username) - if err == user.ErrUserNotFound { + if errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } else if err != nil { return err @@ -149,7 +158,7 @@ func execTokenDel(c *cli.Context) error { if err := manager.RemoveToken(u.ID, token); err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "token %s for user %s removed\n", token, username) + fmt.Fprintf(c.App.Writer, "token %s for user %s removed\n", token, username) return nil } @@ -165,7 +174,7 @@ func execTokenList(c *cli.Context) error { var users []*user.User if username != "" { u, err := manager.User(username) - if err == user.ErrUserNotFound { + if errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } else if err != nil { return err @@ -183,15 +192,15 @@ func execTokenList(c *cli.Context) error { if err != nil { return err } else if len(tokens) == 0 && username != "" { - fmt.Fprintf(c.App.ErrWriter, "user %s has no access tokens\n", username) + fmt.Fprintf(c.App.Writer, "user %s has no access tokens\n", username) return nil } else if len(tokens) == 0 { continue } usersWithTokens++ - fmt.Fprintf(c.App.ErrWriter, "user %s\n", u.Name) + fmt.Fprintf(c.App.Writer, "user %s\n", u.Name) for _, t := range tokens { - var label, expires string + var label, expires, provisioned string if t.Label != "" { label = fmt.Sprintf(" (%s)", t.Label) } @@ -200,11 +209,19 @@ func execTokenList(c *cli.Context) error { } else { expires = fmt.Sprintf("expires %s", t.Expires.Format(time.RFC822)) } - fmt.Fprintf(c.App.ErrWriter, "- %s%s, %s, accessed from %s at %s\n", t.Value, label, expires, t.LastOrigin.String(), t.LastAccess.Format(time.RFC822)) + if t.Provisioned { + provisioned = " (server config)" + } + fmt.Fprintf(c.App.Writer, "- %s%s, %s, accessed from %s at %s%s\n", t.Value, label, expires, t.LastOrigin.String(), t.LastAccess.Format(time.RFC822), provisioned) } } if usersWithTokens == 0 { - fmt.Fprintf(c.App.ErrWriter, "no users with tokens\n") + fmt.Fprintf(c.App.Writer, "no users with tokens\n") } return nil } + +func execTokenGenerate(c *cli.Context) error { + fmt.Fprintln(c.App.Writer, user.GenerateToken()) + return nil +} diff --git a/cmd/token_test.go b/cmd/token_test.go index 03295081..456e53cd 100644 --- a/cmd/token_test.go +++ b/cmd/token_test.go @@ -14,28 +14,28 @@ func TestCLI_Token_AddListRemove(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) - app, stdin, _, stderr := newTestApp() + app, stdin, stdout, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "phil")) - require.Contains(t, stderr.String(), "user phil added with role user") + require.Contains(t, stdout.String(), "user phil added with role user") - app, _, _, stderr = newTestApp() + app, _, stdout, _ = newTestApp() require.Nil(t, runTokenCommand(app, conf, "add", "phil")) - require.Regexp(t, `token tk_.+ created for user phil, never expires`, stderr.String()) + require.Regexp(t, `token tk_.+ created for user phil, never expires`, stdout.String()) - app, _, _, stderr = newTestApp() + app, _, stdout, _ = newTestApp() require.Nil(t, runTokenCommand(app, conf, "list", "phil")) - require.Regexp(t, `user phil\n- tk_.+, never expires, accessed from 0.0.0.0 at .+`, stderr.String()) + require.Regexp(t, `user phil\n- tk_.+, never expires, accessed from 0.0.0.0 at .+`, stdout.String()) re := regexp.MustCompile(`tk_\w+`) - token := re.FindString(stderr.String()) + token := re.FindString(stdout.String()) - app, _, _, stderr = newTestApp() + app, _, stdout, _ = newTestApp() require.Nil(t, runTokenCommand(app, conf, "remove", "phil", token)) - require.Regexp(t, fmt.Sprintf("token %s for user phil removed", token), stderr.String()) + require.Regexp(t, fmt.Sprintf("token %s for user phil removed", token), stdout.String()) - app, _, _, stderr = newTestApp() + app, _, stdout, _ = newTestApp() require.Nil(t, runTokenCommand(app, conf, "list")) - require.Equal(t, "no users with tokens\n", stderr.String()) + require.Equal(t, "no users with tokens\n", stdout.String()) } func runTokenCommand(app *cli.App, conf *server.Config, args ...string) error { diff --git a/cmd/user.go b/cmd/user.go index e6867b11..6bf7030e 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -6,6 +6,7 @@ import ( "crypto/subtle" "errors" "fmt" + "heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/user" "os" "strings" @@ -25,7 +26,7 @@ func init() { var flagsUser = append( append([]cli.Flag{}, flagsDefault...), - &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"}, + &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: server.DefaultConfigFile, DefaultText: server.DefaultConfigFile, Usage: "config file"}, altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), ) @@ -94,7 +95,6 @@ Example: You may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass directly the bcrypt hash. This is useful if you are updating users via scripts. - `, }, { @@ -133,6 +133,22 @@ as messages per day, attachment file sizes, etc. Example: ntfy user change-tier phil pro # Change tier to "pro" for user "phil" ntfy user change-tier phil - # Remove tier from user "phil" entirely +`, + }, + { + Name: "hash", + Usage: "Create password hash for a predefined user", + UsageText: "ntfy user hash", + Action: execUserHash, + Description: `Asks for a password and creates a bcrypt password hash. + +This command is useful to create a password hash for a user, which can then be used +for predefined users in the server config file, in auth-users. + +Example: + $ ntfy user hash + (asks for password and confirmation) + $2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C `, }, { @@ -195,7 +211,7 @@ func execUserAdd(c *cli.Context) error { } if user, _ := manager.User(username); user != nil { if c.Bool("ignore-exists") { - fmt.Fprintf(c.App.ErrWriter, "user %s already exists (exited successfully)\n", username) + fmt.Fprintf(c.App.Writer, "user %s already exists (exited successfully)\n", username) return nil } return fmt.Errorf("user %s already exists", username) @@ -210,7 +226,7 @@ func execUserAdd(c *cli.Context) error { if err := manager.AddUser(username, password, role, hashed); err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "user %s added with role %s\n", username, role) + fmt.Fprintf(c.App.Writer, "user %s added with role %s\n", username, role) return nil } @@ -225,13 +241,13 @@ func execUserDel(c *cli.Context) error { if err != nil { return err } - if _, err := manager.User(username); err == user.ErrUserNotFound { + if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } if err := manager.RemoveUser(username); err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "user %s removed\n", username) + fmt.Fprintf(c.App.Writer, "user %s removed\n", username) return nil } @@ -251,7 +267,7 @@ func execUserChangePass(c *cli.Context) error { if err != nil { return err } - if _, err := manager.User(username); err == user.ErrUserNotFound { + if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } if password == "" { @@ -263,7 +279,7 @@ func execUserChangePass(c *cli.Context) error { if err := manager.ChangePassword(username, password, hashed); err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "changed password for user %s\n", username) + fmt.Fprintf(c.App.Writer, "changed password for user %s\n", username) return nil } @@ -279,13 +295,26 @@ func execUserChangeRole(c *cli.Context) error { if err != nil { return err } - if _, err := manager.User(username); err == user.ErrUserNotFound { + if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } if err := manager.ChangeRole(username, role); err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "changed role for user %s to %s\n", username, role) + fmt.Fprintf(c.App.Writer, "changed role for user %s to %s\n", username, role) + return nil +} + +func execUserHash(c *cli.Context) error { + password, err := readPasswordAndConfirm(c) + if err != nil { + return err + } + hash, err := user.HashPassword(password) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + fmt.Fprintln(c.App.Writer, hash) return nil } @@ -303,19 +332,19 @@ func execUserChangeTier(c *cli.Context) error { if err != nil { return err } - if _, err := manager.User(username); err == user.ErrUserNotFound { + if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } if tier == tierReset { if err := manager.ResetTier(username); err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "removed tier from user %s\n", username) + fmt.Fprintf(c.App.Writer, "removed tier from user %s\n", username) } else { if err := manager.ChangeTier(username, tier); err != nil { return err } - fmt.Fprintf(c.App.ErrWriter, "changed tier for user %s to %s\n", username, tier) + fmt.Fprintf(c.App.Writer, "changed tier for user %s to %s\n", username, tier) } return nil } @@ -345,7 +374,15 @@ func createUserManager(c *cli.Context) (*user.Manager, error) { if err != nil { return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") } - return user.NewManager(authFile, authStartupQueries, authDefault, user.DefaultUserPasswordBcryptCost, user.DefaultUserStatsQueueWriterInterval) + authConfig := &user.Config{ + Filename: authFile, + StartupQueries: authStartupQueries, + DefaultAccess: authDefault, + ProvisionEnabled: false, // Hack: Do not re-provision users on manager initialization + BcryptCost: user.DefaultUserPasswordBcryptCost, + QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval, + } + return user.NewManager(authConfig) } func readPasswordAndConfirm(c *cli.Context) (string, error) { diff --git a/cmd/user_test.go b/cmd/user_test.go index e1bdd3ab..ed6f5de4 100644 --- a/cmd/user_test.go +++ b/cmd/user_test.go @@ -15,20 +15,20 @@ func TestCLI_User_Add(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) - app, stdin, _, stderr := newTestApp() + app, stdin, stdout, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "phil")) - require.Contains(t, stderr.String(), "user phil added with role user") + require.Contains(t, stdout.String(), "user phil added with role user") } func TestCLI_User_Add_Exists(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) - app, stdin, _, stderr := newTestApp() + app, stdin, stdout, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "phil")) - require.Contains(t, stderr.String(), "user phil added with role user") + require.Contains(t, stdout.String(), "user phil added with role user") app, stdin, _, _ = newTestApp() stdin.WriteString("mypass\nmypass") @@ -41,10 +41,10 @@ func TestCLI_User_Add_Admin(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) - app, stdin, _, stderr := newTestApp() + app, stdin, stdout, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil")) - require.Contains(t, stderr.String(), "user phil added with role admin") + require.Contains(t, stdout.String(), "user phil added with role admin") } func TestCLI_User_Add_Password_Mismatch(t *testing.T) { @@ -60,19 +60,27 @@ func TestCLI_User_Add_Password_Mismatch(t *testing.T) { func TestCLI_User_ChangePass(t *testing.T) { s, conf, port := newTestServerWithAuth(t) + conf.AuthUsers = []*user.User{ + {Name: "philuser", Hash: "$2a$10$U4WSIYY6evyGmZaraavM2e2JeVG6EMGUKN1uUwufUeeRd4Jpg6cGC", Role: user.RoleUser}, // philuser:philpass + } defer test.StopServer(t, s, port) // Add user - app, stdin, _, stderr := newTestApp() + app, stdin, stdout, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "phil")) - require.Contains(t, stderr.String(), "user phil added with role user") + require.Contains(t, stdout.String(), "user phil added with role user") // Change pass - app, stdin, _, stderr = newTestApp() + app, stdin, stdout, _ = newTestApp() stdin.WriteString("newpass\nnewpass") require.Nil(t, runUserCommand(app, conf, "change-pass", "phil")) - require.Contains(t, stderr.String(), "changed password for user phil") + require.Contains(t, stdout.String(), "changed password for user phil") + + // Cannot change provisioned user's pass + app, stdin, _, _ = newTestApp() + stdin.WriteString("newpass\nnewpass") + require.Error(t, runUserCommand(app, conf, "change-pass", "philuser")) } func TestCLI_User_ChangeRole(t *testing.T) { @@ -80,15 +88,15 @@ func TestCLI_User_ChangeRole(t *testing.T) { defer test.StopServer(t, s, port) // Add user - app, stdin, _, stderr := newTestApp() + app, stdin, stdout, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "phil")) - require.Contains(t, stderr.String(), "user phil added with role user") + require.Contains(t, stdout.String(), "user phil added with role user") // Change role - app, _, _, stderr = newTestApp() + app, _, stdout, _ = newTestApp() require.Nil(t, runUserCommand(app, conf, "change-role", "phil", "admin")) - require.Contains(t, stderr.String(), "changed role for user phil to admin") + require.Contains(t, stdout.String(), "changed role for user phil to admin") } func TestCLI_User_Delete(t *testing.T) { @@ -96,15 +104,15 @@ func TestCLI_User_Delete(t *testing.T) { defer test.StopServer(t, s, port) // Add user - app, stdin, _, stderr := newTestApp() + app, stdin, stdout, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "phil")) - require.Contains(t, stderr.String(), "user phil added with role user") + require.Contains(t, stdout.String(), "user phil added with role user") // Delete user - app, _, _, stderr = newTestApp() + app, _, stdout, _ = newTestApp() require.Nil(t, runUserCommand(app, conf, "del", "phil")) - require.Contains(t, stderr.String(), "user phil removed") + require.Contains(t, stdout.String(), "user phil removed") // Delete user again (does not exist) app, _, _, _ = newTestApp() diff --git a/cmd/webpush.go b/cmd/webpush.go index 249f91c8..fdcf4ff1 100644 --- a/cmd/webpush.go +++ b/cmd/webpush.go @@ -53,9 +53,9 @@ web-push-private-key: %s if err != nil { return err } - _, err = fmt.Fprintf(c.App.ErrWriter, "Web Push keys written to %s.\n", outputFile) + _, err = fmt.Fprintf(c.App.Writer, "Web Push keys written to %s.\n", outputFile) } else { - _, err = fmt.Fprintf(c.App.ErrWriter, `Web Push keys generated. Add the following lines to your config file: + _, err = fmt.Fprintf(c.App.Writer, `Web Push keys generated. Add the following lines to your config file: web-push-public-key: %s web-push-private-key: %s diff --git a/cmd/webpush_test.go b/cmd/webpush_test.go index 01e1a7a1..5a447831 100644 --- a/cmd/webpush_test.go +++ b/cmd/webpush_test.go @@ -1,6 +1,7 @@ package cmd import ( + "path/filepath" "testing" "github.com/stretchr/testify/require" @@ -9,16 +10,18 @@ import ( ) func TestCLI_WebPush_GenerateKeys(t *testing.T) { - app, _, _, stderr := newTestApp() + app, _, stdout, _ := newTestApp() require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys")) - require.Contains(t, stderr.String(), "Web Push keys generated.") + require.Contains(t, stdout.String(), "Web Push keys generated.") } func TestCLI_WebPush_WriteKeysToFile(t *testing.T) { - app, _, _, stderr := newTestApp() + tempDir := t.TempDir() + t.Chdir(tempDir) + app, _, stdout, _ := newTestApp() require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys", "--output-file=key-file.yaml")) - require.Contains(t, stderr.String(), "Web Push keys written to key-file.yaml") - require.FileExists(t, "key-file.yaml") + require.Contains(t, stdout.String(), "Web Push keys written to key-file.yaml") + require.FileExists(t, filepath.Join(tempDir, "key-file.yaml")) } func runWebPushCommand(app *cli.App, conf *server.Config, args ...string) error { diff --git a/docker-compose.yml b/docker-compose.yml index d39492e8..d634600c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: "2.1" services: ntfy: image: binwiederhier/ntfy @@ -14,4 +13,3 @@ services: ports: - 80:80 restart: unless-stopped - diff --git a/docs/config.md b/docs/config.md index 1687c2ec..10640c46 100644 --- a/docs/config.md +++ b/docs/config.md @@ -18,8 +18,8 @@ get a list of [command line options](#command-line-options). ## Example config !!! info - Definitely check out the **[server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)** file. - It contains examples and detailed descriptions of all the settings. + Definitely check out the **[server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)** file. It contains examples and detailed descriptions of all the settings. + You may also want to look at how ntfy.sh is configured in the [ntfy-ansible](https://github.com/binwiederhier/ntfy-ansible) repository. The most basic settings are `base-url` (the external URL of the ntfy server), the HTTP/HTTPS listen address (`listen-http` and `listen-https`), and socket path (`listen-unix`). All the other things are additional features. @@ -79,7 +79,6 @@ using Docker Compose (i.e. `docker-compose.yml`): === "Docker Compose (w/ auth, cache, attachments)" ``` yaml - version: '3' services: ntfy: image: binwiederhier/ntfy @@ -89,6 +88,7 @@ using Docker Compose (i.e. `docker-compose.yml`): NTFY_CACHE_FILE: /var/lib/ntfy/cache.db NTFY_AUTH_FILE: /var/lib/ntfy/auth.db NTFY_AUTH_DEFAULT_ACCESS: deny-all + NTFY_AUTH_USERS: 'phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin' NTFY_BEHIND_PROXY: true NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments NTFY_ENABLE_LOGIN: true @@ -101,7 +101,6 @@ using Docker Compose (i.e. `docker-compose.yml`): === "Docker Compose (w/ auth, cache, web push, iOS)" ``` yaml - version: '3' services: ntfy: image: binwiederhier/ntfy @@ -190,19 +189,31 @@ ntfy's auth is implemented with a simple [SQLite](https://www.sqlite.org/)-based (`user` and `admin`) and per-topic `read` and `write` permissions using an [access control list (ACL)](https://en.wikipedia.org/wiki/Access-control_list). Access control entries can be applied to users as well as the special everyone user (`*`), which represents anonymous API access. -To set up auth, simply **configure the following two options**: +To set up auth, **configure the following options**: * `auth-file` is the user/access database; it is created automatically if it doesn't already exist; suggested location `/var/lib/ntfy/user.db` (easiest if deb/rpm package is used) * `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`. + set to `read-write` (default), `read-only`, `write-only` or `deny-all`. **If you are setting up a private instance, + you'll want to set this to `deny-all`** (see [private instance example](#example-private-instance)). -Once configured, you can use the `ntfy user` command to [add or modify users](#users-and-roles), and the `ntfy access` command -lets you [modify the access control list](#access-control-list-acl) for specific users and topic patterns. Both of these -commands **directly edit the auth database** (as defined in `auth-file`), so they only work on the server, and only if the user -accessing them has the right permissions. +Once configured, you can use + +- the `ntfy user` command and the `auth-users` config option to [add or modify users](#users-and-roles) +- the `ntfy access` command and the `auth-access` option to [modify the access control list](#access-control-list-acl) +and topic patterns, and +- the `ntfy token` command and the `auth-tokens` config option to [manage access tokens](#access-tokens) for users. + +These commands **directly edit the auth database** (as defined in `auth-file`), so they only work on the server, +and only if the user accessing them has the right permissions. ### Users and roles +Users can be added to the ntfy user database in two different ways + +* [Using the CLI](#users-via-the-cli): Using the `ntfy user` command, you can manually add/update/remove users. +* [In the config](#users-via-the-config): You can provision users in the `server.yml` file via `auth-users` key. + +#### Users via the CLI The `ntfy user` command allows you to add/remove/change users in the ntfy user database, as well as change passwords or roles (`user` or `admin`). In practice, you'll often just create one admin user with `ntfy user add --role=admin ...` and be done with all this (see [example below](#example-private-instance)). @@ -223,12 +234,54 @@ 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 ntfy user change-tier phil pro # Change phil's tier to "pro" +ntfy user hash # Generate password hash, use with auth-users config option ``` +#### Users via the config +As an alternative to manually creating users via the `ntfy user` CLI command, you can provision users declaratively in +the `server.yml` file by adding them to the `auth-users` array. This is useful for general admins, or if you'd like to +deploy your ntfy server via Docker/Ansible without manually editing the database. + +The `auth-users` option is a list of users that are automatically created/updated when the server starts. Users +previously defined in the config but later removed will be deleted. Each entry is defined in the format `::`. + +Here's an example with two users: `phil` is an admin, `ben` is a regular user. + +=== "Declarative users in /etc/ntfy/server.yml" + ``` yaml + auth-file: "/var/lib/ntfy/user.db" + auth-users: + - "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin" + - "ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user" + ``` + +=== "Declarative users via env variables" + ``` + # Comma-separated list, use single quotes to avoid issues with the bcrypt hash + NTFY_AUTH_FILE='/var/lib/ntfy/user.db' + NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user' + ``` + +The password hash can be created using `ntfy user hash` or an [online bcrypt generator](https://bcrypt-generator.com/) (though +note that you're putting your password in an untrusted website). + +!!! important + Users added declaratively via the config file are marked in the database as "provisioned users". Removing users + from the config file will **delete them from the database** the next time ntfy is restarted. + + Also, users that were originally manually created will be "upgraded" to be provisioned users if they are added to + the config. Adding a user manually, then adding it to the config, and then removing it from the config will hence + lead to the **deletion of that user**. + ### Access control list (ACL) 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. +Each entry represents the access permissions for a user to a specific topic or topic pattern. Entries can be created in +two different ways: +* [Using the CLI](#acl-entries-via-the-cli): Using the `ntfy access` command, you can manually edit the access control list. +* [In the config](#acl-entries-via-the-config): You can provision ACL entries in the `server.yml` file via `auth-access` key. + +#### ACL entries via the CLI The ACL can be displayed or modified with the `ntfy access` command: ``` @@ -284,6 +337,51 @@ User `ben` has three topic-specific entries. He can read, but not write to topic to topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated (called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics. +#### ACL entries via the config +As an alternative to manually creating ACL entries via the `ntfy access` CLI command, you can provision access control +entries declaratively in the `server.yml` file by adding them to the `auth-access` array, similar to the `auth-users` +option (see [users via the config](#users-via-the-config). + +The `auth-access` option is a list of access control entries that are automatically created/updated when the server starts. +When entries are removed, they are deleted from the database. Each entry is defined in the format `::`. + +The `` can be any existing, provisioned user as defined in the `auth-users` section (see [users via the config](#users-via-the-config)), +or `everyone`/`*` for anonymous access. The `` can be a specific topic name or a pattern with wildcards (`*`). The +`` can be one of the following: + +* `read-write` or `rw`: Allows both publishing to and subscribing to the topic +* `read-only`, `read`, or `ro`: Allows only subscribing to the topic +* `write-only`, `write`, or `wo`: Allows only publishing to the topic +* `deny-all`, `deny`, or `none`: Denies all access to the topic + +Here's an example with several ACL entries: + +=== "Declarative ACL entries in /etc/ntfy/server.yml" + ``` yaml + auth-file: "/var/lib/ntfy/user.db" + auth-users: + - "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user" + - "ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user" + auth-access: + - "phil:mytopic:rw" + - "ben:alerts-*:rw" + - "ben:system-logs:ro" + - "*:announcements:ro" # or: "everyone:announcements,ro" + ``` + +=== "Declarative ACL entries via env variables" + ``` + # Comma-separated list + NTFY_AUTH_FILE='/var/lib/ntfy/user.db' + NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user,ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user' + NTFY_AUTH_ACCESS='phil:mytopic:rw,ben:alerts-*:rw,ben:system-logs:ro,*:announcements:ro' + ``` + +In this example, the `auth-users` section defines two users, `phil` and `ben`. The `auth-access` section defines +access control entries for these users. `phil` has read-write access to the topic `mytopic`, while `ben` has read-write +access to all topics starting with `alerts-` and read-only access to the topic `system-logs`. The last entry allows +anonymous users (i.e. clients that do not authenticate) to read the `announcements` topic. + ### Access tokens In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful to avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may @@ -294,6 +392,12 @@ want to use a dedicated token to publish from your backup host, and one from you and deleting the account, every action can be performed with a token. Granular access tokens are on the roadmap, but not yet implemented. +You can create access tokens in two different ways: + +* [Using the CLI](#tokens-via-the-cli): Using the `ntfy token` command, you can manually add/update/remove tokens. +* [In the config](#tokens-via-the-config): You can provision access tokens in the `server.yml` file via `auth-tokens` key. + +#### Tokens via the CLI The `ntfy token` command can be used to manage access tokens for users. Tokens can have labels, and they can expire automatically (or never expire). Each user can have up to 60 tokens (hardcoded). @@ -304,6 +408,7 @@ ntfy token list phil # Shows list of tokens for user phil ntfy token add phil # Create token for user phil which never expires ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days ntfy token remove phil tk_th2sxr... # Delete token +ntfy token generate # Generate random token, can be used in auth-tokens config option ``` **Creating an access token:** @@ -311,32 +416,89 @@ ntfy token remove phil tk_th2sxr... # Delete token $ ntfy token add --expires=30d --label="backups" phil $ ntfy token list user phil -- tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 (backups), expires 15 Mar 23 14:33 EDT, accessed from 0.0.0.0 at 13 Feb 23 13:33 EST +- tk_7eevizlsiwf9yi4uxsrs83r4352o0 (backups), expires 15 Mar 23 14:33 EDT, accessed from 0.0.0.0 at 13 Feb 23 13:33 EST ``` Once an access token is created, you can **use it to authenticate against the ntfy server, e.g. when you publish or subscribe to topics**. To learn how, check out [authenticate via access tokens](publish.md#access-tokens). -### Example: Private instance -The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`: +#### Tokens via the config +Access tokens can be pre-provisioned in the `server.yml` configuration file using the `auth-tokens` config option. +This is useful for automated setups, Docker environments, or when you want to define tokens declaratively. -=== "/etc/ntfy/server.yml" +The `auth-tokens` option is a list of access tokens that are automatically created/updated when the server starts. +When entries are removed, they are deleted from the database. Each entry is defined in the format `:[: