diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index b6dc8ddb..72b9e360 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -9,7 +9,7 @@ jobs: - name: Install Go uses: actions/setup-go@v4 with: - go-version: '1.22.x' + go-version: '1.24.x' - name: Install node uses: actions/setup-node@v3 with: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 80155e5b..70a70552 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -12,7 +12,7 @@ jobs: - name: Install Go uses: actions/setup-go@v4 with: - go-version: '1.22.x' + go-version: '1.24.x' - name: Install node uses: actions/setup-node@v3 with: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b0f99ffd..cfd9d754 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -9,7 +9,7 @@ jobs: - name: Install Go uses: actions/setup-go@v4 with: - go-version: '1.22.x' + go-version: '1.24.x' - name: Install node uses: actions/setup-node@v3 with: diff --git a/.gitignore b/.gitignore index 7cbb52ac..cf10bc33 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ node_modules/ __pycache__ web/dev-dist/ venv/ +cmd/key-file.yaml diff --git a/.goreleaser.yml b/.goreleaser.yml index 062cce1f..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 @@ -90,6 +84,8 @@ nfpms: type: "config|noreplace" - src: client/ntfy-client.service dst: /lib/systemd/system/ntfy-client.service + - src: client/user/ntfy-client.service + dst: /lib/systemd/user/ntfy-client.service - dst: /var/cache/ntfy type: dir - dst: /var/cache/ntfy/attachments @@ -104,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 @@ -119,19 +114,18 @@ archives: - server/ntfy.service - client/client.yml - client/ntfy-client.service - - - id: ntfy_windows - builds: + - client/user/ntfy-client.service + - 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: @@ -139,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: @@ -197,3 +190,15 @@ docker_manifests: - *arm64v8_image - *armv7_image - *armv6_image + - name_template: "binwiederhier/ntfy:v{{ .Major }}" + image_templates: + - *amd64_image + - *arm64v8_image + - *armv7_image + - *armv6_image + - name_template: "binwiederhier/ntfy:v{{ .Major }}.{{ .Minor }}" + image_templates: + - *amd64_image + - *arm64v8_image + - *armv7_image + - *armv6_image \ No newline at end of file diff --git a/Dockerfile-build b/Dockerfile-build index 4530ec47..78f2d5d9 100644 --- a/Dockerfile-build +++ b/Dockerfile-build @@ -1,4 +1,4 @@ -FROM golang:1.22-bullseye as builder +FROM golang:1.24-bullseye as builder ARG VERSION=dev ARG COMMIT=unknown @@ -44,6 +44,8 @@ RUN make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server FROM alpine +ARG VERSION=dev + LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com" LABEL org.opencontainers.image.url="https://ntfy.sh/" LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/" @@ -52,6 +54,7 @@ LABEL org.opencontainers.image.vendor="Philipp C. Heckel" LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0" LABEL org.opencontainers.image.title="ntfy" LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST" +LABEL org.opencontainers.image.version="$VERSION" COPY --from=builder /app/dist/ntfy_linux_server/ntfy /usr/bin/ntfy 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 6255d5dd..2d888468 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,16 @@ +
+Special thanks to: +
+
+ + Warp sponsorship + + +### [Warp, built for coding with multiple AI agents.](https://go.warp.dev/ntfy) +[Available for MacOS, Linux, & Windows](https://go.warp.dev/ntfy)
+
+
+ ![ntfy](web/public/static/images/ntfy.png) # ntfy.sh | Send push notifications to your phone or desktop via PUT/POST @@ -56,20 +69,20 @@ For announcements of new releases and cutting-edge beta versions, please subscri topic. If you'd like to test the iOS app, join [TestFlight](https://testflight.apple.com/join/P1fFnAm9). For Android betas, join Discord/Matrix (I'll eventually make a testing channel in Google Play). -## Contributing -I welcome any contributions. Just create a PR or an issue. For larger features/ideas, please reach out -on Discord/Matrix first to see if I'd accept them. To contribute code, check out the [build instructions](https://ntfy.sh/docs/develop/) -for the server and the Android app. Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start immediately in -[Hosted Weblate](https://hosted.weblate.org/projects/ntfy/). - - -Translation status - - ## Sponsors -I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier), -and [Liberapay](https://liberapay.com/ntfy). I would be humbled if you helped me carry the server and developer -account costs. Even small donations are very much appreciated. A big fat **Thank You** to the folks who have sponsored ntfy in the past, or are still sponsoring ntfy: +If you'd like to support the ntfy maintainers, please consider donating to [GitHub Sponsors](https://github.com/sponsors/binwiederhier) or +and [Liberapay](https://liberapay.com/ntfy). We would be humbled if you helped carry the server and developer +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: @@ -210,13 +223,21 @@ account costs. Even small donations are very much appreciated. A big fat **Thank -I'd also like to thank JetBrains for their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/), -and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project: +## Contributing +I welcome any contributions. Just create a PR or an issue. For larger features/ideas, please reach out +on Discord/Matrix first to see if I'd accept them. To contribute code, check out the [build instructions](https://ntfy.sh/docs/develop/) +for the server and the Android app. Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start immediately in +[Hosted Weblate](https://hosted.weblate.org/projects/ntfy/). - + +Translation status + ## Code of Conduct -We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for +everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity +and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, +color, religion, or sexual identity and orientation. **We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.** @@ -247,3 +268,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/SECURITY.md b/SECURITY.md index 45573756..a96cc823 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,5 +6,7 @@ As of today, I only support the latest version of ntfy. Please make sure you sta ## Reporting a Vulnerability -Please report severe security issues privately via ntfy@heckel.io, [Discord](https://discord.gg/cT7ECsZj9w), -or [Matrix](https://matrix.to/#/#ntfy:matrix.org) (my username is `binwiederhier`). +Please report security vulnerabilities privately via email to [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh). + +You can also reach me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org) +(my username is `binwiederhier`). diff --git a/assets/sponsors/magicbell.png b/assets/sponsors/magicbell.png new file mode 100644 index 00000000..24e8b9dd Binary files /dev/null and b/assets/sponsors/magicbell.png differ diff --git a/client/client.yml b/client/client.yml index ebf4c281..d93ece06 100644 --- a/client/client.yml +++ b/client/client.yml @@ -21,7 +21,7 @@ # default-command: # Subscriptions to topics and their actions. This option is primarily used by the systemd service, -# or if you cann "ntfy subscribe --from-config" directly. +# or if you can "ntfy subscribe --from-config" directly. # # Example: # subscribe: diff --git a/client/options.go b/client/options.go index 027b7fb5..b99f1673 100644 --- a/client/options.go +++ b/client/options.go @@ -77,11 +77,22 @@ 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) } +// WithSequenceID sets a sequence ID for the message, allowing updates to existing notifications +func WithSequenceID(sequenceID string) PublishOption { + return WithHeader("X-Sequence-ID", sequenceID) +} + // WithEmail instructs the server to also send the message to the given e-mail address func WithEmail(email string) PublishOption { return WithHeader("X-Email", email) diff --git a/client/user/ntfy-client.service b/client/user/ntfy-client.service new file mode 100644 index 00000000..0a9598ee --- /dev/null +++ b/client/user/ntfy-client.service @@ -0,0 +1,10 @@ +[Unit] +Description=ntfy client +After=network.target + +[Service] +ExecStart=/usr/bin/ntfy subscribe --config "%h/.config/ntfy/client.yml" --from-config +Restart=on-failure + +[Install] +WantedBy=default.target 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..c80c140b 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -32,7 +32,9 @@ 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: "sequence-id", Aliases: []string{"sequence_id", "sid", "S"}, EnvVars: []string{"NTFY_SEQUENCE_ID"}, Usage: "sequence ID for updating notifications"}, &cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"}, &cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"}, &cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"}, @@ -69,6 +71,8 @@ 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 + ntfy pub -S my-id mytopic 'Update me' # Send with sequence ID for updates + 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,7 +101,9 @@ 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") + sequenceID := c.String("sequence-id") file := c.String("file") email := c.String("email") user := c.String("user") @@ -145,9 +151,15 @@ 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)) } + if sequenceID != "" { + options = append(options, client.WithSequenceID(sequenceID)) + } if email != "" { options = append(options, client.WithEmail(email)) } @@ -254,6 +266,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 +333,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/publish_unix.go b/cmd/publish_unix.go index 3ce22ffc..d2b49a5e 100644 --- a/cmd/publish_unix.go +++ b/cmd/publish_unix.go @@ -1,5 +1,4 @@ //go:build darwin || linux || dragonfly || freebsd || netbsd || openbsd -// +build darwin linux dragonfly freebsd netbsd openbsd package cmd diff --git a/cmd/serve.go b/cmd/serve.go index 4b988adc..f6eb1475 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/urfave/cli/v2" + "github.com/urfave/cli/v2/altsrc" + "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/payments" + "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"}), @@ -62,6 +63,7 @@ var flagsServe = append( altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-reservations", Aliases: []string{"enable_reservations"}, EnvVars: []string{"NTFY_ENABLE_RESERVATIONS"}, Value: false, Usage: "allows users to reserve topics (if their tier allows it)"}), + altsrc.NewBoolFlag(&cli.BoolFlag{Name: "require-login", Aliases: []string{"require_login"}, EnvVars: []string{"NTFY_REQUIRE_LOGIN"}, Value: false, Usage: "all actions via the web app requires a login"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-access-token", Aliases: []string{"upstream_access_token"}, EnvVars: []string{"NTFY_UPSTREAM_ACCESS_TOKEN"}, Value: "", Usage: "access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}), @@ -79,6 +81,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,8 +90,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.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for 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-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)"}), @@ -100,6 +106,8 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-file", Aliases: []string{"web_push_file"}, EnvVars: []string{"NTFY_WEB_PUSH_FILE"}, Usage: "file used to store web push subscriptions"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-email-address", Aliases: []string{"web_push_email_address"}, EnvVars: []string{"NTFY_WEB_PUSH_EMAIL_ADDRESS"}, Usage: "e-mail address of sender, required to use browser push services"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-startup-queries", Aliases: []string{"web_push_startup_queries"}, EnvVars: []string{"NTFY_WEB_PUSH_STARTUP_QUERIES"}, Usage: "queries run when the web push database is initialized"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-expiry-duration", Aliases: []string{"web_push_expiry_duration"}, EnvVars: []string{"NTFY_WEB_PUSH_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultWebPushExpiryDuration), Usage: "automatically expire unused subscriptions after this time"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-expiry-warning-duration", Aliases: []string{"web_push_expiry_warning_duration"}, EnvVars: []string{"NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION"}, Value: util.FormatDuration(server.DefaultWebPushExpiryWarningDuration), Usage: "send web push warning notification after this time before expiring unused subscriptions"}), ) var cmdServe = &cli.Command{ @@ -140,6 +148,8 @@ func execServe(c *cli.Context) error { webPushFile := c.String("web-push-file") webPushEmailAddress := c.String("web-push-email-address") webPushStartupQueries := c.String("web-push-startup-queries") + webPushExpiryDurationStr := c.String("web-push-expiry-duration") + webPushExpiryWarningDurationStr := c.String("web-push-expiry-warning-duration") cacheFile := c.String("cache-file") cacheDurationStr := c.String("cache-duration") cacheStartupQueries := c.String("cache-startup-queries") @@ -148,16 +158,21 @@ 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") webRoot := c.String("web-root") enableSignup := c.Bool("enable-signup") enableLogin := c.Bool("enable-login") + requireLogin := c.Bool("require-login") enableReservations := c.Bool("enable-reservations") upstreamBaseURL := c.String("upstream-base-url") upstreamAccessToken := c.String("upstream-access-token") @@ -186,7 +201,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") + 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") @@ -227,6 +246,14 @@ func execServe(c *cli.Context) error { if err != nil { return fmt.Errorf("invalid visitor email limit replenish: %s", visitorEmailLimitReplenishStr) } + webPushExpiryDuration, err := util.ParseDuration(webPushExpiryDurationStr) + if err != nil { + return fmt.Errorf("invalid web push expiry duration: %s", webPushExpiryDurationStr) + } + webPushExpiryWarningDuration, err := util.ParseDuration(webPushExpiryWarningDurationStr) + if err != nil { + return fmt.Errorf("invalid web push expiry warning duration: %s", webPushExpiryWarningDurationStr) + } // Convert sizes to bytes messageSizeLimit, err := util.ParseSize(messageSizeLimitStr) @@ -255,6 +282,8 @@ func execServe(c *cli.Context) error { // Check values if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) { return errors.New("if set, FCM key file must exist") + } else if firebaseKeyFile != "" && !server.FirebaseAvailable { + return errors.New("cannot set firebase-key-file, support for Firebase is not available (nofirebase)") } else if webPushPublicKey != "" && (webPushPrivateKey == "" || webPushFile == "" || webPushEmailAddress == "" || baseURL == "") { return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file, web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys") } else if keepaliveInterval < 5*time.Second { @@ -292,10 +321,14 @@ func execServe(c *cli.Context) error { return errors.New("if upstream-base-url is set, base-url must also be set") } else if upstreamBaseURL != "" && baseURL != "" && baseURL == upstreamBaseURL { return errors.New("base-url and upstream-base-url cannot be identical, you'll likely want to set upstream-base-url to https://ntfy.sh, see https://ntfy.sh/docs/config/#ios-instant-notifications") - } else if authFile == "" && (enableSignup || enableLogin || enableReservations || stripeSecretKey != "") { - return errors.New("cannot set enable-signup, enable-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set") + } else if authFile == "" && (enableSignup || enableLogin || requireLogin || enableReservations || stripeSecretKey != "") { + return errors.New("cannot set enable-signup, enable-login, require-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set") } else if enableSignup && !enableLogin { return errors.New("cannot set enable-signup without also setting enable-login") + } else if requireLogin && !enableLogin { + return errors.New("cannot set require-login without also setting enable-login") + } else if !payments.Available && (stripeSecretKey != "" || stripeWebhookKey != "") { + return errors.New("cannot set stripe-secret-key or stripe-webhook-key, support for payments is not available in this build (nopayments)") } else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") { return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set") } else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") { @@ -305,6 +338,16 @@ func execServe(c *cli.Context) error { if messageSizeLimit > 5*1024*1024 { return errors.New("message-size-limit cannot be higher than 5M") } + } else if !server.WebPushAvailable && (webPushPrivateKey != "" || webPushPublicKey != "" || webPushFile != "") { + return errors.New("cannot enable WebPush, support is not available in this build (nowebpush)") + } else if webPushExpiryWarningDuration > 0 && webPushExpiryWarningDuration > webPushExpiryDuration { + 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 @@ -318,11 +361,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 == "-" { @@ -330,20 +385,29 @@ 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 if stripeSecretKey != "" { - stripe.EnableTelemetry = false // Whoa! - stripe.Key = stripeSecretKey + payments.Setup(stripeSecretKey) } // Add default forbidden topics @@ -368,10 +432,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 @@ -394,31 +462,38 @@ 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.ProxyTrustedPrefixes = trustedProxyPrefixes conf.StripeSecretKey = stripeSecretKey conf.StripeWebhookKey = stripeWebhookKey conf.BillingContact = billingContact conf.EnableSignup = enableSignup conf.EnableLogin = enableLogin + conf.RequireLogin = requireLogin conf.EnableReservations = enableReservations conf.EnableMetrics = enableMetrics conf.MetricsListenHTTP = metricsListenHTTP conf.ProfileListenHTTP = profileListenHTTP - conf.Version = c.App.Version conf.WebPushPrivateKey = webPushPrivateKey conf.WebPushPublicKey = webPushPublicKey conf.WebPushFile = webPushFile conf.WebPushEmailAddress = webPushEmailAddress conf.WebPushStartupQueries = webPushStartupQueries + conf.WebPushExpiryDuration = webPushExpiryDuration + conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration + conf.Version = c.App.Version // Set up hot-reloading of config go sigHandlerConfigReload(config) @@ -426,9 +501,9 @@ func execServe(c *cli.Context) error { // Run server s, err := server.New(conf) if err != nil { - log.Fatal(err.Error()) + log.Fatal("%s", err.Error()) } else if err := s.Run(); err != nil { - log.Fatal(err.Error()) + log.Fatal("%s", err.Error()) } log.Info("Exiting.") return nil @@ -451,7 +526,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()) @@ -475,6 +550,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, user.DefaultUserPasswordBcryptCost); err != nil { + return nil, fmt.Errorf("invalid auth-users: %s, password hash invalid, %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..b89efa8a 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$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"}, + expected: []*user.User{ + { + Name: "alice", + Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S", + Role: user.RoleUser, + Provisioned: true, + }, + }, + }, + { + name: "multiple users with different roles", + input: []string{ + "alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user", + "bob:$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq:admin", + }, + expected: []*user.User{ + { + Name: "alice", + Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S", + Role: user.RoleUser, + Provisioned: true, + }, + { + Name: "bob", + Hash: "$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq", + 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:$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe:user"}, + expected: []*user.User{ + { + Name: "alice.test+123@example.com", + Hash: "$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe", + 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$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"}, + error: "invalid auth-users: alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid", + }, + { + name: "invalid password hash - wrong prefix", + input: []string{"alice:plaintext:user"}, + error: "invalid auth-users: alice:plaintext:user, password hash invalid, password hash must be a bcrypt hash, use 'ntfy user hash' to generate", + }, + { + name: "invalid role", + input: []string{"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid"}, + error: "invalid auth-users: alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid, role invalid is not allowed, allowed roles are 'admin' or 'user'", + }, + { + name: "empty username", + input: []string{":$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"}, + error: "invalid auth-users: :$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S: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 af3afe54..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"}), ) @@ -42,7 +43,7 @@ var cmdUser = &cli.Command{ Name: "add", Aliases: []string{"a"}, Usage: "Adds a new user", - UsageText: "ntfy user add [--role=admin|user] USERNAME\nNTFY_PASSWORD=... ntfy user add [--role=admin|user] USERNAME", + UsageText: "ntfy user add [--role=admin|user] USERNAME\nNTFY_PASSWORD=... ntfy user add [--role=admin|user] USERNAME\nNTFY_PASSWORD_HASH=... ntfy user add [--role=admin|user] USERNAME", Action: execUserAdd, Flags: []cli.Flag{ &cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(user.RoleUser), Usage: "user role"}, @@ -55,12 +56,13 @@ granted otherwise by the auth-default-access setting). An admin user has read an topics. Examples: - ntfy user add phil # Add regular user phil - ntfy user add --role=admin phil # Add admin user phil - NTFY_PASSWORD=... ntfy user add phil # Add user, using env variable to set password (for scripts) + ntfy user add phil # Add regular user phil + ntfy user add --role=admin phil # Add admin user phil + NTFY_PASSWORD=... ntfy user add phil # Add user, using env variable to set password (for scripts) + NTFY_PASSWORD_HASH=... ntfy user add phil # Add user, using env variable to set password hash (for scripts) -You may set the NTFY_PASSWORD environment variable to pass the password. This is useful if -you are creating users via scripts. +You may set the NTFY_PASSWORD environment variable to pass the password, or NTFY_PASSWORD_HASH to pass +directly the bcrypt hash. This is useful if you are creating users via scripts. `, }, { @@ -79,7 +81,7 @@ Example: Name: "change-pass", Aliases: []string{"chp"}, Usage: "Changes a user's password", - UsageText: "ntfy user change-pass USERNAME\nNTFY_PASSWORD=... ntfy user change-pass USERNAME", + UsageText: "ntfy user change-pass USERNAME\nNTFY_PASSWORD=... ntfy user change-pass USERNAME\nNTFY_PASSWORD_HASH=... ntfy user change-pass USERNAME", Action: execUserChangePass, Description: `Change the password for the given user. @@ -89,10 +91,10 @@ it twice. Example: ntfy user change-pass phil NTFY_PASSWORD=.. ntfy user change-pass phil + NTFY_PASSWORD_HASH=.. ntfy user change-pass phil -You may set the NTFY_PASSWORD environment variable to pass the new password. This is -useful if you are updating users via scripts. - +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. `, }, { @@ -131,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 `, }, { @@ -174,7 +192,12 @@ variable to pass the new password. This is useful if you are creating/updating u func execUserAdd(c *cli.Context) error { username := c.Args().Get(0) role := user.Role(c.String("role")) - password := os.Getenv("NTFY_PASSWORD") + password, hashed := os.LookupEnv("NTFY_PASSWORD_HASH") + + if !hashed { + password = os.Getenv("NTFY_PASSWORD") + } + if username == "" { return errors.New("username expected, type 'ntfy user add --help' for help") } else if username == userEveryone || username == user.Everyone { @@ -188,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) @@ -200,10 +223,10 @@ func execUserAdd(c *cli.Context) error { } password = p } - if err := manager.AddUser(username, password, role); err != nil { + 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 } @@ -218,19 +241,23 @@ 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 } func execUserChangePass(c *cli.Context) error { username := c.Args().Get(0) - password := os.Getenv("NTFY_PASSWORD") + password, hashed := os.LookupEnv("NTFY_PASSWORD_HASH") + + if !hashed { + password = os.Getenv("NTFY_PASSWORD") + } if username == "" { return errors.New("username expected, type 'ntfy user change-pass --help' for help") } else if username == userEveryone || username == user.Everyone { @@ -240,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 == "" { @@ -249,10 +276,10 @@ func execUserChangePass(c *cli.Context) error { return err } } - if err := manager.ChangePassword(username, password); err != nil { + 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 } @@ -268,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 } @@ -292,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 } @@ -334,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 ec66f083..90d9268c 100644 --- a/cmd/webpush.go +++ b/cmd/webpush.go @@ -1,12 +1,19 @@ -//go:build !noserver +//go:build !noserver && !nowebpush package cmd import ( "fmt" + "os" "github.com/SherClockHolmes/webpush-go" "github.com/urfave/cli/v2" + "github.com/urfave/cli/v2/altsrc" +) + +var flagsWebPush = append( + []cli.Flag{}, + altsrc.NewStringFlag(&cli.StringFlag{Name: "output-file", Aliases: []string{"f"}, Usage: "write VAPID keys to this file"}), ) func init() { @@ -26,6 +33,7 @@ var cmdWebPush = &cli.Command{ Usage: "Generate VAPID keys to enable browser background push notifications", UsageText: "ntfy webpush keys", Category: categoryServer, + Flags: flagsWebPush, }, }, } @@ -35,7 +43,19 @@ func generateWebPushKeys(c *cli.Context) error { if err != nil { return err } - _, err = fmt.Fprintf(c.App.ErrWriter, `Web Push keys generated. Add the following lines to your config file: + + if outputFile := c.String("output-file"); outputFile != "" { + contents := fmt.Sprintf(`--- +web-push-public-key: %s +web-push-private-key: %s +`, publicKey, privateKey) + err = os.WriteFile(outputFile, []byte(contents), 0660) + if err != nil { + return err + } + _, err = fmt.Fprintf(c.App.Writer, "Web Push keys written to %s.\n", outputFile) + } else { + _, 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 @@ -44,5 +64,6 @@ web-push-email-address: See https://ntfy.sh/docs/config/#web-push for details. `, publicKey, privateKey) + } return err } diff --git a/cmd/webpush_test.go b/cmd/webpush_test.go index 51926ca1..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,9 +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) { + 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, 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 eb63b947..56c2ceb8 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. @@ -50,6 +50,7 @@ Here are a few working sample configs using a `/etc/ntfy/server.yml` file: listen-http: ":2586" cache-file: "/var/cache/ntfy/cache.db" attachment-cache-dir: "/var/cache/ntfy/attachments" + behind-proxy: true ``` === "server.yml (ntfy.sh config)" @@ -78,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 @@ -88,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' # Must escape '$' as '$$' NTFY_BEHIND_PROXY: true NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments NTFY_ENABLE_LOGIN: true @@ -100,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 @@ -189,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)). @@ -222,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: ``` @@ -283,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 @@ -293,8 +392,14 @@ 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 20 tokens (hardcoded). +automatically (or never expire). Each user can have up to 60 tokens (hardcoded). **Example commands** (type `ntfy token --help` or `ntfy token COMMAND --help` for more details): ``` @@ -303,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:** @@ -310,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 `:[: