Repository: binwiederhier/ntfy Branch: main Commit: f9974d8a2f45 Files: 377 Total size: 4.7 MB Directory structure: gitextract_5801oewz/ ├── .dockerignore ├── .git-blame-ignore-revs ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── 1_bug_report.md │ │ ├── 2_enhancement_request.md │ │ ├── 3_tech_support.md │ │ └── 4_question.md │ └── workflows/ │ ├── build.yaml │ ├── docs.yaml │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .gitpod.yml ├── .goreleaser.yml ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── Dockerfile-arm ├── Dockerfile-build ├── LICENSE ├── LICENSE.GPLv2 ├── Makefile ├── README.md ├── SECURITY.md ├── assets/ │ └── favicon.xcf ├── client/ │ ├── client.go │ ├── client.yml │ ├── client_test.go │ ├── config.go │ ├── config_darwin.go │ ├── config_test.go │ ├── config_unix.go │ ├── config_windows.go │ ├── ntfy-client.service │ ├── options.go │ └── user/ │ └── ntfy-client.service ├── cmd/ │ ├── access.go │ ├── access_test.go │ ├── app.go │ ├── app_test.go │ ├── config_loader.go │ ├── config_loader_test.go │ ├── publish.go │ ├── publish_test.go │ ├── publish_unix.go │ ├── publish_windows.go │ ├── serve.go │ ├── serve_test.go │ ├── serve_unix.go │ ├── serve_windows.go │ ├── subscribe.go │ ├── subscribe_darwin.go │ ├── subscribe_test.go │ ├── subscribe_unix.go │ ├── subscribe_windows.go │ ├── tier.go │ ├── tier_test.go │ ├── token.go │ ├── token_test.go │ ├── user.go │ ├── user_test.go │ ├── webpush.go │ └── webpush_test.go ├── db/ │ ├── db.go │ ├── pg/ │ │ ├── pg.go │ │ └── pg_test.go │ ├── test/ │ │ └── test.go │ ├── types.go │ └── util.go ├── docker-compose.yml ├── docs/ │ ├── _overrides/ │ │ └── main.html │ ├── config.md │ ├── contact.md │ ├── contributing.md │ ├── deprecations.md │ ├── develop.md │ ├── emojis.md │ ├── examples.md │ ├── faq.md │ ├── hooks.py │ ├── index.md │ ├── install.md │ ├── integrations.md │ ├── known-issues.md │ ├── privacy.md │ ├── publish/ │ │ └── template-functions.md │ ├── publish.md │ ├── releases.md │ ├── static/ │ │ ├── audio/ │ │ │ └── ntfy-phone-call.ogg │ │ ├── css/ │ │ │ ├── config-generator.css │ │ │ └── extra.css │ │ ├── img/ │ │ │ ├── cli-subscribe-video-2.webm │ │ │ └── cli-subscribe-video-3.webm │ │ └── js/ │ │ ├── bcrypt.js │ │ ├── config-generator.js │ │ └── extra.js │ ├── subscribe/ │ │ ├── api.md │ │ ├── cli.md │ │ ├── phone.md │ │ ├── pwa.md │ │ └── web.md │ ├── terms.md │ └── troubleshooting.md ├── examples/ │ ├── grafana-dashboard/ │ │ └── ntfy-grafana.json │ ├── linux-desktop-notifications/ │ │ └── notify-desktop.sh │ ├── publish-go/ │ │ └── main.go │ ├── publish-php/ │ │ └── publish.php │ ├── publish-python/ │ │ └── publish.py │ ├── ssh-login-alert/ │ │ ├── ntfy-ssh-login.sh │ │ └── pam_sshd │ ├── subscribe-go/ │ │ └── main.go │ ├── subscribe-php/ │ │ └── subscribe.php │ ├── subscribe-python/ │ │ └── subscribe.py │ ├── web-example-eventsource/ │ │ └── example-sse.html │ └── web-example-websocket/ │ └── example-ws.html ├── go.mod ├── go.sum ├── log/ │ ├── event.go │ ├── log.go │ ├── log_test.go │ └── types.go ├── main.go ├── message/ │ ├── cache.go │ ├── cache_postgres.go │ ├── cache_postgres_schema.go │ ├── cache_sqlite.go │ ├── cache_sqlite_schema.go │ ├── cache_sqlite_test.go │ └── cache_test.go ├── mkdocs.yml ├── model/ │ └── model.go ├── payments/ │ ├── payments.go │ └── payments_dummy.go ├── requirements.txt ├── scripts/ │ ├── emoji-convert.sh │ ├── emoji.json │ ├── postinst.sh │ ├── postrm.sh │ ├── preinst.sh │ └── prerm.sh ├── server/ │ ├── actions.go │ ├── actions_test.go │ ├── config.go │ ├── config_test.go │ ├── config_unix.go │ ├── config_windows.go │ ├── errors.go │ ├── file_cache.go │ ├── file_cache_test.go │ ├── log.go │ ├── mailer_emoji_map.json │ ├── ntfy.service │ ├── server.go │ ├── server.yml │ ├── server_account.go │ ├── server_account_test.go │ ├── server_admin.go │ ├── server_admin_test.go │ ├── server_firebase.go │ ├── server_firebase_dummy.go │ ├── server_firebase_test.go │ ├── server_manager.go │ ├── server_manager_test.go │ ├── server_matrix.go │ ├── server_matrix_test.go │ ├── server_metrics.go │ ├── server_middleware.go │ ├── server_payments.go │ ├── server_payments_dummy.go │ ├── server_payments_test.go │ ├── server_race_off_test.go │ ├── server_race_on_test.go │ ├── server_test.go │ ├── server_twilio.go │ ├── server_twilio_test.go │ ├── server_webpush.go │ ├── server_webpush_dummy.go │ ├── server_webpush_test.go │ ├── smtp_sender.go │ ├── smtp_sender_test.go │ ├── smtp_server.go │ ├── smtp_server_test.go │ ├── templates/ │ │ ├── alertmanager.yml │ │ ├── github.yml │ │ └── grafana.yml │ ├── testdata/ │ │ ├── webhook_alertmanager_firing.json │ │ ├── webhook_github_comment_created.json │ │ ├── webhook_github_issue_opened.json │ │ ├── webhook_github_pr_opened.json │ │ ├── webhook_github_star_created.json │ │ ├── webhook_github_watch_created.json │ │ └── webhook_grafana_resolved.json │ ├── topic.go │ ├── topic_test.go │ ├── types.go │ ├── util.go │ ├── util_test.go │ └── visitor.go ├── test/ │ ├── server.go │ ├── test.go │ └── util.go ├── tools/ │ ├── fbsend/ │ │ ├── README.md │ │ └── main.go │ ├── loadgen/ │ │ └── main.go │ ├── loadtest/ │ │ ├── go.mod │ │ ├── go.sum │ │ └── main.go │ ├── pgimport/ │ │ ├── README.md │ │ └── main.go │ └── shrink-png.sh ├── user/ │ ├── manager.go │ ├── manager_postgres.go │ ├── manager_postgres_schema.go │ ├── manager_sqlite.go │ ├── manager_sqlite_schema.go │ ├── manager_test.go │ ├── types.go │ ├── types_test.go │ ├── util.go │ └── util_test.go ├── util/ │ ├── batching_queue.go │ ├── batching_queue_test.go │ ├── content_type_writer.go │ ├── content_type_writer_test.go │ ├── embedfs/ │ │ └── test.txt │ ├── embedfs.go │ ├── embedfs_test.go │ ├── gzip_handler.go │ ├── gzip_handler_test.go │ ├── limit.go │ ├── limit_test.go │ ├── lookup_cache.go │ ├── lookup_cache_test.go │ ├── peek.go │ ├── peek_test.go │ ├── sprig/ │ │ ├── LICENSE.txt │ │ ├── crypto.go │ │ ├── crypto_test.go │ │ ├── date.go │ │ ├── date_test.go │ │ ├── defaults.go │ │ ├── defaults_test.go │ │ ├── dict.go │ │ ├── dict_test.go │ │ ├── doc.go │ │ ├── example_test.go │ │ ├── flow_control.go │ │ ├── flow_control_test.go │ │ ├── functions.go │ │ ├── functions_linux_test.go │ │ ├── functions_test.go │ │ ├── list.go │ │ ├── list_test.go │ │ ├── numeric.go │ │ ├── numeric_test.go │ │ ├── reflect.go │ │ ├── reflect_test.go │ │ ├── regex.go │ │ ├── regex_test.go │ │ ├── strings.go │ │ ├── strings_test.go │ │ ├── url.go │ │ └── url_test.go │ ├── time.go │ ├── time_test.go │ ├── timeout_writer.go │ ├── util.go │ └── util_test.go ├── web/ │ ├── .eslintignore │ ├── .eslintrc │ ├── .prettierignore │ ├── index.html │ ├── package.json │ ├── public/ │ │ ├── config.js │ │ ├── static/ │ │ │ ├── css/ │ │ │ │ ├── app.css │ │ │ │ └── fonts.css │ │ │ └── langs/ │ │ │ ├── ar.json │ │ │ ├── bg.json │ │ │ ├── bn.json │ │ │ ├── ca.json │ │ │ ├── cs.json │ │ │ ├── cu.json │ │ │ ├── cy.json │ │ │ ├── da.json │ │ │ ├── de.json │ │ │ ├── en.json │ │ │ ├── eo.json │ │ │ ├── es.json │ │ │ ├── et.json │ │ │ ├── fa.json │ │ │ ├── fi.json │ │ │ ├── fr.json │ │ │ ├── gl.json │ │ │ ├── he.json │ │ │ ├── hu.json │ │ │ ├── id.json │ │ │ ├── it.json │ │ │ ├── ja.json │ │ │ ├── ko.json │ │ │ ├── mk.json │ │ │ ├── ms.json │ │ │ ├── nb_NO.json │ │ │ ├── nl.json │ │ │ ├── pl.json │ │ │ ├── pt.json │ │ │ ├── pt_BR.json │ │ │ ├── ro.json │ │ │ ├── ru.json │ │ │ ├── sk.json │ │ │ ├── sq.json │ │ │ ├── sv.json │ │ │ ├── ta.json │ │ │ ├── th.json │ │ │ ├── tr.json │ │ │ ├── uk.json │ │ │ ├── uz.json │ │ │ ├── vi.json │ │ │ ├── zh_Hans.json │ │ │ └── zh_Hant.json │ │ └── sw.js │ ├── src/ │ │ ├── app/ │ │ │ ├── AccountApi.js │ │ │ ├── Api.js │ │ │ ├── Connection.js │ │ │ ├── ConnectionManager.js │ │ │ ├── Notifier.js │ │ │ ├── Poller.js │ │ │ ├── Prefs.js │ │ │ ├── Pruner.js │ │ │ ├── Session.js │ │ │ ├── SubscriptionManager.js │ │ │ ├── UserManager.js │ │ │ ├── VersionChecker.js │ │ │ ├── actions.js │ │ │ ├── config.js │ │ │ ├── db.js │ │ │ ├── emojis.js │ │ │ ├── emojisMapped.js │ │ │ ├── errors.js │ │ │ ├── events.js │ │ │ ├── i18n.js │ │ │ ├── notificationUtils.js │ │ │ └── utils.js │ │ ├── components/ │ │ │ ├── Account.jsx │ │ │ ├── ActionBar.jsx │ │ │ ├── App.jsx │ │ │ ├── AttachmentIcon.jsx │ │ │ ├── AvatarBox.jsx │ │ │ ├── DialogFooter.jsx │ │ │ ├── EmojiPicker.jsx │ │ │ ├── ErrorBoundary.jsx │ │ │ ├── Login.jsx │ │ │ ├── Messaging.jsx │ │ │ ├── Navigation.jsx │ │ │ ├── Notifications.jsx │ │ │ ├── PopupMenu.jsx │ │ │ ├── Pref.jsx │ │ │ ├── Preferences.jsx │ │ │ ├── PublishDialog.jsx │ │ │ ├── RTLCacheProvider.jsx │ │ │ ├── ReserveDialogs.jsx │ │ │ ├── ReserveIcons.jsx │ │ │ ├── ReserveTopicSelect.jsx │ │ │ ├── Signup.jsx │ │ │ ├── SubscribeDialog.jsx │ │ │ ├── SubscriptionPopup.jsx │ │ │ ├── UpgradeDialog.jsx │ │ │ ├── hooks.js │ │ │ ├── routes.js │ │ │ ├── styles.js │ │ │ └── theme.js │ │ ├── index.jsx │ │ └── registerSW.js │ └── vite.config.js └── webpush/ ├── store.go ├── store_postgres.go ├── store_sqlite.go ├── store_test.go └── types.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ dist */node_modules Dockerfile* ================================================ FILE: .git-blame-ignore-revs ================================================ # https://docs.github.com/en/repositories/working-with-files/using-files/viewing-a-file#ignore-commits-in-the-blame-view # Run prettier (https://github.com/binwiederhier/ntfy/pull/746) 6f6a2d1f693070bf72e89d86748080e4825c9164 c87549e71a10bc789eac8036078228f06e515a8e ca5d736a7169eb6b4b0d849e061d5bf9565dcc53 2e27f58963feb9e4d1c573d4745d07770777fa7d # Run eslint (https://github.com/binwiederhier/ntfy/pull/748) f558b4dbe9bb5b9e0e87fada1215de2558353173 8319f1cf26113167fb29fe12edaff5db74caf35f ================================================ FILE: .github/FUNDING.yml ================================================ github: [binwiederhier] liberapay: ntfy ================================================ FILE: .github/ISSUE_TEMPLATE/1_bug_report.md ================================================ --- name: 🐛 Bug Report about: Report any errors and problems title: '' labels: '🪲 bug' assignees: '' --- :lady_beetle: **Describe the bug** :computer: **Components impacted** :bulb: **Screenshots and/or logs** :crystal_ball: **Additional context** ================================================ FILE: .github/ISSUE_TEMPLATE/2_enhancement_request.md ================================================ --- name: 💡 Feature/Enhancement Request about: Got a great idea? Let us know! title: '' labels: 'enhancement' assignees: '' --- :bulb: **Idea** :computer: **Target components** ================================================ FILE: .github/ISSUE_TEMPLATE/3_tech_support.md ================================================ --- name: 🆘 I need help with ... about: Installing ntfy, configuring the app, etc. title: '' labels: 'tech-support' assignees: '' --- ================================================ FILE: .github/ISSUE_TEMPLATE/4_question.md ================================================ --- name: ❓ Question about: Ask a question about ntfy title: '' labels: 'question' assignees: '' --- :question: **Question** ================================================ FILE: .github/workflows/build.yaml ================================================ name: build on: [ push, pull_request ] jobs: build: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 - name: Install Go uses: actions/setup-go@v4 with: go-version: '1.24.x' - name: Install node uses: actions/setup-node@v3 with: node-version: '20' cache: 'npm' cache-dependency-path: './web/package-lock.json' - name: Install dependencies run: make build-deps-ubuntu - name: Build all the things run: make build - name: Print build results and checksums run: make cli-build-results ================================================ FILE: .github/workflows/docs.yaml ================================================ name: docs on: push: branches: - main jobs: publish-docs: runs-on: ubuntu-latest steps: - name: Checkout ntfy code uses: actions/checkout@v3 - name: Checkout docs pages code uses: actions/checkout@v3 with: repository: binwiederhier/ntfy-docs.github.io path: build/ntfy-docs.github.io token: ${{secrets.NTFY_DOCS_PUSH_TOKEN}} # Expires after 1 year, re-generate via # User -> Settings -> Developer options -> Personal Access Tokens -> Fine Grained Token - name: Build docs run: make docs - name: Copy generated docs run: rsync -av --exclude CNAME --delete server/docs/ build/ntfy-docs.github.io/docs/ - name: Publish docs run: | cd build/ntfy-docs.github.io git config user.name "GitHub Actions Bot" git config user.email "" git add docs/ git commit -m "Updated docs" git push origin main ================================================ FILE: .github/workflows/release.yaml ================================================ name: release on: push: tags: - 'v[0-9]+.[0-9]+.[0-9]+' jobs: release: runs-on: ubuntu-latest services: postgres: image: postgres:17 env: POSTGRES_USER: ntfy POSTGRES_PASSWORD: ntfy POSTGRES_DB: ntfy_test ports: - 5432:5432 options: >- --health-cmd "pg_isready -U ntfy" --health-interval 10s --health-timeout 5s --health-retries 5 env: NTFY_TEST_DATABASE_URL: "postgres://ntfy:ntfy@localhost:5432/ntfy_test?sslmode=disable" steps: - name: Checkout code uses: actions/checkout@v3 - name: Install Go uses: actions/setup-go@v4 with: go-version: '1.24.x' - name: Install node uses: actions/setup-node@v3 with: node-version: '20' cache: 'npm' cache-dependency-path: './web/package-lock.json' - name: Docker login uses: docker/login-action@v2 with: username: ${{ github.repository_owner }} password: ${{ secrets.DOCKER_HUB_TOKEN }} - name: Install dependencies run: make build-deps-ubuntu - name: Build and publish run: make release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Print build results and checksums run: make cli-build-results ================================================ FILE: .github/workflows/test.yaml ================================================ name: test on: [ push, pull_request ] jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:17 env: POSTGRES_USER: ntfy POSTGRES_PASSWORD: ntfy POSTGRES_DB: ntfy_test ports: - 5432:5432 options: >- --health-cmd "pg_isready -U ntfy" --health-interval 10s --health-timeout 5s --health-retries 5 env: NTFY_TEST_DATABASE_URL: "postgres://ntfy:ntfy@localhost:5432/ntfy_test?sslmode=disable" steps: - name: Checkout code uses: actions/checkout@v3 - name: Install Go uses: actions/setup-go@v4 with: go-version: '1.24.x' - name: Install node uses: actions/setup-node@v3 with: node-version: '20' cache: 'npm' cache-dependency-path: './web/package-lock.json' - name: Install dependencies run: make build-deps-ubuntu - name: Build docs (required for tests) run: make docs - name: Build web app (required for tests) run: make web - name: Run tests, formatting, vetting and linting run: make checkv - name: Run coverage run: make coverage ================================================ FILE: .gitignore ================================================ dist/ dev-dist/ build/ .idea/ .vscode/ *.swp server/docs/ server/site/ tools/fbsend/fbsend tools/pgimport/pgimport tools/loadtest/loadtest playground/ secrets/ *.iml node_modules/ .DS_Store __pycache__ web/dev-dist/ venv/ cmd/key-file.yaml ================================================ FILE: .gitpod.yml ================================================ tasks: - name: docs before: make docs-deps command: mkdocs serve - name: binary before: | npm install --global nodemon make cli-deps-static-sites command: | nodemon --watch './**/*.go' --ext go --signal SIGTERM --exec "CGO_ENABLED=1 go run main.go serve --listen-http :2586 --debug --base-url $(gp url 2586)" openMode: split-right - name: web before: make web-deps command: cd web && npm start openMode: split-right vscode: extensions: - golang.go - ms-azuretools.vscode-docker ports: - name: docs port: 8000 - name: binary port: 2586 - name: web port: 3000 ================================================ FILE: .goreleaser.yml ================================================ version: 2 before: hooks: - go mod download - go mod tidy builds: - id: ntfy_linux_amd64 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 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 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 ] 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 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 ] 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 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 ] 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 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - CC=x86_64-w64-mingw32-gcc # apt install gcc-mingw-w64-x86-64 tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" 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 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) nfpms: - package_name: ntfy homepage: https://heckel.io/ntfy maintainer: Philipp C. Heckel description: Simple pub-sub notification service license: Apache 2.0 formats: - deb - rpm bindir: /usr/bin contents: - src: server/server.yml dst: /etc/ntfy/server.yml type: "config|noreplace" - src: server/ntfy.service dst: /lib/systemd/system/ntfy.service - src: client/client.yml dst: /etc/ntfy/client.yml 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 type: dir - dst: /var/lib/ntfy type: dir - dst: /usr/share/ntfy/logo.png src: web/public/static/images/ntfy.png scripts: preinstall: "scripts/preinst.sh" postinstall: "scripts/postinst.sh" preremove: "scripts/prerm.sh" postremove: "scripts/postrm.sh" archives: - id: ntfy_linux ids: - ntfy_linux_amd64 - ntfy_linux_armv6 - ntfy_linux_armv7 - ntfy_linux_arm64 wrap_in_directory: true files: - LICENSE - README.md - server/server.yml - server/ntfy.service - client/client.yml - client/ntfy-client.service - client/user/ntfy-client.service - id: ntfy_windows ids: - ntfy_windows_amd64 formats: [ zip ] wrap_in_directory: true files: - LICENSE - README.md - client/client.yml - id: ntfy_darwin ids: - ntfy_darwin_all wrap_in_directory: true files: - LICENSE - README.md - client/client.yml universal_binaries: - id: ntfy_darwin_all replace: true name_template: ntfy checksum: name_template: 'checksums.txt' snapshot: version_template: "{{ .Tag }}-next" changelog: sort: asc filters: exclude: - '^docs:' - '^test:' dockers: - image_templates: - &amd64_image "binwiederhier/ntfy:{{ .Tag }}-amd64" use: buildx dockerfile: Dockerfile goarch: amd64 build_flag_templates: - "--platform=linux/amd64" - image_templates: - &arm64v8_image "binwiederhier/ntfy:{{ .Tag }}-arm64v8" use: buildx dockerfile: Dockerfile-arm goarch: arm64 build_flag_templates: - "--platform=linux/arm64/v8" - image_templates: - &armv7_image "binwiederhier/ntfy:{{ .Tag }}-armv7" use: buildx dockerfile: Dockerfile-arm goarch: arm goarm: 7 build_flag_templates: - "--platform=linux/arm/v7" - image_templates: - &armv6_image "binwiederhier/ntfy:{{ .Tag }}-armv6" use: buildx dockerfile: Dockerfile-arm goarch: arm goarm: 6 build_flag_templates: - "--platform=linux/arm/v6" docker_manifests: - name_template: "binwiederhier/ntfy:latest" image_templates: - *amd64_image - *arm64v8_image - *armv7_image - *armv6_image - name_template: "binwiederhier/ntfy:{{ .Tag }}" image_templates: - *amd64_image - *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 ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge 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. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement via Discord/Matrix (binwiederhier), or email (ntfy@heckel.io). All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. [homepage]: https://www.contributor-covenant.org [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html [Mozilla CoC]: https://github.com/mozilla/diversity [FAQ]: https://www.contributor-covenant.org/faq [translations]: https://www.contributor-covenant.org/translations ================================================ FILE: Dockerfile ================================================ FROM alpine 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/" LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy" 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" RUN apk add --no-cache tzdata COPY ntfy /usr/bin EXPOSE 80/tcp ENTRYPOINT ["ntfy"] ================================================ FILE: Dockerfile-arm ================================================ FROM alpine 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/" LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy" 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" # Alpine does not support adding "tzdata" on ARM anymore, see # https://github.com/binwiederhier/ntfy/issues/894 COPY ntfy /usr/bin EXPOSE 80/tcp ENTRYPOINT ["ntfy"] ================================================ FILE: Dockerfile-build ================================================ FROM golang:1.24-bullseye as builder ARG VERSION=dev ARG COMMIT=unknown ARG NODE_MAJOR=18 RUN apt-get update && apt-get install -y \ build-essential ca-certificates curl gnupg \ && mkdir -p /etc/apt/keyrings \ && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" >> /etc/apt/sources.list.d/nodesource.list \ && apt-get update \ && apt-get install -y \ python3-pip \ python3-venv \ nodejs \ && rm -rf /var/lib/apt/lists/* WORKDIR /app ADD Makefile . # docs ADD ./requirements.txt . RUN make docs-deps ADD ./mkdocs.yml . ADD ./docs ./docs RUN make docs-build # web ADD ./web/package.json ./web/package-lock.json ./web/ RUN make web-deps ADD ./web ./web RUN make web-build # cli & server ADD go.mod go.sum main.go ./ ADD ./client ./client ADD ./cmd ./cmd ADD ./log ./log ADD ./server ./server ADD ./user ./user ADD ./util ./util ADD ./payments ./payments 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/" LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy" 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 EXPOSE 80/tcp ENTRYPOINT ["ntfy"] ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2021 Philipp C. Heckel Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: LICENSE.GPLv2 ================================================ GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. ntfy Copyright (C) 2021 Philipp C. Heckel This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. Also add information on how to contact you by electronic and paper mail. If the program is interactive, make it output a short notice like this when it starts in an interactive mode: Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. , 1 April 1989 Ty Coon, President of Vice This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. ================================================ FILE: Makefile ================================================ MAKEFLAGS := --jobs=1 NPM := npm PYTHON := python3 PIP := pip3 VERSION := $(shell git describe --tag) COMMIT := $(shell git rev-parse --short HEAD) .PHONY: help: @echo "Typical commands (more see below):" @echo " make build - Build web app, documentation and server/client (sloowwww)" @echo " make cli-linux-amd64 - Build server/client binary (amd64, no web app or docs)" @echo " make install-linux-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)" @echo " make web - Build the web app" @echo " make docs - Build the documentation" @echo " make check - Run all tests, vetting/formatting checks and linters" @echo @echo "Build everything:" @echo " make build - Build web app, documentation and server/client" @echo " make clean - Clean build/dist folders" @echo @echo "Build server & client (using GoReleaser, not release version):" @echo " make cli - Build server & client (all architectures)" @echo " make cli-linux-amd64 - Build server & client (Linux, amd64 only)" @echo " make cli-linux-armv6 - Build server & client (Linux, armv6 only)" @echo " make cli-linux-armv7 - Build server & client (Linux, armv7 only)" @echo " make cli-linux-arm64 - Build server & client (Linux, arm64 only)" @echo " make cli-windows-amd64 - Build client (Windows, amd64 only)" @echo " make cli-darwin-all - Build client (macOS, arm64+amd64 universal binary)" @echo @echo "Build server & client (without GoReleaser):" @echo " make cli-linux-server - Build client & server (no GoReleaser, current arch, Linux)" @echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)" @echo " make cli-windows-server - Build client & server (no GoReleaser, amd64 only, Windows)" @echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)" @echo @echo "Build dev Docker:" @echo " make docker-dev - Build client & server for current architecture using Docker only" @echo @echo "Build web app:" @echo " make web - Build the web app" @echo " make web-deps - Install web app dependencies (npm install the universe)" @echo " make web-build - Actually build the web app" @echo " make web-lint - Run eslint on the web app" @echo " make web-fmt - Run prettier on the web app" @echo " make web-fmt-check - Run prettier on the web app, but don't change anything" @echo @echo "Build documentation:" @echo " make docs - Build the documentation" @echo " make docs-deps - Install Python dependencies (pip3 install)" @echo " make docs-build - Actually build the documentation" @echo @echo "Test/check:" @echo " make test - Run tests" @echo " make race - Run tests with -race flag" @echo " make coverage - Run tests and show coverage" @echo " make coverage-html - Run tests and show coverage (as HTML)" @echo " make coverage-upload - Upload coverage results to codecov.io" @echo @echo "Lint/format:" @echo " make fmt - Run 'go fmt'" @echo " make fmt-check - Run 'go fmt', but don't change anything" @echo " make vet - Run 'go vet'" @echo " make lint - Run 'golint'" @echo " make staticcheck - Run 'staticcheck'" @echo @echo "Releasing:" @echo " make release - Create a release" @echo " make release-snapshot - Create a test release" @echo @echo "Install locally (requires sudo):" @echo " make install-linux-amd64 - Copy amd64 binary from dist/ to /usr/bin/ntfy" @echo " make install-linux-armv6 - Copy armv6 binary from dist/ to /usr/bin/ntfy" @echo " make install-linux-armv7 - Copy armv7 binary from dist/ to /usr/bin/ntfy" @echo " make install-linux-arm64 - Copy arm64 binary from dist/ to /usr/bin/ntfy" @echo " make install-linux-deb-amd64 - Install .deb from dist/ (amd64 only)" @echo " make install-linux-deb-armv6 - Install .deb from dist/ (armv6 only)" @echo " make install-linux-deb-armv7 - Install .deb from dist/ (armv7 only)" @echo " make install-linux-deb-arm64 - Install .deb from dist/ (arm64 only)" # Building everything clean: .PHONY rm -rf dist build server/docs server/site build: web docs cli update: web-deps-update cli-deps-update docs-deps-update docker pull alpine docker-dev: docker build \ --file ./Dockerfile-build \ --tag binwiederhier/ntfy:$(VERSION) \ --tag binwiederhier/ntfy:dev \ --build-arg VERSION=$(VERSION) \ --build-arg COMMIT=$(COMMIT) \ ./ # Ubuntu-specific build-deps-ubuntu: sudo apt-get update sudo apt-get install -y \ curl \ gcc-aarch64-linux-gnu \ gcc-arm-linux-gnueabi \ gcc-mingw-w64-x86-64 \ python3 \ python3-venv \ jq which pip3 || sudo apt-get install -y python3-pip # Documentation docs: docs-deps docs-build docs-venv: .PHONY $(PYTHON) -m venv ./venv docs-build: docs-venv (. venv/bin/activate && $(PYTHON) -m mkdocs build) docs-deps: docs-venv (. venv/bin/activate && $(PIP) install -r requirements.txt) docs-deps-update: .PHONY (. venv/bin/activate && $(PIP) install -r requirements.txt --upgrade) # Web app web: web-deps web-build web-build: cd web \ && $(NPM) run build \ && mv build/index.html build/app.html \ && rm -rf ../server/site \ && mv build ../server/site \ && rm \ ../server/site/config.js web-deps: cd web && $(NPM) install # If this fails for .svg files, optimize them with svgo web-deps-update: cd web && $(NPM) update web-fmt: cd web && $(NPM) run format web-fmt-check: cd web && $(NPM) run format:check web-lint: cd web && $(NPM) run lint # Main server/client build cli: cli-deps goreleaser build --snapshot --clean cli-linux-amd64: cli-deps-static-sites goreleaser build --snapshot --clean --id ntfy_linux_amd64 cli-linux-armv6: cli-deps-static-sites cli-deps-gcc-armv6-armv7 goreleaser build --snapshot --clean --id ntfy_linux_armv6 cli-linux-armv7: cli-deps-static-sites cli-deps-gcc-armv6-armv7 goreleaser build --snapshot --clean --id ntfy_linux_armv7 cli-linux-arm64: cli-deps-static-sites cli-deps-gcc-arm64 goreleaser build --snapshot --clean --id ntfy_linux_arm64 cli-windows-amd64: cli-deps-static-sites goreleaser build --snapshot --clean --id ntfy_windows_amd64 cli-darwin-all: cli-deps-static-sites goreleaser build --snapshot --clean --id ntfy_darwin_all cli-linux-server: cli-deps-static-sites # This is a target to build the CLI (including the server) manually. # Use this for development, if you really don't want to install GoReleaser ... mkdir -p dist/ntfy_linux_server server/docs CGO_ENABLED=1 go build \ -o dist/ntfy_linux_server/ntfy \ -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=$(shell date +%s)" cli-darwin-server: cli-deps-static-sites # This is a target to build the CLI (including the server) manually. # Use this for macOS/iOS development, so you have a local server to test with. mkdir -p dist/ntfy_darwin_server server/docs CGO_ENABLED=1 go build \ -o dist/ntfy_darwin_server/ntfy \ -tags sqlite_omit_load_extension,osusergo,netgo \ -ldflags \ "-linkmode=external -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)" cli-windows-server: cli-deps-static-sites # This is a target to build the CLI (including the server) for Windows. # Use this for Windows development, if you really don't want to install GoReleaser ... mkdir -p dist/ntfy_windows_server server/docs CC=x86_64-w64-mingw32-gcc GOOS=windows GOARCH=amd64 CGO_ENABLED=1 go build \ -o dist/ntfy_windows_server/ntfy.exe \ -tags sqlite_omit_load_extension,osusergo,netgo \ -ldflags \ "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)" cli-client: cli-deps-static-sites # This is a target to build the CLI (excluding the server) manually. This should work on Linux/macOS/Windows. # Use this for development, if you really don't want to install GoReleaser ... mkdir -p dist/ntfy_client server/docs CGO_ENABLED=0 go build \ -o dist/ntfy_client/ntfy \ -tags noserver \ -ldflags \ "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)" cli-deps: cli-deps-static-sites cli-deps-all cli-deps-gcc cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64 cli-deps-gcc-windows cli-deps-static-sites: mkdir -p server/docs server/site touch server/docs/index.html server/site/app.html cli-deps-all: 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; } cli-deps-gcc-arm64: which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; } cli-deps-gcc-windows: which x86_64-w64-mingw32-gcc || { echo "ERROR: Windows cross compiler not installed. On Ubuntu, run: apt install gcc-mingw-w64-x86-64"; exit 1; } cli-deps-update: go get -u go mod tidy go install honnef.co/go/tools/cmd/staticcheck@latest go install golang.org/x/lint/golint@latest go install github.com/goreleaser/goreleaser/v2@latest cli-build-results: cat dist/config.yaml [ -f dist/artifacts.json ] && cat dist/artifacts.json | jq . || true [ -f dist/metadata.json ] && cat dist/metadata.json | jq . || true [ -f dist/checksums.txt ] && cat dist/checksums.txt || true find dist -maxdepth 2 -type f \ \( -name '*.deb' -or -name '*.rpm' -or -name '*.zip' -or -name '*.tar.gz' -or -name 'ntfy' \) \ -and -not -path 'dist/goreleaserdocker*' \ -exec sha256sum {} \; # Test/check targets check: test web-fmt-check fmt-check vet web-lint lint staticcheck checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck test: .PHONY go test $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)') testv: .PHONY go test -v $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)') race: .PHONY go test -v -race $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)') coverage: mkdir -p build/coverage go test -v -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools|web)') go tool cover -func build/coverage/coverage.txt coverage-html: mkdir -p build/coverage go test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)') go tool cover -html build/coverage/coverage.txt coverage-upload: cd build/coverage && (curl -s https://codecov.io/bash | bash) # Lint/formatting targets fmt: web-fmt gofmt -s -w . fmt-check: test -z $(shell gofmt -l .) vet: go vet ./... lint: which golint || go install golang.org/x/lint/golint@latest go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status staticcheck: .PHONY rm -rf build/staticcheck which staticcheck || go install honnef.co/go/tools/cmd/staticcheck@latest mkdir -p build/staticcheck ln -s "go" build/staticcheck/go PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck ./... rm -rf build/staticcheck # Releasing targets release: clean cli-deps release-checks docs web check goreleaser release --clean release-snapshot: clean cli-deps docs web check goreleaser release --snapshot --clean release-checks: $(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-)) if ! grep -q $(LATEST_TAG) docs/install.md; then\ echo "ERROR: Must update docs/install.md with latest tag first.";\ exit 1;\ fi if ! grep -q $(LATEST_TAG) docs/releases.md; then\ echo "ERROR: Must update docs/releases.md with latest tag first.";\ exit 1;\ fi if [ -n "$(shell git status -s)" ]; then\ echo "ERROR: Git repository is in an unclean state.";\ exit 1;\ fi # Installing targets install-linux-amd64: remove-binary sudo cp -a dist/ntfy_linux_amd64_linux_amd64_v1/ntfy /usr/bin/ntfy install-linux-armv6: remove-binary sudo cp -a dist/ntfy_linux_armv6_linux_arm_6/ntfy /usr/bin/ntfy install-linux-armv7: remove-binary sudo cp -a dist/ntfy_linux_armv7_linux_arm_7/ntfy /usr/bin/ntfy install-linux-arm64: remove-binary sudo cp -a dist/ntfy_linux_arm64_linux_arm64/ntfy /usr/bin/ntfy remove-binary: sudo rm -f /usr/bin/ntfy install-linux-amd64-deb: purge-package sudo dpkg -i dist/ntfy_*_linux_amd64.deb install-linux-armv6-deb: purge-package sudo dpkg -i dist/ntfy_*_linux_armv6.deb install-linux-armv7-deb: purge-package sudo dpkg -i dist/ntfy_*_linux_armv7.deb install-linux-arm64-deb: purge-package sudo dpkg -i dist/ntfy_*_linux_arm64.deb purge-package: sudo systemctl stop ntfy || true sudo apt-get purge ntfy || true ================================================ FILE: README.md ================================================
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 [![Release](https://img.shields.io/github/release/binwiederhier/ntfy.svg?color=success&style=flat-square)](https://github.com/binwiederhier/ntfy/releases/latest) [![Go Reference](https://pkg.go.dev/badge/heckel.io/ntfy.svg)](https://pkg.go.dev/heckel.io/ntfy/v2) [![Tests](https://github.com/binwiederhier/ntfy/workflows/test/badge.svg)](https://github.com/binwiederhier/ntfy/actions) [![Go Report Card](https://goreportcard.com/badge/github.com/binwiederhier/ntfy)](https://goreportcard.com/report/github.com/binwiederhier/ntfy) [![codecov](https://codecov.io/gh/binwiederhier/ntfy/branch/main/graph/badge.svg?token=A597KQ463G)](https://codecov.io/gh/binwiederhier/ntfy) [![Discord](https://img.shields.io/discord/874398661709295626?label=Discord)](https://discord.gg/cT7ECsZj9w) [![Matrix](https://img.shields.io/matrix/ntfy:matrix.org?label=Matrix)](https://matrix.to/#/#ntfy:matrix.org) [![Matrix space](https://img.shields.io/matrix/ntfy-space:matrix.org?label=Matrix+space)](https://matrix.to/#/#ntfy-space:matrix.org) [![Healthcheck](https://healthchecks.io/badge/68b65976-b3b0-4102-aec9-980921/kcoEgrLY.svg)](https://ntfy.statuspage.io/) [![Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod)](https://gitpod.io/#https://github.com/binwiederhier/ntfy) **ntfy** (pronounced "*notify*") is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service. With ntfy, you can **send notifications to your phone or desktop via scripts** from any computer, **without having to sign up or pay any fees**. If you'd like to run your own instance of the service, you can easily do so since ntfy is open source. You can access the free version of ntfy at **[ntfy.sh](https://ntfy.sh)**. There is also an [open-source Android app](https://github.com/binwiederhier/ntfy-android) available on [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/), as well as an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) available on the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).

## [ntfy Pro](https://ntfy.sh/app) 💸 🎉 I now offer paid plans for [ntfy.sh](https://ntfy.sh/) if you don't want to self-host, or you want to support the development of ntfy (→ [Purchase via web app](https://ntfy.sh/app)). You can **buy a plan for as low as $5/month**. You can also donate via [GitHub Sponsors](https://github.com/sponsors/binwiederhier), and [Liberapay](https://liberapay.com/ntfy). I would be very humbled by your sponsorship. ❤️ ## **[Documentation](https://ntfy.sh/docs/)** [Getting started](https://ntfy.sh/docs/) | [Android/iOS](https://ntfy.sh/docs/subscribe/phone/) | [API](https://ntfy.sh/docs/publish/) | [Install / Self-hosting](https://ntfy.sh/docs/install/) | [Building](https://ntfy.sh/docs/develop/) ## Chat/forum There are a few ways to get in touch with me and/or the rest of the community. Feel free to use any of these methods. Whatever works best for you: * [Discord server](https://discord.gg/cT7ECsZj9w) - direct chat with the community * [Matrix room #ntfy](https://matrix.to/#/#ntfy:matrix.org) (+ [Matrix space](https://matrix.to/#/#ntfy-space:matrix.org)) - same chat, bridged from Discord * [GitHub issues](https://github.com/binwiederhier/ntfy/issues) - questions, features, bugs ## Announcements/beta testers For announcements of new releases and cutting-edge beta versions, please subscribe to the [ntfy.sh/announcements](https://ntfy.sh/announcements) 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). ## Sponsors 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: ## 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 pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.** _Please be sure to read the complete [Code of Conduct](CODE_OF_CONDUCT.md)._ ## License Made with ❤️ by [Philipp C. Heckel](https://heckel.io). The project is dual licensed under the [Apache License 2.0](LICENSE) and the [GPLv2 License](LICENSE.GPLv2). Third-party libraries and resources: * [github.com/urfave/cli](https://github.com/urfave/cli) (MIT) is used to drive the CLI * [Mixkit sounds](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) are used as notification sounds * [Sounds from notificationsounds.com](https://notificationsounds.com) (Creative Commons Attribution) are used as notification sounds * [Roboto Font](https://fonts.google.com/specimen/Roboto) (Apache 2.0) is used as a font in everything web * [React](https://reactjs.org/) (MIT) is used for the web app * [Material UI components](https://mui.com/) (MIT) are used in the web app * [MUI dashboard template](https://github.com/mui/material-ui/tree/master/docs/data/material/getting-started/templates/dashboard) (MIT) was used as a basis for the web app * [Dexie.js](https://github.com/dexie/Dexie.js) (Apache 2.0) is used for web app persistence in IndexedDB * [GoReleaser](https://goreleaser.com/) (MIT) is used to create releases * [go-smtp](https://github.com/emersion/go-smtp) (MIT) is used to receive e-mails * [stretchr/testify](https://github.com/stretchr/testify) (MIT) is used for unit and integration tests * [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache * [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages * [github/gemoji](https://github.com/github/gemoji) (MIT) is used for emoji support (specifically the [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file) * [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox) as a lightbox on the landing page * [HTTP middleware for gzip compression](https://gist.github.com/CJEnright/bc2d8b8dc0c1389a9feeddb110f822d7) (MIT) is used for serving static files * [Regex for auto-linking](https://github.com/bryanwoods/autolink-js) (MIT) is used to highlight links (the library is not used) * [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 ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions As of today, I only support the latest version of ntfy. Please make sure you stay up-to-date. ## Reporting a Vulnerability 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`). ================================================ FILE: client/client.go ================================================ // Package client provides a ntfy client to publish and subscribe to topics package client import ( "bufio" "context" "encoding/json" "errors" "fmt" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/util" "io" "net/http" "regexp" "strings" "sync" "time" ) const ( // MessageEvent identifies a message event MessageEvent = "message" ) const ( maxResponseBytes = 4096 ) var ( topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // Same as in server/server.go ) // Client is the ntfy client that can be used to publish and subscribe to ntfy topics type Client struct { Messages chan *Message config *Config subscriptions map[string]*subscription mu sync.Mutex } // Message is a struct that represents a ntfy message type Message struct { // TODO combine with server.message ID string Event string Time int64 Topic string Message string Title string Priority int Tags []string Click string Icon string Attachment *Attachment // Additional fields TopicURL string SubscriptionID string Raw string } // Attachment represents a message attachment type Attachment struct { Name string `json:"name"` Type string `json:"type,omitempty"` Size int64 `json:"size,omitempty"` Expires int64 `json:"expires,omitempty"` URL string `json:"url"` Owner string `json:"-"` // IP address of uploader, used for rate limiting } type subscription struct { ID string topicURL string cancel context.CancelFunc } // New creates a new Client using a given Config func New(config *Config) *Client { return &Client{ Messages: make(chan *Message, 50), // Allow reading a few messages config: config, subscriptions: make(map[string]*subscription), } } // Publish sends a message to a specific topic, optionally using options. // See PublishReader for details. func (c *Client) Publish(topic, message string, options ...PublishOption) (*Message, error) { return c.PublishReader(topic, strings.NewReader(message), options...) } // PublishReader sends a message to a specific topic, optionally using options. // // A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https:// // (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the // config (e.g. mytopic -> https://ntfy.sh/mytopic). // // To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache, // WithNoFirebase, and the generic WithHeader. func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) { topicURL, err := c.expandTopicURL(topic) if err != nil { return nil, err } req, err := http.NewRequest("POST", topicURL, body) if err != nil { return nil, err } for _, option := range options { if err := option(req); err != nil { return nil, err } } log.Debug("%s Publishing message with headers %s", util.ShortTopicURL(topicURL), req.Header) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, errors.New(strings.TrimSpace(string(b))) } m, err := toMessage(string(b), topicURL, "") if err != nil { return nil, err } return m, nil } // Poll queries a topic for all (or a limited set) of messages. Unlike Subscribe, this method only polls for // messages and does not subscribe to messages that arrive after this call. // // A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https:// // (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the // config (e.g. mytopic -> https://ntfy.sh/mytopic). // // By default, all messages will be returned, but you can change this behavior using a SubscribeOption. // See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam. func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, error) { topicURL, err := c.expandTopicURL(topic) if err != nil { return nil, err } ctx := context.Background() messages := make([]*Message, 0) msgChan := make(chan *Message) errChan := make(chan error) log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL)) options = append(options, WithPoll()) go func() { err := performSubscribeRequest(ctx, msgChan, topicURL, "", options...) close(msgChan) errChan <- err }() for m := range msgChan { messages = append(messages, m) } return messages, <-errChan } // Subscribe subscribes to a topic to listen for newly incoming messages. The method starts a connection in the // background and returns new messages via the Messages channel. // // A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https:// // (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the // config (e.g. mytopic -> https://ntfy.sh/mytopic). // // By default, only new messages will be returned, but you can change this behavior using a SubscribeOption. // See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam. // // The method returns a unique subscriptionID that can be used in Unsubscribe. // // Example: // // c := client.New(client.NewConfig()) // subscriptionID, _ := c.Subscribe("mytopic") // for m := range c.Messages { // fmt.Printf("New message: %s", m.Message) // } func (c *Client) Subscribe(topic string, options ...SubscribeOption) (string, error) { topicURL, err := c.expandTopicURL(topic) if err != nil { return "", err } c.mu.Lock() defer c.mu.Unlock() subscriptionID := util.RandomString(10) log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL)) ctx, cancel := context.WithCancel(context.Background()) c.subscriptions[subscriptionID] = &subscription{ ID: subscriptionID, topicURL: topicURL, cancel: cancel, } go handleSubscribeConnLoop(ctx, c.Messages, topicURL, subscriptionID, options...) return subscriptionID, nil } // Unsubscribe unsubscribes from a topic that has been previously subscribed to using the unique // subscriptionID returned in Subscribe. func (c *Client) Unsubscribe(subscriptionID string) { c.mu.Lock() defer c.mu.Unlock() sub, ok := c.subscriptions[subscriptionID] if !ok { return } delete(c.subscriptions, subscriptionID) sub.cancel() } func (c *Client) expandTopicURL(topic string) (string, error) { if strings.HasPrefix(topic, "http://") || strings.HasPrefix(topic, "https://") { return topic, nil } else if strings.Contains(topic, "/") { return fmt.Sprintf("https://%s", topic), nil } if !topicRegex.MatchString(topic) { return "", fmt.Errorf("invalid topic name: %s", topic) } return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic), nil } func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicURL, subcriptionID string, options ...SubscribeOption) { for { // TODO The retry logic is crude and may lose messages. It should record the last message like the // Android client, use since=, and do incremental backoff too if err := performSubscribeRequest(ctx, msgChan, topicURL, subcriptionID, options...); err != nil { log.Warn("%s Connection failed: %s", util.ShortTopicURL(topicURL), err.Error()) } select { case <-ctx.Done(): log.Info("%s Connection exited", util.ShortTopicURL(topicURL)) return case <-time.After(10 * time.Second): // TODO Add incremental backoff } } } func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicURL string, subscriptionID string, options ...SubscribeOption) error { streamURL := fmt.Sprintf("%s/json", topicURL) log.Debug("%s Listening to %s", util.ShortTopicURL(topicURL), streamURL) req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil) if err != nil { return err } for _, option := range options { if err := option(req); err != nil { return err } } resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) if err != nil { return err } return errors.New(strings.TrimSpace(string(b))) } scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { messageJSON := scanner.Text() m, err := toMessage(messageJSON, topicURL, subscriptionID) if err != nil { return err } log.Trace("%s Message received: %s", util.ShortTopicURL(topicURL), messageJSON) if m.Event == MessageEvent { msgChan <- m } } return nil } func toMessage(s, topicURL, subscriptionID string) (*Message, error) { var m *Message if err := json.NewDecoder(strings.NewReader(s)).Decode(&m); err != nil { return nil, err } m.TopicURL = topicURL m.SubscriptionID = subscriptionID m.Raw = s return m, nil } ================================================ FILE: client/client.yml ================================================ # ntfy client config file # Base URL used to expand short topic names in the "ntfy publish" and "ntfy subscribe" commands. # If you self-host a ntfy server, you'll likely want to change this. # # default-host: https://ntfy.sh # Default credentials will be used with "ntfy publish" and "ntfy subscribe" if no other credentials are provided. # You can set a default token to use or a default user:password combination, but not both. For an empty password, # use empty double-quotes (""). # # To override the default user:password combination or default token for a particular subscription (e.g., to send # no Authorization header), set the user:pass/token for the subscription to empty double-quotes (""). # default-token: # default-user: # default-password: # Default command will execute after "ntfy subscribe" receives a message if no command is provided in subscription below # default-command: # Subscriptions to topics and their actions. This option is primarily used by the systemd service, # or if you can "ntfy subscribe --from-config" directly. # # Example: # subscribe: # - topic: mytopic # command: /usr/local/bin/mytopic-triggered.sh # - topic: myserver.com/anothertopic # command: 'echo "$message"' # if: # priority: high,urgent # - topic: secret # command: 'notify-send "$m"' # user: phill # password: mypass # - topic: token_topic # token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 # # Variables: # Variable Aliases Description # --------------- --------------------- ----------------------------------- # $NTFY_ID $id Unique message ID # $NTFY_TIME $time Unix timestamp of the message delivery # $NTFY_TOPIC $topic Topic name # $NTFY_MESSAGE $message, $m Message body # $NTFY_TITLE $title, $t Message title # $NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max) # $NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list) # $NTFY_RAW $raw Raw JSON message # # Filters ('if:'): # You can filter 'message', 'title', 'priority' (comma-separated list, logical OR) # and 'tags' (comma-separated list, logical AND). See https://ntfy.sh/docs/subscribe/api/#filter-messages. # # subscribe: ================================================ FILE: client/client_test.go ================================================ package client_test import ( "fmt" "github.com/stretchr/testify/require" "heckel.io/ntfy/v2/client" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/test" "os" "testing" "time" ) func TestMain(m *testing.M) { log.SetLevel(log.ErrorLevel) os.Exit(m.Run()) } func TestClient_Publish_Subscribe(t *testing.T) { s, port := test.StartServer(t) defer test.StopServer(t, s, port) c := client.New(newTestConfig(port)) subscriptionID, _ := c.Subscribe("mytopic") time.Sleep(time.Second) msg, err := c.Publish("mytopic", "some message") require.Nil(t, err) require.Equal(t, "some message", msg.Message) msg, err = c.Publish("mytopic", "some other message", client.WithTitle("some title"), client.WithPriority("high"), client.WithTags([]string{"tag1", "tag 2"})) require.Nil(t, err) require.Equal(t, "some other message", msg.Message) require.Equal(t, "some title", msg.Title) require.Equal(t, []string{"tag1", "tag 2"}, msg.Tags) require.Equal(t, 4, msg.Priority) msg, err = c.Publish("mytopic", "some delayed message", client.WithDelay("25 hours")) require.Nil(t, err) require.Equal(t, "some delayed message", msg.Message) require.True(t, time.Now().Add(24*time.Hour).Unix() < msg.Time) time.Sleep(200 * time.Millisecond) msg = nextMessage(c) require.NotNil(t, msg) require.Equal(t, "some message", msg.Message) msg = nextMessage(c) require.NotNil(t, msg) require.Equal(t, "some other message", msg.Message) require.Equal(t, "some title", msg.Title) require.Equal(t, []string{"tag1", "tag 2"}, msg.Tags) require.Equal(t, 4, msg.Priority) msg = nextMessage(c) require.Nil(t, msg) c.Unsubscribe(subscriptionID) time.Sleep(200 * time.Millisecond) msg, err = c.Publish("mytopic", "a message that won't be received") require.Nil(t, err) require.Equal(t, "a message that won't be received", msg.Message) msg = nextMessage(c) require.Nil(t, msg) } func TestClient_Publish_Poll(t *testing.T) { s, port := test.StartServer(t) defer test.StopServer(t, s, port) c := client.New(newTestConfig(port)) msg, err := c.Publish("mytopic", "some message", client.WithNoFirebase(), client.WithTagsList("tag1,tag2")) require.Nil(t, err) require.Equal(t, "some message", msg.Message) require.Equal(t, []string{"tag1", "tag2"}, msg.Tags) msg, err = c.Publish("mytopic", "this won't be cached", client.WithNoCache()) require.Nil(t, err) require.Equal(t, "this won't be cached", msg.Message) msg, err = c.Publish("mytopic", "some delayed message", client.WithDelay("20 min")) require.Nil(t, err) require.Equal(t, "some delayed message", msg.Message) messages, err := c.Poll("mytopic") require.Nil(t, err) require.Equal(t, 1, len(messages)) require.Equal(t, "some message", messages[0].Message) messages, err = c.Poll("mytopic", client.WithScheduled()) require.Nil(t, err) require.Equal(t, 2, len(messages)) require.Equal(t, "some message", messages[0].Message) require.Equal(t, "some delayed message", messages[1].Message) } func newTestConfig(port int) *client.Config { c := client.NewConfig() c.DefaultHost = fmt.Sprintf("http://127.0.0.1:%d", port) return c } func nextMessage(c *client.Client) *client.Message { select { case m := <-c.Messages: return m default: return nil } } ================================================ FILE: client/config.go ================================================ package client import ( "gopkg.in/yaml.v2" "heckel.io/ntfy/v2/log" "os" ) const ( // DefaultBaseURL is the base URL used to expand short topic names DefaultBaseURL = "https://ntfy.sh" ) // DefaultConfigFile is the default path to the client config file (set in config_*.go) var DefaultConfigFile string // Config is the config struct for a Client type Config struct { DefaultHost string `yaml:"default-host"` DefaultUser string `yaml:"default-user"` DefaultPassword *string `yaml:"default-password"` DefaultToken string `yaml:"default-token"` DefaultCommand string `yaml:"default-command"` Subscribe []Subscribe `yaml:"subscribe"` } // Subscribe is the struct for a Subscription within Config type Subscribe struct { Topic string `yaml:"topic"` User *string `yaml:"user"` Password *string `yaml:"password"` Token *string `yaml:"token"` Command string `yaml:"command"` If map[string]string `yaml:"if"` } // NewConfig creates a new Config struct for a Client func NewConfig() *Config { return &Config{ DefaultHost: DefaultBaseURL, DefaultUser: "", DefaultPassword: nil, DefaultToken: "", DefaultCommand: "", Subscribe: nil, } } // LoadConfig loads the Client config from a yaml file func LoadConfig(filename string) (*Config, error) { log.Debug("Loading client config from %s", filename) b, err := os.ReadFile(filename) if err != nil { return nil, err } c := NewConfig() if err := yaml.Unmarshal(b, c); err != nil { return nil, err } return c, nil } ================================================ FILE: client/config_darwin.go ================================================ //go:build darwin package client import ( "os" "os/user" "path/filepath" ) func init() { u, err := user.Current() if err == nil && u.Uid == "0" { DefaultConfigFile = "/etc/ntfy/client.yml" } else if configDir, err := os.UserConfigDir(); err == nil { DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml") } } ================================================ FILE: client/config_test.go ================================================ package client_test import ( "github.com/stretchr/testify/require" "heckel.io/ntfy/v2/client" "os" "path/filepath" "testing" ) func TestConfig_Load(t *testing.T) { filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(` default-host: http://localhost default-user: philipp default-password: mypass default-command: 'echo "Got the message: $message"' subscribe: - topic: no-command-with-auth user: phil password: mypass - topic: echo-this command: 'echo "Message received: $message"' - topic: alerts command: notify-send -i /usr/share/ntfy/logo.png "Important" "$m" if: priority: high,urgent - topic: defaults `), 0600)) conf, err := client.LoadConfig(filename) require.Nil(t, err) require.Equal(t, "http://localhost", conf.DefaultHost) require.Equal(t, "philipp", conf.DefaultUser) require.Equal(t, "mypass", *conf.DefaultPassword) require.Equal(t, `echo "Got the message: $message"`, conf.DefaultCommand) require.Equal(t, 4, len(conf.Subscribe)) require.Equal(t, "no-command-with-auth", conf.Subscribe[0].Topic) require.Equal(t, "", conf.Subscribe[0].Command) require.Equal(t, "phil", *conf.Subscribe[0].User) require.Equal(t, "mypass", *conf.Subscribe[0].Password) require.Equal(t, "echo-this", conf.Subscribe[1].Topic) require.Equal(t, `echo "Message received: $message"`, conf.Subscribe[1].Command) require.Equal(t, "alerts", conf.Subscribe[2].Topic) require.Equal(t, `notify-send -i /usr/share/ntfy/logo.png "Important" "$m"`, conf.Subscribe[2].Command) require.Equal(t, "high,urgent", conf.Subscribe[2].If["priority"]) require.Equal(t, "defaults", conf.Subscribe[3].Topic) } func TestConfig_EmptyPassword(t *testing.T) { filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(` default-host: http://localhost default-user: philipp default-password: "" subscribe: - topic: no-command-with-auth user: phil password: "" `), 0600)) conf, err := client.LoadConfig(filename) require.Nil(t, err) require.Equal(t, "http://localhost", conf.DefaultHost) require.Equal(t, "philipp", conf.DefaultUser) require.Equal(t, "", *conf.DefaultPassword) require.Equal(t, 1, len(conf.Subscribe)) require.Equal(t, "no-command-with-auth", conf.Subscribe[0].Topic) require.Equal(t, "", conf.Subscribe[0].Command) require.Equal(t, "phil", *conf.Subscribe[0].User) require.Equal(t, "", *conf.Subscribe[0].Password) } func TestConfig_NullPassword(t *testing.T) { filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(` default-host: http://localhost default-user: philipp default-password: ~ subscribe: - topic: no-command-with-auth user: phil password: ~ `), 0600)) conf, err := client.LoadConfig(filename) require.Nil(t, err) require.Equal(t, "http://localhost", conf.DefaultHost) require.Equal(t, "philipp", conf.DefaultUser) require.Nil(t, conf.DefaultPassword) require.Equal(t, 1, len(conf.Subscribe)) require.Equal(t, "no-command-with-auth", conf.Subscribe[0].Topic) require.Equal(t, "", conf.Subscribe[0].Command) require.Equal(t, "phil", *conf.Subscribe[0].User) require.Nil(t, conf.Subscribe[0].Password) } func TestConfig_NoPassword(t *testing.T) { filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(` default-host: http://localhost default-user: philipp subscribe: - topic: no-command-with-auth user: phil `), 0600)) conf, err := client.LoadConfig(filename) require.Nil(t, err) require.Equal(t, "http://localhost", conf.DefaultHost) require.Equal(t, "philipp", conf.DefaultUser) require.Nil(t, conf.DefaultPassword) require.Equal(t, 1, len(conf.Subscribe)) require.Equal(t, "no-command-with-auth", conf.Subscribe[0].Topic) require.Equal(t, "", conf.Subscribe[0].Command) require.Equal(t, "phil", *conf.Subscribe[0].User) require.Nil(t, conf.Subscribe[0].Password) } func TestConfig_DefaultToken(t *testing.T) { filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(` default-host: http://localhost default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 subscribe: - topic: mytopic `), 0600)) conf, err := client.LoadConfig(filename) require.Nil(t, err) require.Equal(t, "http://localhost", conf.DefaultHost) require.Equal(t, "", conf.DefaultUser) require.Nil(t, conf.DefaultPassword) require.Equal(t, "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", conf.DefaultToken) require.Equal(t, 1, len(conf.Subscribe)) require.Equal(t, "mytopic", conf.Subscribe[0].Topic) require.Nil(t, conf.Subscribe[0].User) require.Nil(t, conf.Subscribe[0].Password) require.Nil(t, conf.Subscribe[0].Token) } ================================================ FILE: client/config_unix.go ================================================ //go:build linux || dragonfly || freebsd || netbsd || openbsd package client import ( "os" "os/user" "path/filepath" ) func init() { u, err := user.Current() if err == nil && u.Uid == "0" { DefaultConfigFile = "/etc/ntfy/client.yml" } else if configDir, err := os.UserConfigDir(); err == nil { DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml") } } ================================================ FILE: client/config_windows.go ================================================ //go:build windows package client import ( "os" "path/filepath" ) func init() { if configDir, err := os.UserConfigDir(); err == nil { DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml") } } ================================================ FILE: client/ntfy-client.service ================================================ [Unit] Description=ntfy client After=network.target [Service] User=ntfy Group=ntfy ExecStart=/usr/bin/ntfy subscribe --config /etc/ntfy/client.yml --from-config Restart=on-failure [Install] WantedBy=multi-user.target ================================================ FILE: client/options.go ================================================ package client import ( "fmt" "heckel.io/ntfy/v2/util" "net/http" "strings" "time" ) // RequestOption is a generic request option that can be added to Client calls type RequestOption = func(r *http.Request) error // PublishOption is an option that can be passed to the Client.Publish call type PublishOption = RequestOption // SubscribeOption is an option that can be passed to a Client.Subscribe or Client.Poll call type SubscribeOption = RequestOption // WithMessage sets the notification message. This is an alternative way to passing the message body. func WithMessage(message string) PublishOption { return WithHeader("X-Message", message) } // WithTitle adds a title to a message func WithTitle(title string) PublishOption { return WithHeader("X-Title", title) } // WithPriority adds a priority to a message. The priority can be either a number (1=min, 5=max), // or the corresponding names (see util.ParsePriority). func WithPriority(priority string) PublishOption { return WithHeader("X-Priority", priority) } // WithTagsList adds a list of tags to a message. The tags parameter must be a comma-separated list // of tags. To use a slice, use WithTags instead func WithTagsList(tags string) PublishOption { return WithHeader("X-Tags", tags) } // WithTags adds a list of a tags to a message func WithTags(tags []string) PublishOption { return WithTagsList(strings.Join(tags, ",")) } // WithDelay instructs the server to send the message at a later date. The delay parameter can be a // Unix timestamp, a duration string or a natural langage string. See https://ntfy.sh/docs/publish/#scheduled-delivery // for details. func WithDelay(delay string) PublishOption { return WithHeader("X-Delay", delay) } // WithClick makes the notification action open the given URL as opposed to entering the detail view func WithClick(url string) PublishOption { return WithHeader("X-Click", url) } // WithIcon makes the notification use the given URL as its icon func WithIcon(icon string) PublishOption { return WithHeader("X-Icon", icon) } // WithActions adds custom user actions to the notification. The value can be either a JSON array or the // simple format definition. See https://ntfy.sh/docs/publish/#action-buttons for details. func WithActions(value string) PublishOption { return WithHeader("X-Actions", value) } // WithAttach sets a URL that will be used by the client to download an attachment func WithAttach(attach string) PublishOption { return WithHeader("X-Attach", attach) } // WithMarkdown instructs the server to interpret the message body as Markdown 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) } // WithBasicAuth adds the Authorization header for basic auth to the request func WithBasicAuth(user, pass string) PublishOption { return WithHeader("Authorization", util.BasicAuth(user, pass)) } // WithBearerAuth adds the Authorization header for Bearer auth to the request func WithBearerAuth(token string) PublishOption { return WithHeader("Authorization", fmt.Sprintf("Bearer %s", token)) } // WithEmptyAuth clears the Authorization header func WithEmptyAuth() PublishOption { return RemoveHeader("Authorization") } // WithNoCache instructs the server not to cache the message server-side func WithNoCache() PublishOption { return WithHeader("X-Cache", "no") } // WithNoFirebase instructs the server not to forward the message to Firebase func WithNoFirebase() PublishOption { return WithHeader("X-Firebase", "no") } // WithSince limits the number of messages returned from the server. The parameter since can be a Unix // timestamp (see WithSinceUnixTime), a duration (WithSinceDuration) the word "all" (see WithSinceAll). func WithSince(since string) SubscribeOption { return WithQueryParam("since", since) } // WithSinceAll instructs the server to return all messages for the given topic from the server func WithSinceAll() SubscribeOption { return WithSince("all") } // WithSinceDuration instructs the server to return all messages since the given duration ago func WithSinceDuration(since time.Duration) SubscribeOption { return WithSinceUnixTime(time.Now().Add(-1 * since).Unix()) } // WithSinceUnixTime instructs the server to return only messages newer or equal to the given timestamp func WithSinceUnixTime(since int64) SubscribeOption { return WithSince(fmt.Sprintf("%d", since)) } // WithPoll instructs the server to close the connection after messages have been returned. Don't use this option // directly. Use Client.Poll instead. func WithPoll() SubscribeOption { return WithQueryParam("poll", "1") } // WithScheduled instructs the server to also return messages that have not been sent yet, i.e. delayed/scheduled // messages (see WithDelay). The messages will have a future date. func WithScheduled() SubscribeOption { return WithQueryParam("scheduled", "1") } // WithFilter is a generic subscribe option meant to be used to filter for certain messages only func WithFilter(param, value string) SubscribeOption { return WithQueryParam(param, value) } // WithMessageFilter instructs the server to only return messages that match the exact message func WithMessageFilter(message string) SubscribeOption { return WithQueryParam("message", message) } // WithTitleFilter instructs the server to only return messages with a title that match the exact string func WithTitleFilter(title string) SubscribeOption { return WithQueryParam("title", title) } // WithPriorityFilter instructs the server to only return messages with the matching priority. Not that messages // without priority also implicitly match priority 3. func WithPriorityFilter(priority int) SubscribeOption { return WithQueryParam("priority", fmt.Sprintf("%d", priority)) } // WithTagsFilter instructs the server to only return messages that contain all of the given tags func WithTagsFilter(tags []string) SubscribeOption { return WithQueryParam("tags", strings.Join(tags, ",")) } // WithHeader is a generic option to add headers to a request func WithHeader(header, value string) RequestOption { return func(r *http.Request) error { if value != "" { r.Header.Set(header, value) } return nil } } // WithQueryParam is a generic option to add query parameters to a request func WithQueryParam(param, value string) RequestOption { return func(r *http.Request) error { if value != "" { q := r.URL.Query() q.Add(param, value) r.URL.RawQuery = q.Encode() } return nil } } // RemoveHeader is a generic option to remove a header from a request func RemoveHeader(header string) RequestOption { return func(r *http.Request) error { if header != "" { delete(r.Header, header) } return nil } } ================================================ FILE: client/user/ntfy-client.service ================================================ [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 ================================================ FILE: cmd/access.go ================================================ //go:build !noserver package cmd import ( "errors" "fmt" "github.com/urfave/cli/v2" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) func init() { commands = append(commands, cmdAccess) } const ( userEveryone = "everyone" ) var flagsAccess = append( append([]cli.Flag{}, flagsUser...), &cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"}, ) var cmdAccess = &cli.Command{ Name: "access", Usage: "Grant/revoke access to a topic, or show access", UsageText: "ntfy access [USERNAME [TOPIC [PERMISSION]]]", Flags: flagsAccess, Before: initConfigFileInputSourceFunc("config", flagsAccess, initLogFunc), Action: execUserAccess, Category: categoryServer, Description: `Manage the access control list for the ntfy server. This is a server-only command. It directly manages the user.db as defined in the server config file server.yml. The command only works if 'auth-file' is properly defined. Please also refer to the related command 'ntfy user'. The command allows you to show the access control list, as well as change it, depending on how it is called. Usage: ntfy access # Shows access control list (alias: 'ntfy user list') ntfy access USERNAME # Shows access control entries for USERNAME ntfy access USERNAME TOPIC PERMISSION # Allow/deny access for USERNAME to TOPIC Arguments: USERNAME an existing user, as created with 'ntfy user add', or "everyone"/"*" to define access rules for anonymous/unauthenticated clients TOPIC name of a topic with optional wildcards, e.g. "mytopic*" PERMISSION one of the following: - read-write (alias: rw) - read-only (aliases: read, ro) - write-only (aliases: write, wo) - deny (alias: none) Examples: ntfy access # Shows access control list (alias: 'ntfy user list') ntfy access phil # Shows access for user phil ntfy access phil mytopic rw # Allow read-write access to mytopic for user phil ntfy access everyone mytopic rw # Allow anonymous read-write access to mytopic ntfy access everyone "up*" write # Allow anonymous write-only access to topics "up..." ntfy access --reset # Reset entire access control list ntfy access --reset phil # Reset all access for user phil ntfy access --reset phil mytopic # Reset access for user phil and topic mytopic `, } func execUserAccess(c *cli.Context) error { if c.NArg() > 3 { return errors.New("too many arguments, please check 'ntfy access --help' for usage details") } manager, err := createUserManager(c) if err != nil { return err } username := c.Args().Get(0) if username == userEveryone { username = user.Everyone } topic := c.Args().Get(1) perms := c.Args().Get(2) reset := c.Bool("reset") if reset { if perms != "" { return errors.New("too many arguments, please check 'ntfy access --help' for usage details") } return resetAccess(c, manager, username, topic) } else if perms == "" { if topic != "" { return errors.New("invalid syntax, please check 'ntfy access --help' for usage details") } return showAccess(c, manager, username) } return changeAccess(c, manager, username, topic, perms) } func changeAccess(c *cli.Context, manager *user.Manager, username string, topic string, perms string) error { if !util.Contains([]string{"", "read-write", "rw", "read-only", "read", "ro", "write-only", "write", "wo", "none", "deny"}, perms) { return errors.New("permission must be one of: read-write, read-only, write-only, or deny (or the aliases: read, ro, write, wo, none)") } permission, err := user.ParsePermission(perms) if err != nil { return err } u, err := manager.User(username) 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) } if err := manager.AllowAccess(username, topic, permission); err != nil { return err } if permission.IsReadWrite() { fmt.Fprintf(c.App.Writer, "granted read-write access to topic %s\n\n", topic) } else if permission.IsRead() { fmt.Fprintf(c.App.Writer, "granted read-only access to topic %s\n\n", topic) } else if permission.IsWrite() { fmt.Fprintf(c.App.Writer, "granted write-only access to topic %s\n\n", topic) } else { fmt.Fprintf(c.App.Writer, "revoked all access to topic %s\n\n", topic) } return showUserAccess(c, manager, username) } func resetAccess(c *cli.Context, manager *user.Manager, username, topic string) error { if username == "" { return resetAllAccess(c, manager) } else if topic == "" { return resetUserAccess(c, manager, username) } return resetUserTopicAccess(c, manager, username, topic) } func resetAllAccess(c *cli.Context, manager *user.Manager) error { if err := manager.ResetAccess("", ""); err != nil { return err } fmt.Fprintln(c.App.Writer, "reset access for all users") return nil } func resetUserAccess(c *cli.Context, manager *user.Manager, username string) error { if err := manager.ResetAccess(username, ""); err != nil { return err } fmt.Fprintf(c.App.Writer, "reset access for user %s\n\n", username) return showUserAccess(c, manager, username) } func resetUserTopicAccess(c *cli.Context, manager *user.Manager, username string, topic string) error { if err := manager.ResetAccess(username, topic); err != nil { return err } fmt.Fprintf(c.App.Writer, "reset access for user %s and topic %s\n\n", username, topic) return showUserAccess(c, manager, username) } func showAccess(c *cli.Context, manager *user.Manager, username string) error { if username == "" { return showAllAccess(c, manager) } return showUserAccess(c, manager, username) } func showAllAccess(c *cli.Context, manager *user.Manager) error { users, err := manager.Users() if err != nil { return err } return showUsers(c, manager, users) } func showUserAccess(c *cli.Context, manager *user.Manager, username string) error { users, err := manager.User(username) if errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } else if err != nil { return err } return showUsers(c, manager, []*user.User{users}) } func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error { for _, u := range users { grants, err := manager.Grants(u.Name) if err != nil { return err } tier := "none" if u.Tier != nil { tier = u.Tier.Name } 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.Writer, "- read-write access to all topics (admin role)\n") } else if len(grants) > 0 { for _, grant := range grants { 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.Writer, "- no access to topic %s%s\n", grant.TopicPattern, grantProvisioned) } } } else { 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.Writer, "- read-write access to all (other) topics (server config)") } else if access.IsRead() { fmt.Fprintln(c.App.Writer, "- read-only access to all (other) topics (server config)") } else if access.IsWrite() { fmt.Fprintln(c.App.Writer, "- write-only access to all (other) topics (server config)") } else { fmt.Fprintln(c.App.Writer, "- no access to any (other) topics (server config)") } } } return nil } ================================================ FILE: cmd/access_test.go ================================================ package cmd import ( "fmt" "github.com/stretchr/testify/require" "github.com/urfave/cli/v2" "heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/test" "testing" ) func TestCLI_Access_Show(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) app, _, stdout, _ := newTestApp() require.Nil(t, runAccessCommand(app, conf)) 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) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) app, stdin, _, _ := newTestApp() stdin.WriteString("philpass\nphilpass\nbenpass\nbenpass") require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil")) require.Nil(t, runUserCommand(app, conf, "add", "ben")) require.Nil(t, runAccessCommand(app, conf, "ben", "announcements", "rw")) require.Nil(t, runAccessCommand(app, conf, "ben", "sometopic", "read")) require.Nil(t, runAccessCommand(app, conf, "everyone", "announcements", "read")) app, _, stdout, _ := newTestApp() require.Nil(t, runAccessCommand(app, conf)) expected := `user phil (role: admin, tier: none) - read-write access to all topics (admin role) user ben (role: user, tier: none) - read-write access to topic announcements - read-only access to topic sometopic user * (role: anonymous, tier: none) - read-only access to topic announcements - no access to any (other) topics (server config) ` require.Equal(t, expected, stdout.String()) // See if access permissions match app, _, _, _ = newTestApp() require.Error(t, app.Run([]string{ "ntfy", "publish", fmt.Sprintf("http://127.0.0.1:%d/announcements", port), })) require.Nil(t, app.Run([]string{ "ntfy", "publish", "-u", "ben:benpass", fmt.Sprintf("http://127.0.0.1:%d/announcements", port), })) require.Nil(t, app.Run([]string{ "ntfy", "publish", "-u", "phil:philpass", fmt.Sprintf("http://127.0.0.1:%d/announcements", port), })) require.Nil(t, app.Run([]string{ "ntfy", "subscribe", "--poll", fmt.Sprintf("http://127.0.0.1:%d/announcements", port), })) require.Error(t, app.Run([]string{ "ntfy", "subscribe", "--poll", fmt.Sprintf("http://127.0.0.1:%d/something-else", port), })) } func runAccessCommand(app *cli.App, conf *server.Config, args ...string) error { userArgs := []string{ "ntfy", "--log-level=ERROR", "access", "--config=" + conf.File, // Dummy config file to avoid lookups of real file "--auth-file=" + conf.AuthFile, "--auth-default-access=" + conf.AuthDefault.String(), } return app.Run(append(userArgs, args...)) } ================================================ FILE: cmd/app.go ================================================ // Package cmd provides the ntfy CLI application package cmd import ( "fmt" "os" "regexp" "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" "heckel.io/ntfy/v2/log" ) const ( categoryClient = "Client commands" categoryServer = "Server commands" ) // Build metadata keys for app.Metadata const ( MetadataKeyCommit = "commit" MetadataKeyDate = "date" ) var commands = make([]*cli.Command, 0) var flagsDefault = []cli.Flag{ &cli.BoolFlag{Name: "debug", Aliases: []string{"d"}, EnvVars: []string{"NTFY_DEBUG"}, Usage: "enable debug logging"}, &cli.BoolFlag{Name: "trace", EnvVars: []string{"NTFY_TRACE"}, Usage: "enable tracing (very verbose, be careful)"}, &cli.BoolFlag{Name: "no-log-dates", Aliases: []string{"no_log_dates"}, EnvVars: []string{"NTFY_NO_LOG_DATES"}, Usage: "disable the date/time prefix"}, altsrc.NewStringFlag(&cli.StringFlag{Name: "log-level", Aliases: []string{"log_level"}, Value: log.InfoLevel.String(), EnvVars: []string{"NTFY_LOG_LEVEL"}, Usage: "set log level"}), altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "log-level-overrides", Aliases: []string{"log_level_overrides"}, EnvVars: []string{"NTFY_LOG_LEVEL_OVERRIDES"}, Usage: "set log level overrides"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "log-format", Aliases: []string{"log_format"}, Value: log.TextFormat.String(), EnvVars: []string{"NTFY_LOG_FORMAT"}, Usage: "set log format"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "log-file", Aliases: []string{"log_file"}, EnvVars: []string{"NTFY_LOG_FILE"}, Usage: "set log file, default is STDOUT"}), } var ( logLevelOverrideRegex = regexp.MustCompile(`(?i)^([^=\s]+)(?:\s*=\s*(\S+))?\s*->\s*(TRACE|DEBUG|INFO|WARN|ERROR)$`) ) // New creates a new CLI application func New() *cli.App { return &cli.App{ Name: "ntfy", Usage: "Simple pub-sub notification service", UsageText: "ntfy [OPTION..]", HideVersion: true, UseShortOptionHandling: true, Reader: os.Stdin, Writer: os.Stdout, ErrWriter: os.Stderr, Commands: commands, Flags: flagsDefault, Before: initLogFunc, } } func initLogFunc(c *cli.Context) error { log.SetLevel(log.ToLevel(c.String("log-level"))) log.SetFormat(log.ToFormat(c.String("log-format"))) if c.Bool("trace") { log.SetLevel(log.TraceLevel) } else if c.Bool("debug") { log.SetLevel(log.DebugLevel) } if c.Bool("no-log-dates") { log.DisableDates() } if err := applyLogLevelOverrides(c.StringSlice("log-level-overrides")); err != nil { return err } logFile := c.String("log-file") if logFile != "" { w, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { return err } log.SetOutput(w) } return nil } func applyLogLevelOverrides(rawOverrides []string) error { for _, override := range rawOverrides { m := logLevelOverrideRegex.FindStringSubmatch(override) if len(m) == 4 { field, value, level := m[1], m[2], m[3] log.SetLevelOverride(field, value, log.ToLevel(level)) } else if len(m) == 3 { field, level := m[1], m[2] log.SetLevelOverride(field, "", log.ToLevel(level)) // Matches any value } else { return fmt.Errorf(`invalid log level override "%s", must be "field=value -> loglevel", e.g. "user_id=u_123 -> DEBUG"`, override) } } return nil } ================================================ FILE: cmd/app_test.go ================================================ package cmd import ( "bytes" "encoding/json" "github.com/urfave/cli/v2" "heckel.io/ntfy/v2/client" "heckel.io/ntfy/v2/log" "os" "strings" "testing" ) // This only contains helpers so far func TestMain(m *testing.M) { log.SetLevel(log.ErrorLevel) os.Exit(m.Run()) } func newTestApp() (*cli.App, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) { var stdin, stdout, stderr bytes.Buffer app := New() app.Reader = &stdin app.Writer = &stdout app.ErrWriter = &stderr return app, &stdin, &stdout, &stderr } func toMessage(t *testing.T, s string) *client.Message { var m *client.Message if err := json.NewDecoder(strings.NewReader(s)).Decode(&m); err != nil { t.Fatal(err) } return m } ================================================ FILE: cmd/config_loader.go ================================================ package cmd import ( "fmt" "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" "gopkg.in/yaml.v2" "heckel.io/ntfy/v2/util" "os" ) // initConfigFileInputSourceFunc is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks // if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails. func initConfigFileInputSourceFunc(configFlag string, flags []cli.Flag, next cli.BeforeFunc) cli.BeforeFunc { return func(context *cli.Context) error { configFile := context.String(configFlag) if context.IsSet(configFlag) && !util.FileExists(configFile) { return fmt.Errorf("config file %s does not exist", configFile) } else if !context.IsSet(configFlag) && !util.FileExists(configFile) { return nil } inputSource, err := newYamlSourceFromFile(configFile, flags) if err != nil { return err } if err := altsrc.ApplyInputSourceValues(context, inputSource, flags); err != nil { return err } if next != nil { if err := next(context); err != nil { return err } } return nil } } // newYamlSourceFromFile creates a new Yaml InputSourceContext from a filepath. // // This function also maps aliases, so a .yml file can contain short options, or options with underscores // instead of dashes. See https://github.com/binwiederhier/ntfy/issues/255. func newYamlSourceFromFile(file string, flags []cli.Flag) (altsrc.InputSourceContext, error) { var rawConfig map[any]any b, err := os.ReadFile(file) if err != nil { return nil, err } if err := yaml.Unmarshal(b, &rawConfig); err != nil { return nil, err } for _, f := range flags { flagName := f.Names()[0] for _, flagAlias := range f.Names()[1:] { if _, ok := rawConfig[flagAlias]; ok { rawConfig[flagName] = rawConfig[flagAlias] } } } return altsrc.NewMapInputSource(file, rawConfig), nil } ================================================ FILE: cmd/config_loader_test.go ================================================ package cmd import ( "github.com/stretchr/testify/require" "os" "path/filepath" "testing" ) func TestNewYamlSourceFromFile(t *testing.T) { filename := filepath.Join(t.TempDir(), "server.yml") contents := ` # Normal options listen-https: ":10443" # Note the underscore! listen_http: ":1080" # OMG this is allowed now ... K: /some/file.pem ` require.Nil(t, os.WriteFile(filename, []byte(contents), 0600)) ctx, err := newYamlSourceFromFile(filename, flagsServe) require.Nil(t, err) listenHTTPS, err := ctx.String("listen-https") require.Nil(t, err) require.Equal(t, ":10443", listenHTTPS) listenHTTP, err := ctx.String("listen-http") // No underscore! require.Nil(t, err) require.Equal(t, ":1080", listenHTTP) keyFile, err := ctx.String("key-file") // Long option! require.Nil(t, err) require.Equal(t, "/some/file.pem", keyFile) } ================================================ FILE: cmd/publish.go ================================================ package cmd import ( "errors" "fmt" "github.com/urfave/cli/v2" "heckel.io/ntfy/v2/client" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/util" "io" "os" "os/exec" "path/filepath" "strings" "time" ) func init() { commands = append(commands, cmdPublish) } var flagsPublish = append( append([]cli.Flag{}, flagsDefault...), &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"}, &cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"}, &cli.StringFlag{Name: "message", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MESSAGE"}, Usage: "message body"}, &cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"}, &cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"}, &cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"}, &cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"}, &cli.StringFlag{Name: "icon", Aliases: []string{"i"}, EnvVars: []string{"NTFY_ICON"}, Usage: "URL to use as notification icon"}, &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"}, &cli.StringFlag{Name: "token", Aliases: []string{"k"}, EnvVars: []string{"NTFY_TOKEN"}, Usage: "access token used to auth against the server"}, &cli.IntFlag{Name: "wait-pid", Aliases: []string{"wait_pid", "pid"}, EnvVars: []string{"NTFY_WAIT_PID"}, Usage: "wait until PID exits before publishing"}, &cli.BoolFlag{Name: "wait-cmd", Aliases: []string{"wait_cmd", "cmd", "done"}, EnvVars: []string{"NTFY_WAIT_CMD"}, Usage: "run command and wait until it finishes before publishing"}, &cli.BoolFlag{Name: "no-cache", Aliases: []string{"no_cache", "C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"}, &cli.BoolFlag{Name: "no-firebase", Aliases: []string{"no_firebase", "F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"}, &cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do not print message"}, ) var cmdPublish = &cli.Command{ Name: "publish", Aliases: []string{"pub", "send", "trigger"}, Usage: "Send message via a ntfy server", UsageText: `ntfy publish [OPTIONS..] TOPIC [MESSAGE...] ntfy publish [OPTIONS..] --wait-cmd COMMAND... NTFY_TOPIC=.. ntfy publish [OPTIONS..] [MESSAGE...]`, Action: execPublish, Category: categoryClient, Flags: flagsPublish, Before: initLogFunc, Description: `Publish a message to a ntfy server. Examples: ntfy publish mytopic This is my message # Send simple message ntfy send myserver.com/mytopic "This is my message" # Send message to different default host ntfy pub -p high backups "Backups failed" # Send high priority message ntfy pub --tags=warning,skull backups "Backups failed" # Add tags/emojis to message ntfy pub --delay=10s delayed_topic Laterzz # Delay message by 10s ntfy pub --at=8:30am delayed_topic Laterzz # Send message at 8:30am ntfy pub -e phil@example.com alerts 'App is down!' # Also send email to phil@example.com ntfy pub --click="https://reddit.com" redd 'New msg' # Opens Reddit when notification is clicked 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 NTFY_USER=phil:mypass ntfy pub secret Psst # Use env variables to set username/password NTFY_TOPIC=mytopic ntfy pub "some message" # Use NTFY_TOPIC variable as topic cat flower.jpg | ntfy pub --file=- flowers 'Nice!' # Same as above, send image.jpg as attachment ntfy trigger mywebhook # Sending without message, useful for webhooks Please also check out the docs on publishing messages. Especially for the --tags and --delay options, it has incredibly useful information: https://ntfy.sh/docs/publish/. ` + clientCommandDescriptionSuffix, } func execPublish(c *cli.Context) error { conf, err := loadConfig(c) if err != nil { return err } title := c.String("title") priority := c.String("priority") tags := c.String("tags") delay := c.String("delay") click := c.String("click") icon := c.String("icon") 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") token := c.String("token") noCache := c.Bool("no-cache") noFirebase := c.Bool("no-firebase") quiet := c.Bool("quiet") pid := c.Int("wait-pid") // Checks if user != "" && token != "" { return errors.New("cannot set both --user and --token") } // Do the things topic, message, command, err := parseTopicMessageCommand(c) if err != nil { return err } var options []client.PublishOption if title != "" { options = append(options, client.WithTitle(title)) } if priority != "" { options = append(options, client.WithPriority(priority)) } if tags != "" { options = append(options, client.WithTagsList(tags)) } if delay != "" { options = append(options, client.WithDelay(delay)) } if click != "" { options = append(options, client.WithClick(click)) } if icon != "" { options = append(options, client.WithIcon(icon)) } if actions != "" { options = append(options, client.WithActions(strings.ReplaceAll(actions, "\n", " "))) } if attach != "" { options = append(options, client.WithAttach(attach)) } 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)) } if noCache { options = append(options, client.WithNoCache()) } if noFirebase { options = append(options, client.WithNoFirebase()) } if token != "" { options = append(options, client.WithBearerAuth(token)) } else if user != "" { var pass string parts := strings.SplitN(user, ":", 2) if len(parts) == 2 { user = parts[0] pass = parts[1] } else { fmt.Fprint(c.App.ErrWriter, "Enter Password: ") p, err := util.ReadPassword(c.App.Reader) if err != nil { return err } pass = string(p) fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20)) } options = append(options, client.WithBasicAuth(user, pass)) } else if conf.DefaultToken != "" { options = append(options, client.WithBearerAuth(conf.DefaultToken)) } else if conf.DefaultUser != "" && conf.DefaultPassword != nil { options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword)) } if pid > 0 { newMessage, err := waitForProcess(pid) if err != nil { return err } else if message == "" { message = newMessage } } else if len(command) > 0 { newMessage, err := runAndWaitForCommand(command) if err != nil { return err } else if message == "" { message = newMessage } } var body io.Reader if file == "" { body = strings.NewReader(message) } else { if message != "" { options = append(options, client.WithMessage(message)) } if file == "-" { if filename == "" { options = append(options, client.WithFilename("stdin")) } body = c.App.Reader } else { if filename == "" { options = append(options, client.WithFilename(filepath.Base(file))) } body, err = os.Open(file) if err != nil { return err } } } cl := client.New(conf) m, err := cl.PublishReader(topic, body, options...) if err != nil { return err } if !quiet { fmt.Fprintln(c.App.Writer, strings.TrimSpace(m.Raw)) } return nil } // parseTopicMessageCommand reads the topic and the remaining arguments from the context. // There are a few cases to consider: // // ntfy publish [] // ntfy publish --wait-cmd // NTFY_TOPIC=.. ntfy publish [] // NTFY_TOPIC=.. ntfy publish --wait-cmd func parseTopicMessageCommand(c *cli.Context) (topic string, message string, command []string, err error) { var args []string topic, args, err = parseTopicAndArgs(c) if err != nil { return } if c.Bool("wait-cmd") { if len(args) == 0 { err = errors.New("must specify command when --wait-cmd is passed, type 'ntfy publish --help' for help") return } command = args } else { message = strings.Join(args, " ") } 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 } func parseTopicAndArgs(c *cli.Context) (topic string, args []string, err error) { envTopic := os.Getenv("NTFY_TOPIC") if envTopic != "" { topic = envTopic return topic, remainingArgs(c, 0), nil } if c.NArg() < 1 { return "", nil, errors.New("must specify topic, type 'ntfy publish --help' for help") } return c.Args().Get(0), remainingArgs(c, 1), nil } func remainingArgs(c *cli.Context, fromIndex int) []string { if c.NArg() > fromIndex { return c.Args().Slice()[fromIndex:] } return []string{} } func waitForProcess(pid int) (message string, err error) { if !processExists(pid) { return "", fmt.Errorf("process with PID %d not running", pid) } start := time.Now() log.Debug("Waiting for process with PID %d to exit", pid) for processExists(pid) { time.Sleep(500 * time.Millisecond) } runtime := time.Since(start).Round(time.Millisecond) log.Debug("Process with PID %d exited after %s", pid, runtime) return fmt.Sprintf("Process with PID %d exited after %s", pid, runtime), nil } func runAndWaitForCommand(command []string) (message string, err error) { prettyCmd := util.QuoteCommand(command) log.Debug("Running command: %s", prettyCmd) start := time.Now() cmd := exec.Command(command[0], command[1:]...) if log.IsTrace() { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr } err = cmd.Run() runtime := time.Since(start).Round(time.Millisecond) if err != nil { if exitError, ok := err.(*exec.ExitError); ok { log.Debug("Command failed after %s (exit code %d): %s", runtime, exitError.ExitCode(), prettyCmd) return fmt.Sprintf("Command failed after %s (exit code %d): %s", runtime, exitError.ExitCode(), prettyCmd), nil } // Hard fail when command does not exist or could not be properly launched return "", fmt.Errorf("command failed: %s, error: %s", prettyCmd, 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 } ================================================ FILE: cmd/publish_test.go ================================================ package cmd import ( "fmt" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strconv" "strings" "testing" "time" "github.com/stretchr/testify/require" "heckel.io/ntfy/v2/test" "heckel.io/ntfy/v2/util" ) func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) { t.Skip("temporarily disabled") // FIXME testMessage := util.RandomString(10) app, _, _, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage})) _, err := util.Retry(func() (*int, error) { app2, _, stdout, _ := newTestApp() if err := app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"}); err != nil { return nil, err } if !strings.Contains(stdout.String(), testMessage) { return nil, fmt.Errorf("test message %s not found in topic", testMessage) } return util.Int(1), nil }, time.Second, 2*time.Second, 5*time.Second) // Since #502, ntfy.sh writes messages to the cache asynchronously, after a timeout of ~1.5s require.Nil(t, err) } func TestCLI_Publish_Subscribe_Poll(t *testing.T) { s, port := test.StartServer(t) defer test.StopServer(t, s, port) topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "publish", topic, "some message"})) m := toMessage(t, stdout.String()) require.Equal(t, "some message", m.Message) app2, _, stdout, _ := newTestApp() require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", topic})) m = toMessage(t, stdout.String()) require.Equal(t, "some message", m.Message) } func TestCLI_Publish_All_The_Things(t *testing.T) { s, port := test.StartServer(t) defer test.StopServer(t, s, port) topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{ "ntfy", "publish", "--title", "this is a title", "--priority", "high", "--tags", "tag1,tag2", // No --delay, --email "--click", "https://ntfy.sh", "--icon", "https://ntfy.sh/static/img/ntfy.png", "--attach", "https://f-droid.org/F-Droid.apk", "--filename", "fdroid.apk", "--no-cache", "--no-firebase", topic, "some message", })) m := toMessage(t, stdout.String()) require.Equal(t, "message", m.Event) require.Equal(t, "mytopic", m.Topic) require.Equal(t, "some message", m.Message) require.Equal(t, "this is a title", m.Title) require.Equal(t, 4, m.Priority) require.Equal(t, []string{"tag1", "tag2"}, m.Tags) require.Equal(t, "https://ntfy.sh", m.Click) require.Equal(t, "https://f-droid.org/F-Droid.apk", m.Attachment.URL) require.Equal(t, "fdroid.apk", m.Attachment.Name) require.Equal(t, int64(0), m.Attachment.Size) require.Equal(t, "", m.Attachment.Owner) require.Equal(t, int64(0), m.Attachment.Expires) require.Equal(t, "", m.Attachment.Type) require.Equal(t, "https://ntfy.sh/static/img/ntfy.png", m.Icon) } func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) { s, port := test.StartServer(t) defer test.StopServer(t, s, port) topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port) // Test: sleep 0.5 sleep := exec.Command("sleep", "0.5") require.Nil(t, sleep.Start()) go sleep.Wait() // Must be called to release resources start := time.Now() app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-pid", strconv.Itoa(sleep.Process.Pid), topic})) m := toMessage(t, stdout.String()) require.True(t, time.Since(start) >= 500*time.Millisecond) require.Regexp(t, `Process with PID \d+ exited after `, m.Message) // Test: PID does not exist app, _, _, _ = newTestApp() err := app.Run([]string{"ntfy", "publish", "--wait-pid", "1234567", topic}) require.Error(t, err) require.Equal(t, "process with PID 1234567 not running", err.Error()) // Test: Successful command (exit 0) start = time.Now() app, _, stdout, _ = newTestApp() require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-cmd", topic, "sleep", "0.5"})) m = toMessage(t, stdout.String()) require.True(t, time.Since(start) >= 500*time.Millisecond) require.Contains(t, m.Message, `Command succeeded after `) require.Contains(t, m.Message, `: sleep 0.5`) // Test: Failing command (exit 1) app, _, stdout, _ = newTestApp() require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-cmd", topic, "/bin/false", "false doesn't care about its args"})) m = toMessage(t, stdout.String()) require.Contains(t, m.Message, `Command failed after `) require.Contains(t, m.Message, `(exit code 1): /bin/false "false doesn't care about its args"`, m.Message) // Test: Non-existing command (hard fail!) app, _, _, _ = newTestApp() err = app.Run([]string{"ntfy", "publish", "--wait-cmd", topic, "does-not-exist-no-really", "really though"}) require.Error(t, err) require.Equal(t, `command failed: does-not-exist-no-really "really though", error: exec: "does-not-exist-no-really": executable file not found in $PATH`, err.Error()) // Tests with NTFY_TOPIC set //// t.Setenv("NTFY_TOPIC", topic) // Test: Successful command with NTFY_TOPIC app, _, stdout, _ = newTestApp() require.Nil(t, app.Run([]string{"ntfy", "publish", "--cmd", "echo", "hi there"})) m = toMessage(t, stdout.String()) require.Equal(t, "mytopic", m.Topic) // Test: Successful --wait-pid with NTFY_TOPIC sleep = exec.Command("sleep", "0.2") require.Nil(t, sleep.Start()) go sleep.Wait() // Must be called to release resources app, _, stdout, _ = newTestApp() require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-pid", strconv.Itoa(sleep.Process.Pid)})) m = toMessage(t, stdout.String()) require.Regexp(t, `Process with PID \d+ exited after .+ms`, m.Message) } func TestCLI_Publish_Default_UserPass(t *testing.T) { message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/mytopic", r.URL.Path) require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) w.Write([]byte(message)) })) defer server.Close() filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` default-host: %s default-user: philipp default-password: mypass `, server.URL)), 0600)) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "mytopic", "triggered"})) m := toMessage(t, stdout.String()) require.Equal(t, "triggered", m.Message) } func TestCLI_Publish_Default_Token(t *testing.T) { message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/mytopic", r.URL.Path) require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) w.Write([]byte(message)) })) defer server.Close() filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` default-host: %s default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 `, server.URL)), 0600)) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "mytopic", "triggered"})) m := toMessage(t, stdout.String()) require.Equal(t, "triggered", m.Message) } func TestCLI_Publish_Default_UserPass_CLI_Token(t *testing.T) { message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/mytopic", r.URL.Path) require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) w.Write([]byte(message)) })) defer server.Close() filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` default-host: %s default-user: philipp default-password: mypass `, server.URL)), 0600)) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic", "triggered"})) m := toMessage(t, stdout.String()) require.Equal(t, "triggered", m.Message) } func TestCLI_Publish_Default_Token_CLI_UserPass(t *testing.T) { message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/mytopic", r.URL.Path) require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) w.Write([]byte(message)) })) defer server.Close() filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` default-host: %s default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 `, server.URL)), 0600)) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--user", "philipp:mypass", "mytopic", "triggered"})) m := toMessage(t, stdout.String()) require.Equal(t, "triggered", m.Message) } func TestCLI_Publish_Default_Token_CLI_Token(t *testing.T) { message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/mytopic", r.URL.Path) require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) w.Write([]byte(message)) })) defer server.Close() filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` default-host: %s default-token: tk_FAKETOKEN01234567890FAKETOKEN `, server.URL)), 0600)) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic", "triggered"})) m := toMessage(t, stdout.String()) require.Equal(t, "triggered", m.Message) } func TestCLI_Publish_Default_UserPass_CLI_UserPass(t *testing.T) { message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/mytopic", r.URL.Path) require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) w.Write([]byte(message)) })) defer server.Close() filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` default-host: %s default-user: philipp default-password: fakepass `, server.URL)), 0600)) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--user", "philipp:mypass", "mytopic", "triggered"})) m := toMessage(t, stdout.String()) require.Equal(t, "triggered", m.Message) } func TestCLI_Publish_Token_And_UserPass(t *testing.T) { app, _, _, _ := newTestApp() err := app.Run([]string{"ntfy", "publish", "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "--user", "philipp:mypass", "mytopic", "triggered"}) require.Error(t, err) require.Equal(t, "cannot set both --user and --token", err.Error()) } ================================================ FILE: cmd/publish_unix.go ================================================ //go:build darwin || linux || dragonfly || freebsd || netbsd || openbsd package cmd import "syscall" func processExists(pid int) bool { err := syscall.Kill(pid, syscall.Signal(0)) return err == nil } ================================================ FILE: cmd/publish_windows.go ================================================ package cmd import ( "os" ) func processExists(pid int) bool { _, err := os.FindProcess(pid) return err == nil } ================================================ FILE: cmd/serve.go ================================================ //go:build !noserver package cmd import ( "errors" "fmt" "io/fs" "math" "net" "net/netip" "net/url" "runtime" "strings" "text/template" "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) } var flagsServe = append( append([]cli.Flag{}, flagsDefault...), &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"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"listen_unix", "U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "listen-unix-mode", Aliases: []string{"listen_unix_mode"}, EnvVars: []string{"NTFY_LISTEN_UNIX_MODE"}, DefaultText: "system default", Usage: "file permissions of unix socket, e.g. 0700"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"key_file", "K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"cert_file", "E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores (e.g. postgres://user:pass@host:5432/ntfy)"}), altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "database-replica-urls", Aliases: []string{"database_replica_urls"}, EnvVars: []string{"NTFY_DATABASE_REPLICA_URLS"}, Usage: "PostgreSQL read replica connection strings for offloading read queries"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: util.FormatDuration(server.DefaultCacheDuration), Usage: "buffer messages for this time to allow `since` requests"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "cache-batch-size", Aliases: []string{"cache_batch_size"}, EnvVars: []string{"NTFY_BATCH_SIZE"}, Usage: "max size of messages to batch together when writing to message cache (if zero, writes are synchronous)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-batch-timeout", Aliases: []string{"cache_batch_timeout"}, EnvVars: []string{"NTFY_CACHE_BATCH_TIMEOUT"}, Value: util.FormatDuration(server.DefaultCacheBatchTimeout), Usage: "timeout for batched async writes to the message cache (if zero, writes are synchronous)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-startup-queries", Aliases: []string{"cache_startup_queries"}, EnvVars: []string{"NTFY_CACHE_STARTUP_QUERIES"}, Usage: "queries run when the cache database is initialized"}), 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"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "/", Usage: "sets root of the web app (e.g. /, or /app), or disables it (disable)"}), 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"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", Aliases: []string{"smtp_sender_from"}, EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-account", Aliases: []string{"twilio_account"}, EnvVars: []string{"NTFY_TWILIO_ACCOUNT"}, Usage: "Twilio account SID, used for phone calls, e.g. AC123..."}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-call-format", Aliases: []string{"twilio_call_format"}, EnvVars: []string{"NTFY_TWILIO_CALL_FORMAT"}, Usage: "Twilio/TwiML format string for phone calls"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "message-size-limit", Aliases: []string{"message_size_limit"}, EnvVars: []string{"NTFY_MESSAGE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultMessageSizeLimit), Usage: "size limit for the message (see docs for limitations)"}), 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"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorRequestLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}), 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.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)"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-metrics", Aliases: []string{"enable_metrics"}, EnvVars: []string{"NTFY_ENABLE_METRICS"}, Value: false, Usage: "if set, Prometheus metrics are exposed via the /metrics endpoint"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "metrics-listen-http", Aliases: []string{"metrics_listen_http"}, EnvVars: []string{"NTFY_METRICS_LISTEN_HTTP"}, Usage: "ip:port used to expose the metrics endpoint (implicitly enables metrics)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "profile-listen-http", Aliases: []string{"profile_listen_http"}, EnvVars: []string{"NTFY_PROFILE_LISTEN_HTTP"}, Usage: "ip:port used to expose the profiling endpoints (implicitly enables profiling)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-public-key", Aliases: []string{"web_push_public_key"}, EnvVars: []string{"NTFY_WEB_PUSH_PUBLIC_KEY"}, Usage: "public key used for web push notifications"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-private-key", Aliases: []string{"web_push_private_key"}, EnvVars: []string{"NTFY_WEB_PUSH_PRIVATE_KEY"}, Usage: "private key used for web push notifications"}), 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{ Name: "serve", Usage: "Run the ntfy server", UsageText: "ntfy serve [OPTIONS..]", Action: execServe, Category: categoryServer, Flags: flagsServe, Before: initConfigFileInputSourceFunc("config", flagsServe, initLogFunc), Description: `Run the ntfy server and listen for incoming requests The command will load the configuration from /etc/ntfy/server.yml. Config options can be overridden using the command line options. Examples: ntfy serve # Starts server in the foreground (on port 80) ntfy serve --listen-http :8080 # Starts server with alternate port`, } func execServe(c *cli.Context) error { if c.NArg() > 0 { return errors.New("no arguments expected, see 'ntfy serve --help' for help") } // Read all the options config := c.String("config") baseURL := strings.TrimSuffix(c.String("base-url"), "/") listenHTTP := c.String("listen-http") listenHTTPS := c.String("listen-https") listenUnix := c.String("listen-unix") listenUnixMode := c.Int("listen-unix-mode") keyFile := c.String("key-file") certFile := c.String("cert-file") firebaseKeyFile := c.String("firebase-key-file") databaseURL := c.String("database-url") databaseReplicaURLs := c.StringSlice("database-replica-urls") webPushPrivateKey := c.String("web-push-private-key") webPushPublicKey := c.String("web-push-public-key") 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") cacheBatchSize := c.Int("cache-batch-size") cacheBatchTimeoutStr := c.String("cache-batch-timeout") 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") smtpSenderAddr := c.String("smtp-sender-addr") smtpSenderUser := c.String("smtp-sender-user") smtpSenderPass := c.String("smtp-sender-pass") smtpSenderFrom := c.String("smtp-sender-from") smtpServerListen := c.String("smtp-server-listen") smtpServerDomain := c.String("smtp-server-domain") smtpServerAddrPrefix := c.String("smtp-server-addr-prefix") twilioAccount := c.String("twilio-account") twilioAuthToken := c.String("twilio-auth-token") twilioPhoneNumber := c.String("twilio-phone-number") twilioVerifyService := c.String("twilio-verify-service") twilioCallFormat := c.String("twilio-call-format") messageSizeLimitStr := c.String("message-size-limit") messageDelayLimitStr := c.String("message-delay-limit") totalTopicLimit := c.Int("global-topic-limit") visitorSubscriptionLimit := c.Int("visitor-subscription-limit") visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting") visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit") visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit") visitorRequestLimitBurst := c.Int("visitor-request-limit-burst") visitorRequestLimitReplenishStr := c.String("visitor-request-limit-replenish") visitorRequestLimitExemptHosts := util.SplitNoEmpty(c.String("visitor-request-limit-exempt-hosts"), ",") 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") metricsListenHTTP := c.String("metrics-listen-http") enableMetrics := c.Bool("enable-metrics") || metricsListenHTTP != "" profileListenHTTP := c.String("profile-listen-http") // Convert durations cacheDuration, err := util.ParseDuration(cacheDurationStr) if err != nil { return fmt.Errorf("invalid cache duration: %s", cacheDurationStr) } cacheBatchTimeout, err := util.ParseDuration(cacheBatchTimeoutStr) if err != nil { return fmt.Errorf("invalid cache batch timeout: %s", cacheBatchTimeoutStr) } attachmentExpiryDuration, err := util.ParseDuration(attachmentExpiryDurationStr) if err != nil { return fmt.Errorf("invalid attachment expiry duration: %s", attachmentExpiryDurationStr) } keepaliveInterval, err := util.ParseDuration(keepaliveIntervalStr) if err != nil { return fmt.Errorf("invalid keepalive interval: %s", keepaliveIntervalStr) } managerInterval, err := util.ParseDuration(managerIntervalStr) if err != nil { return fmt.Errorf("invalid manager interval: %s", managerIntervalStr) } messageDelayLimit, err := util.ParseDuration(messageDelayLimitStr) if err != nil { return fmt.Errorf("invalid message delay limit: %s", messageDelayLimitStr) } visitorRequestLimitReplenish, err := util.ParseDuration(visitorRequestLimitReplenishStr) if err != nil { return fmt.Errorf("invalid visitor request limit replenish: %s", visitorRequestLimitReplenishStr) } visitorEmailLimitReplenish, err := util.ParseDuration(visitorEmailLimitReplenishStr) 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) if err != nil { return fmt.Errorf("invalid message size limit: %s", messageSizeLimitStr) } attachmentTotalSizeLimit, err := util.ParseSize(attachmentTotalSizeLimitStr) if err != nil { return fmt.Errorf("invalid attachment total size limit: %s", attachmentTotalSizeLimitStr) } attachmentFileSizeLimit, err := util.ParseSize(attachmentFileSizeLimitStr) if err != nil { return fmt.Errorf("invalid attachment file size limit: %s", attachmentFileSizeLimitStr) } visitorAttachmentTotalSizeLimit, err := util.ParseSize(visitorAttachmentTotalSizeLimitStr) if err != nil { return fmt.Errorf("invalid visitor attachment total size limit: %s", visitorAttachmentTotalSizeLimitStr) } visitorAttachmentDailyBandwidthLimit, err := util.ParseSize(visitorAttachmentDailyBandwidthLimitStr) if err != nil { return fmt.Errorf("invalid visitor attachment daily bandwidth limit: %s", visitorAttachmentDailyBandwidthLimitStr) } else if visitorAttachmentDailyBandwidthLimit > math.MaxInt { return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt) } // Check values if databaseURL != "" && !strings.HasPrefix(databaseURL, "postgres://") && !strings.HasPrefix(databaseURL, "postgresql://") { return errors.New("if database-url is set, it must start with postgres:// or postgresql://") } else if databaseURL != "" && (authFile != "" || cacheFile != "" || webPushFile != "") { return errors.New("if database-url is set, auth-file, cache-file, and web-push-file must not be set") } else if len(databaseReplicaURLs) > 0 && databaseURL == "" { return errors.New("database-replica-urls can only be used if database-url is also set") } else 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 == "" && databaseURL == "") || webPushEmailAddress == "" || baseURL == "") { return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file (or database-url), web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys") } else if keepaliveInterval < 5*time.Second { return errors.New("keepalive interval cannot be lower than five seconds") } else if managerInterval < 5*time.Second { return errors.New("manager interval cannot be lower than five seconds") } else if cacheDuration > 0 && cacheDuration < managerInterval { return errors.New("cache duration cannot be lower than manager interval") } else if keyFile != "" && !util.FileExists(keyFile) { return errors.New("if set, key file must exist") } else if certFile != "" && !util.FileExists(certFile) { return errors.New("if set, certificate file must exist") } else if listenHTTPS != "" && (keyFile == "" || certFile == "") { return errors.New("if listen-https is set, both key-file and cert-file must be set") } else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderFrom == "") { return errors.New("if smtp-sender-addr is set, base-url, and smtp-sender-from must also be set") } else if smtpServerListen != "" && smtpServerDomain == "" { return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set") } else if attachmentCacheDir != "" && baseURL == "" { return errors.New("if attachment-cache-dir is set, base-url must also be set") } else if baseURL != "" { u, err := url.Parse(baseURL) if err != nil { return fmt.Errorf("if set, base-url must be a valid URL, e.g. https://ntfy.mydomain.com: %v", err) } else if u.Scheme != "http" && u.Scheme != "https" { return errors.New("if set, base-url must be a valid URL starting with http:// or https://, e.g. https://ntfy.mydomain.com") } else if u.Path != "" { return fmt.Errorf("if set, base-url must not have a path (%s), as hosting ntfy on a sub-path is not supported, e.g. https://ntfy.mydomain.com", u.Path) } } else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") { return errors.New("if set, upstream-base-url must start with http:// or https://") } else if upstreamBaseURL != "" && strings.HasSuffix(upstreamBaseURL, "/") { return errors.New("if set, upstream-base-url must not end with a slash (/)") } else if upstreamBaseURL != "" && baseURL == "" { 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 == "" && databaseURL == "" && (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 or database-url 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 == "" && databaseURL == "")) { return errors.New("if twilio-account is set, twilio-auth-token, twilio-phone-number, twilio-verify-service, base-url, and auth-file (or database-url) must also be set") } else if messageSizeLimit > server.DefaultMessageSizeLimit { log.Warn("message-size-limit is greater than 4K, this is not recommended and largely untested, and may lead to issues with some clients") 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") } else if runtime.GOOS == "windows" && listenUnix != "" { return errors.New("listen-unix is not supported on Windows") } // Backwards compatibility if webRoot == "app" { webRoot = "/" } else if webRoot == "home" { webRoot = "/app" } else if webRoot == "disable" { webRoot = "" } else if !strings.HasPrefix(webRoot, "/") { webRoot = "/" + webRoot } // 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 == "-" { listenHTTP = "" } // Resolve hosts visitorRequestLimitExemptPrefixes := make([]netip.Prefix, 0) for _, host := range visitorRequestLimitExemptHosts { prefixes, err := parseIPHostPrefix(host) if err != nil { log.Warn("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error()) continue } 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 != "" { payments.Setup(stripeSecretKey) } // Parse Twilio template var twilioCallFormatTemplate *template.Template if twilioCallFormat != "" { twilioCallFormatTemplate, err = template.New("").Parse(twilioCallFormat) if err != nil { return fmt.Errorf("failed to parse twilio-call-format template: %w", err) } } // Add default forbidden topics disallowedTopics = append(disallowedTopics, server.DefaultDisallowedTopics...) // Run server conf := server.NewConfig() conf.File = config conf.BaseURL = baseURL conf.ListenHTTP = listenHTTP conf.ListenHTTPS = listenHTTPS conf.ListenUnix = listenUnix conf.ListenUnixMode = fs.FileMode(listenUnixMode) conf.KeyFile = keyFile conf.CertFile = certFile conf.FirebaseKeyFile = firebaseKeyFile conf.CacheFile = cacheFile conf.CacheDuration = cacheDuration conf.CacheStartupQueries = cacheStartupQueries conf.CacheBatchSize = cacheBatchSize conf.CacheBatchTimeout = cacheBatchTimeout 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 conf.WebRoot = webRoot conf.UpstreamBaseURL = upstreamBaseURL conf.UpstreamAccessToken = upstreamAccessToken conf.SMTPSenderAddr = smtpSenderAddr conf.SMTPSenderUser = smtpSenderUser conf.SMTPSenderPass = smtpSenderPass conf.SMTPSenderFrom = smtpSenderFrom conf.SMTPServerListen = smtpServerListen conf.SMTPServerDomain = smtpServerDomain conf.SMTPServerAddrPrefix = smtpServerAddrPrefix conf.TwilioAccount = twilioAccount conf.TwilioAuthToken = twilioAuthToken conf.TwilioPhoneNumber = twilioPhoneNumber conf.TwilioVerifyService = twilioVerifyService conf.TwilioCallFormat = twilioCallFormatTemplate conf.MessageSizeLimit = int(messageSizeLimit) 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.VisitorRequestExemptPrefixes = visitorRequestLimitExemptPrefixes conf.VisitorMessageDailyLimit = visitorMessageDailyLimit conf.VisitorEmailLimitBurst = visitorEmailLimitBurst conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish 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.DatabaseURL = databaseURL conf.DatabaseReplicaURLs = databaseReplicaURLs conf.WebPushPrivateKey = webPushPrivateKey conf.WebPushPublicKey = webPushPublicKey conf.WebPushFile = webPushFile conf.WebPushEmailAddress = webPushEmailAddress conf.WebPushStartupQueries = webPushStartupQueries conf.WebPushExpiryDuration = webPushExpiryDuration conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration conf.BuildVersion = c.App.Version conf.BuildDate = maybeFromMetadata(c.App.Metadata, MetadataKeyDate) conf.BuildCommit = maybeFromMetadata(c.App.Metadata, MetadataKeyCommit) // Check if we should run as a Windows service if ranAsService, err := maybeRunAsService(conf); err != nil { log.Fatal("%s", err.Error()) } else if ranAsService { log.Info("Exiting.") return nil } // Set up hot-reloading of config go sigHandlerConfigReload(config) // Run server s, err := server.New(conf) if err != nil { log.Fatal("%s", err.Error()) } else if err := s.Run(); err != nil { log.Fatal("%s", err.Error()) } log.Info("Exiting.") return nil } func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) { // 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()) return prefixes, nil } // Not a prefix, parse as host or IP (LookupHost passes through an IP as is) ips, err := net.LookupHost(host) if err != nil { return nil, err } for _, ipStr := range ips { ip, err := netip.ParseAddr(ipStr) if err == nil { prefix, err := ip.Prefix(ip.BitLen()) if err != nil { return nil, fmt.Errorf("%s successfully parsed but unable to make prefix: %s", ip.String(), err.Error()) } prefixes = append(prefixes, prefix.Masked()) } } 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 maybeFromMetadata(m map[string]any, key string) string { if m == nil { return "" } v, exists := m[key] if !exists { return "" } s, ok := v.(string) if !ok { return "" } return s } ================================================ FILE: cmd/serve_test.go ================================================ package cmd import ( "fmt" "math/rand" "os" "os/exec" "path/filepath" "testing" "time" "github.com/gorilla/websocket" "github.com/stretchr/testify/assert" "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 go func() { app, _, _, _ := newTestApp() err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--listen-http=-", "--listen-unix=" + sockFile}) require.Nil(t, err) }() for i := 0; i < 40 && !util.FileExists(sockFile); i++ { time.Sleep(50 * time.Millisecond) } require.True(t, util.FileExists(sockFile)) cmd := exec.Command("curl", "-s", "--unix-socket", sockFile, "-d", "this is a message", "localhost/mytopic") out, err := cmd.Output() require.Nil(t, err) m := toMessage(t, string(out)) require.Equal(t, "this is a message", m.Message) } func TestCLI_Serve_WebSocket(t *testing.T) { port := 10000 + rand.Intn(20000) go func() { configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system app, _, _, _ := newTestApp() err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, fmt.Sprintf("--listen-http=:%d", port)}) require.Nil(t, err) }() test.WaitForPortUp(t, port) ws, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://127.0.0.1:%d/mytopic/ws", port), nil) require.Nil(t, err) messageType, data, err := ws.ReadMessage() require.Nil(t, err) require.Equal(t, websocket.TextMessage, messageType) require.Equal(t, "open", toMessage(t, string(data)).Event) c := client.New(client.NewConfig()) _, err = c.Publish(fmt.Sprintf("http://127.0.0.1:%d/mytopic", port), "my message") require.Nil(t, err) messageType, data, err = ws.ReadMessage() require.Nil(t, err) require.Equal(t, websocket.TextMessage, messageType) m := toMessage(t, string(data)) require.Equal(t, "my message", m.Message) require.Equal(t, "mytopic", m.Topic) } func TestIP_Host_Parsing(t *testing.T) { cases := map[string]string{ "1.1.1.1": "1.1.1.1/32", "fd00::1234": "fd00::1234/128", "192.168.0.3/24": "192.168.0.0/24", "10.1.2.3/8": "10.0.0.0/8", "201:be93::4a6/21": "201:b800::/21", } for q, expectedAnswer := range cases { ips, err := parseIPHostPrefix(q) require.Nil(t, err) assert.Equal(t, 1, len(ips)) assert.Equal(t, expectedAnswer, ips[0].String()) } } func newEmptyFile(t *testing.T) string { filename := filepath.Join(t.TempDir(), "empty") require.Nil(t, os.WriteFile(filename, []byte{}, 0600)) return filename } ================================================ FILE: cmd/serve_unix.go ================================================ //go:build linux || dragonfly || freebsd || netbsd || openbsd package cmd import ( "os" "os/signal" "syscall" "github.com/urfave/cli/v2/altsrc" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/server" ) func sigHandlerConfigReload(config string) { sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGHUP) for range sigs { log.Info("Partially hot reloading configuration ...") inputSource, err := newYamlSourceFromFile(config, flagsServe) if err != nil { log.Warn("Hot reload failed: %s", err.Error()) continue } if err := reloadLogLevel(inputSource); err != nil { log.Warn("Reloading log level failed: %s", err.Error()) } } } func reloadLogLevel(inputSource altsrc.InputSourceContext) error { newLevelStr, err := inputSource.String("log-level") if err != nil { return err } overrides, err := inputSource.StringSlice("log-level-overrides") if err != nil { return err } log.ResetLevelOverrides() if err := applyLogLevelOverrides(overrides); err != nil { return err } log.SetLevel(log.ToLevel(newLevelStr)) if len(overrides) > 0 { log.Info("Log level is %v, %d override(s) in place", newLevelStr, len(overrides)) } else { log.Info("Log level is %v", newLevelStr) } return nil } func maybeRunAsService(conf *server.Config) (bool, error) { return false, nil } ================================================ FILE: cmd/serve_windows.go ================================================ //go:build windows && !noserver package cmd import ( "fmt" "sync" "golang.org/x/sys/windows/svc" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/server" ) const serviceName = "ntfy" // sigHandlerConfigReload is a no-op on Windows since SIGHUP is not available. // Windows users can restart the service to reload configuration. func sigHandlerConfigReload(config string) { log.Debug("Config hot-reload via SIGHUP is not supported on Windows") } // runAsWindowsService runs the ntfy server as a Windows service func runAsWindowsService(conf *server.Config) error { return svc.Run(serviceName, &windowsService{conf: conf}) } // windowsService implements the svc.Handler interface type windowsService struct { conf *server.Config server *server.Server mu sync.Mutex } // Execute is the main entry point for the Windows service func (s *windowsService) Execute(args []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) { const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown status <- svc.Status{State: svc.StartPending} // Create and start the server var err error s.mu.Lock() s.server, err = server.New(s.conf) s.mu.Unlock() if err != nil { log.Error("Failed to create server: %s", err.Error()) return true, 1 } // Start server in a goroutine serverErrChan := make(chan error, 1) go func() { serverErrChan <- s.server.Run() }() status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} log.Info("Windows service started") for { select { case err := <-serverErrChan: if err != nil { log.Error("Server error: %s", err.Error()) return true, 1 } return false, 0 case req := <-requests: switch req.Cmd { case svc.Interrogate: status <- req.CurrentStatus case svc.Stop, svc.Shutdown: log.Info("Windows service stopping...") status <- svc.Status{State: svc.StopPending} s.mu.Lock() if s.server != nil { s.server.Stop() } s.mu.Unlock() return false, 0 default: log.Warn("Unexpected service control request: %d", req.Cmd) } } } } // maybeRunAsService checks if the process is running as a Windows service, // and if so, runs the server as a service. Returns true if it ran as a service. func maybeRunAsService(conf *server.Config) (bool, error) { isService, err := svc.IsWindowsService() if err != nil { return false, fmt.Errorf("failed to detect Windows service mode: %w", err) } else if !isService { return false, nil } log.Info("Running as Windows service") if err := runAsWindowsService(conf); err != nil { return true, fmt.Errorf("failed to run as Windows service: %w", err) } return true, nil } ================================================ FILE: cmd/subscribe.go ================================================ package cmd import ( "errors" "fmt" "os" "os/exec" "sort" "strings" "github.com/urfave/cli/v2" "heckel.io/ntfy/v2/client" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/util" ) func init() { commands = append(commands, cmdSubscribe) } var flagsSubscribe = append( append([]cli.Flag{}, flagsDefault...), &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"}, &cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"}, &cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"}, &cli.StringFlag{Name: "token", Aliases: []string{"k"}, EnvVars: []string{"NTFY_TOKEN"}, Usage: "access token used to auth against the server"}, &cli.BoolFlag{Name: "from-config", Aliases: []string{"from_config", "C"}, Usage: "read subscriptions from config file (service mode)"}, &cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"}, &cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"}, ) var cmdSubscribe = &cli.Command{ Name: "subscribe", Aliases: []string{"sub"}, Usage: "Subscribe to one or more topics on a ntfy server", UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]", Action: execSubscribe, Category: categoryClient, Flags: flagsSubscribe, Before: initLogFunc, Description: `Subscribe to a topic from a ntfy server, and either print or execute a command for every arriving message. There are 3 modes in which the command can be run: ntfy subscribe TOPIC This prints the JSON representation of every incoming message. It is useful when you have a command that wants to stream-read incoming JSON messages. Unless --poll is passed, this command stays open forever. Examples: ntfy subscribe mytopic # Prints JSON for incoming messages for ntfy.sh/mytopic ntfy sub home.lan/backups # Subscribe to topic on different server ntfy sub --poll home.lan/backups # Just query for latest messages and exit ntfy sub -u phil:mypass secret # Subscribe with username/password ntfy subscribe TOPIC COMMAND This executes COMMAND for every incoming messages. The message fields are passed to the command as environment variables: Variable Aliases Description --------------- --------------------- ----------------------------------- $NTFY_ID $id Unique message ID $NTFY_TIME $time Unix timestamp of the message delivery $NTFY_TOPIC $topic Topic name $NTFY_MESSAGE $message, $m Message body $NTFY_TITLE $title, $t Message title $NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max) $NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list) $NTFY_RAW $raw Raw JSON message Examples: ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages ntfy sub topic1 myscript.sh # Execute script for incoming messages ntfy subscribe --from-config Service mode (used in ntfy-client.service). This reads the config file and sets up subscriptions for every topic in the "subscribe:" block (see config file). Examples: ntfy sub --from-config # Read topics from config file ntfy sub --config=myclient.yml --from-config # Read topics from alternate config file ` + clientCommandDescriptionSuffix, } func execSubscribe(c *cli.Context) error { // Read config and options conf, err := loadConfig(c) if err != nil { return err } cl := client.New(conf) since := c.String("since") user := c.String("user") token := c.String("token") poll := c.Bool("poll") scheduled := c.Bool("scheduled") fromConfig := c.Bool("from-config") topic := c.Args().Get(0) command := c.Args().Get(1) // Checks if user != "" && token != "" { return errors.New("cannot set both --user and --token") } if !fromConfig { conf.Subscribe = nil // wipe if --from-config not passed } var options []client.SubscribeOption if since != "" { options = append(options, client.WithSince(since)) } if token != "" { options = append(options, client.WithBearerAuth(token)) } else if user != "" { var pass string parts := strings.SplitN(user, ":", 2) if len(parts) == 2 { user = parts[0] pass = parts[1] } else { fmt.Fprint(c.App.ErrWriter, "Enter Password: ") p, err := util.ReadPassword(c.App.Reader) if err != nil { return err } pass = string(p) fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20)) } options = append(options, client.WithBasicAuth(user, pass)) } else if conf.DefaultToken != "" { options = append(options, client.WithBearerAuth(conf.DefaultToken)) } else if conf.DefaultUser != "" && conf.DefaultPassword != nil { options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword)) } if scheduled { options = append(options, client.WithScheduled()) } if topic == "" && len(conf.Subscribe) == 0 { return errors.New("must specify topic, type 'ntfy subscribe --help' for help") } // Execute poll or subscribe if poll { return doPoll(c, cl, conf, topic, command, options...) } return doSubscribe(c, cl, conf, topic, command, options...) } func doPoll(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error { for _, s := range conf.Subscribe { // may be nil if auth := maybeAddAuthHeader(s, conf); auth != nil { options = append(options, auth) } if err := doPollSingle(c, cl, s.Topic, s.Command, options...); err != nil { return err } } if topic != "" { if err := doPollSingle(c, cl, topic, command, options...); err != nil { return err } } return nil } func doPollSingle(c *cli.Context, cl *client.Client, topic, command string, options ...client.SubscribeOption) error { messages, err := cl.Poll(topic, options...) if err != nil { return err } for _, m := range messages { printMessageOrRunCommand(c, m, command) } return nil } func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error { cmds := make(map[string]string) // Subscription ID -> command for _, s := range conf.Subscribe { // May be nil topicOptions := append(make([]client.SubscribeOption, 0), options...) for filter, value := range s.If { topicOptions = append(topicOptions, client.WithFilter(filter, value)) } if auth := maybeAddAuthHeader(s, conf); auth != nil { topicOptions = append(topicOptions, auth) } subscriptionID, err := cl.Subscribe(s.Topic, topicOptions...) if err != nil { return err } if s.Command != "" { cmds[subscriptionID] = s.Command } else if conf.DefaultCommand != "" { cmds[subscriptionID] = conf.DefaultCommand } else { cmds[subscriptionID] = "" } } if topic != "" { subscriptionID, err := cl.Subscribe(topic, options...) if err != nil { return err } cmds[subscriptionID] = command } for m := range cl.Messages { cmd, ok := cmds[m.SubscriptionID] if !ok { continue } log.Debug("%s Dispatching received message: %s", logMessagePrefix(m), m.Raw) printMessageOrRunCommand(c, m, cmd) } return nil } func maybeAddAuthHeader(s client.Subscribe, conf *client.Config) client.SubscribeOption { // if an explicit empty token or empty user:pass is given, exit without auth if (s.Token != nil && *s.Token == "") || (s.User != nil && *s.User == "" && s.Password != nil && *s.Password == "") { return client.WithEmptyAuth() } // check for subscription token then subscription user:pass if s.Token != nil && *s.Token != "" { return client.WithBearerAuth(*s.Token) } if s.User != nil && *s.User != "" && s.Password != nil { return client.WithBasicAuth(*s.User, *s.Password) } // if no subscription token nor subscription user:pass, check for default token then default user:pass if conf.DefaultToken != "" { return client.WithBearerAuth(conf.DefaultToken) } if conf.DefaultUser != "" && conf.DefaultPassword != nil { return client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword) } return nil } func printMessageOrRunCommand(c *cli.Context, m *client.Message, command string) { if command != "" { runCommand(c, command, m) } else { log.Debug("%s Printing raw message", logMessagePrefix(m)) fmt.Fprintln(c.App.Writer, m.Raw) } } func runCommand(c *cli.Context, command string, m *client.Message) { if err := runCommandInternal(c, command, m); err != nil { log.Warn("%s Command failed: %s", logMessagePrefix(m), err.Error()) } } func runCommandInternal(c *cli.Context, script string, m *client.Message) error { scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.%s", os.TempDir(), util.RandomString(10), scriptExt) log.Debug("%s Running command '%s' via temporary script %s", logMessagePrefix(m), script, scriptFile) script = scriptHeader + script if err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil { return err } defer os.Remove(scriptFile) log.Debug("%s Executing script %s", logMessagePrefix(m), scriptFile) cmd := exec.Command(scriptLauncher[0], append(scriptLauncher[1:], scriptFile)...) cmd.Stdin = c.App.Reader cmd.Stdout = c.App.Writer cmd.Stderr = c.App.ErrWriter cmd.Env = envVars(m) return cmd.Run() } func envVars(m *client.Message) []string { env := make([]string, 0) env = append(env, envVar(m.ID, "NTFY_ID", "id")...) env = append(env, envVar(m.Topic, "NTFY_TOPIC", "topic")...) env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...) env = append(env, envVar(m.Message, "NTFY_MESSAGE", "message", "m")...) env = append(env, envVar(m.Title, "NTFY_TITLE", "title", "t")...) env = append(env, envVar(fmt.Sprintf("%d", m.Priority), "NTFY_PRIORITY", "priority", "prio", "p")...) env = append(env, envVar(strings.Join(m.Tags, ","), "NTFY_TAGS", "tags", "tag", "ta")...) env = append(env, envVar(m.Raw, "NTFY_RAW", "raw")...) sort.Strings(env) if log.IsTrace() { log.Trace("%s With environment:\n%s", logMessagePrefix(m), strings.Join(env, "\n")) } return append(os.Environ(), env...) } func envVar(value string, vars ...string) []string { env := make([]string, 0) for _, v := range vars { env = append(env, fmt.Sprintf("%s=%s", v, value)) } return env } func loadConfig(c *cli.Context) (*client.Config, error) { filename := c.String("config") if filename != "" { return client.LoadConfig(filename) } if client.DefaultConfigFile != "" { if s, _ := os.Stat(client.DefaultConfigFile); s != nil { return client.LoadConfig(client.DefaultConfigFile) } log.Debug("Config file %s not found", client.DefaultConfigFile) } log.Debug("Loading default config") return client.NewConfig(), nil } func logMessagePrefix(m *client.Message) string { return fmt.Sprintf("%s/%s", util.ShortTopicURL(m.TopicURL), m.ID) } ================================================ FILE: cmd/subscribe_darwin.go ================================================ //go:build darwin package cmd const ( scriptExt = "sh" scriptHeader = "#!/bin/sh\n" clientCommandDescriptionSuffix = `The default config file for all client commands is /etc/ntfy/client.yml (if root user), or "~/Library/Application Support/ntfy/client.yml" for all other users.` ) var ( scriptLauncher = []string{"sh", "-c"} ) ================================================ FILE: cmd/subscribe_test.go ================================================ package cmd import ( "fmt" "github.com/stretchr/testify/require" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) func TestCLI_Subscribe_Default_UserPass_Subscription_Token(t *testing.T) { message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/mytopic/json", r.URL.Path) require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) w.Write([]byte(message)) })) defer server.Close() filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` default-host: %s default-user: philipp default-password: mypass subscribe: - topic: mytopic token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 `, server.URL)), 0600)) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } func TestCLI_Subscribe_Default_Token_Subscription_UserPass(t *testing.T) { message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/mytopic/json", r.URL.Path) require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) w.Write([]byte(message)) })) defer server.Close() filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` default-host: %s default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 subscribe: - topic: mytopic user: philipp password: mypass `, server.URL)), 0600)) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } func TestCLI_Subscribe_Default_Token_Subscription_Token(t *testing.T) { message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/mytopic/json", r.URL.Path) require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) w.Write([]byte(message)) })) defer server.Close() filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` default-host: %s default-token: tk_FAKETOKEN01234567890FAKETOKEN subscribe: - topic: mytopic token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 `, server.URL)), 0600)) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } func TestCLI_Subscribe_Default_UserPass_Subscription_UserPass(t *testing.T) { message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/mytopic/json", r.URL.Path) require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) w.Write([]byte(message)) })) defer server.Close() filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` default-host: %s default-user: fake default-password: password subscribe: - topic: mytopic user: philipp password: mypass `, server.URL)), 0600)) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } func TestCLI_Subscribe_Default_Token_Subscription_Empty(t *testing.T) { message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/mytopic/json", r.URL.Path) require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) w.Write([]byte(message)) })) defer server.Close() filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` default-host: %s default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 subscribe: - topic: mytopic `, server.URL)), 0600)) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } func TestCLI_Subscribe_Default_UserPass_Subscription_Empty(t *testing.T) { message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/mytopic/json", r.URL.Path) require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) w.Write([]byte(message)) })) defer server.Close() filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` default-host: %s default-user: philipp default-password: mypass subscribe: - topic: mytopic `, server.URL)), 0600)) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } func TestCLI_Subscribe_Default_Empty_Subscription_Token(t *testing.T) { message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/mytopic/json", r.URL.Path) require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) w.Write([]byte(message)) })) defer server.Close() filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` default-host: %s subscribe: - topic: mytopic token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 `, server.URL)), 0600)) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } func TestCLI_Subscribe_Default_Empty_Subscription_UserPass(t *testing.T) { message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/mytopic/json", r.URL.Path) require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) w.Write([]byte(message)) })) defer server.Close() filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` default-host: %s subscribe: - topic: mytopic user: philipp password: mypass `, server.URL)), 0600)) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } func TestCLI_Subscribe_Default_Token_CLI_Token(t *testing.T) { message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/mytopic/json", r.URL.Path) require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) w.Write([]byte(message)) })) defer server.Close() filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` default-host: %s default-token: tk_FAKETOKEN0123456789FAKETOKEN `, server.URL)), 0600)) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic"})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } func TestCLI_Subscribe_Default_Token_CLI_UserPass(t *testing.T) { message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/mytopic/json", r.URL.Path) require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) w.Write([]byte(message)) })) defer server.Close() filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` default-host: %s default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 `, server.URL)), 0600)) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--user", "philipp:mypass", "mytopic"})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } func TestCLI_Subscribe_Default_Token_Subscription_Token_CLI_UserPass(t *testing.T) { message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/mytopic/json", r.URL.Path) require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) w.Write([]byte(message)) })) defer server.Close() filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` default-host: %s default-token: tk_FAKETOKEN01234567890FAKETOKEN subscribe: - topic: mytopic token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 `, server.URL)), 0600)) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--user", "philipp:mypass"})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } func TestCLI_Subscribe_Token_And_UserPass(t *testing.T) { app, _, _, _ := newTestApp() err := app.Run([]string{"ntfy", "subscribe", "--poll", "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "--user", "philipp:mypass", "mytopic", "triggered"}) require.Error(t, err) require.Equal(t, "cannot set both --user and --token", err.Error()) } func TestCLI_Subscribe_Default_Token(t *testing.T) { message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/mytopic/json", r.URL.Path) require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) w.Write([]byte(message)) })) defer server.Close() filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` default-host: %s default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 `, server.URL)), 0600)) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "mytopic"})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } func TestCLI_Subscribe_Default_UserPass(t *testing.T) { message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/mytopic/json", r.URL.Path) require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) w.Write([]byte(message)) })) defer server.Close() filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` default-host: %s default-user: philipp default-password: mypass `, server.URL)), 0600)) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "mytopic"})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } func TestCLI_Subscribe_Override_Default_UserPass_With_Empty_UserPass(t *testing.T) { message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/mytopic/json", r.URL.Path) require.Equal(t, "", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) w.Write([]byte(message)) })) defer server.Close() filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` default-host: %s default-user: philipp default-password: mypass subscribe: - topic: mytopic user: "" password: "" `, server.URL)), 0600)) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } func TestCLI_Subscribe_Override_Default_Token_With_Empty_Token(t *testing.T) { message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/mytopic/json", r.URL.Path) require.Equal(t, "", r.Header.Get("Authorization")) w.WriteHeader(http.StatusOK) w.Write([]byte(message)) })) defer server.Close() filename := filepath.Join(t.TempDir(), "client.yml") require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` default-host: %s default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 subscribe: - topic: mytopic token: "" `, server.URL)), 0600)) app, _, stdout, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) require.Equal(t, message, strings.TrimSpace(stdout.String())) } ================================================ FILE: cmd/subscribe_unix.go ================================================ //go:build linux || dragonfly || freebsd || netbsd || openbsd package cmd const ( scriptExt = "sh" scriptHeader = "#!/bin/sh\n" clientCommandDescriptionSuffix = `The default config file for all client commands is /etc/ntfy/client.yml (if root user), or ~/.config/ntfy/client.yml for all other users.` ) var ( scriptLauncher = []string{"sh", "-c"} ) ================================================ FILE: cmd/subscribe_windows.go ================================================ //go:build windows package cmd const ( scriptExt = "bat" scriptHeader = "" clientCommandDescriptionSuffix = `The default config file for all client commands is %AppData%\ntfy\client.yml.` ) var ( scriptLauncher = []string{"cmd.exe", "/Q", "/C"} ) ================================================ FILE: cmd/tier.go ================================================ //go:build !noserver package cmd import ( "errors" "fmt" "github.com/urfave/cli/v2" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) func init() { commands = append(commands, cmdTier) } const ( defaultMessageLimit = 5000 defaultMessageExpiryDuration = "12h" defaultEmailLimit = 20 defaultCallLimit = 0 defaultReservationLimit = 3 defaultAttachmentFileSizeLimit = "15M" defaultAttachmentTotalSizeLimit = "100M" defaultAttachmentExpiryDuration = "6h" defaultAttachmentBandwidthLimit = "1G" ) var ( flagsTier = append([]cli.Flag{}, flagsUser...) ) var cmdTier = &cli.Command{ Name: "tier", Usage: "Manage/show tiers", UsageText: "ntfy tier [list|add|change|remove] ...", Flags: flagsTier, Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc), Category: categoryServer, Subcommands: []*cli.Command{ { Name: "add", Aliases: []string{"a"}, Usage: "Adds a new tier", UsageText: "ntfy tier add [OPTIONS] CODE", Action: execTierAdd, Flags: []cli.Flag{ &cli.StringFlag{Name: "name", Usage: "tier name"}, &cli.Int64Flag{Name: "message-limit", Value: defaultMessageLimit, Usage: "daily message limit"}, &cli.StringFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"}, &cli.Int64Flag{Name: "email-limit", Value: defaultEmailLimit, Usage: "daily email limit"}, &cli.Int64Flag{Name: "call-limit", Value: defaultCallLimit, Usage: "daily phone call limit"}, &cli.Int64Flag{Name: "reservation-limit", Value: defaultReservationLimit, Usage: "topic reservation limit"}, &cli.StringFlag{Name: "attachment-file-size-limit", Value: defaultAttachmentFileSizeLimit, Usage: "per-attachment file size limit"}, &cli.StringFlag{Name: "attachment-total-size-limit", Value: defaultAttachmentTotalSizeLimit, Usage: "total size limit of attachments for the user"}, &cli.StringFlag{Name: "attachment-expiry-duration", Value: defaultAttachmentExpiryDuration, Usage: "duration after which attachments are deleted"}, &cli.StringFlag{Name: "attachment-bandwidth-limit", Value: defaultAttachmentBandwidthLimit, Usage: "daily bandwidth limit for attachment uploads/downloads"}, &cli.StringFlag{Name: "stripe-monthly-price-id", Usage: "Monthly Stripe price ID for paid tiers (e.g. price_12345)"}, &cli.StringFlag{Name: "stripe-yearly-price-id", Usage: "Yearly Stripe price ID for paid tiers (e.g. price_12345)"}, &cli.BoolFlag{Name: "ignore-exists", Usage: "if the tier already exists, perform no action and exit"}, }, Description: `Add a new tier to the ntfy user database. Tiers can be used to grant users higher limits, such as daily message limits, attachment size, or make it possible for users to reserve topics. 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. Examples: ntfy tier add pro # Add tier with code "pro", using the defaults ntfy tier add \ # Add a tier with custom limits --name="Pro" \ --message-limit=10000 \ --message-expiry-duration=24h \ --email-limit=50 \ --reservation-limit=10 \ --attachment-file-size-limit=100M \ --attachment-total-size-limit=1G \ --attachment-expiry-duration=12h \ --attachment-bandwidth-limit=5G \ pro `, }, { Name: "change", Aliases: []string{"ch"}, Usage: "Change a tier", UsageText: "ntfy tier change [OPTIONS] CODE", Action: execTierChange, Flags: []cli.Flag{ &cli.StringFlag{Name: "name", Usage: "tier name"}, &cli.Int64Flag{Name: "message-limit", Usage: "daily message limit"}, &cli.StringFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"}, &cli.Int64Flag{Name: "email-limit", Usage: "daily email limit"}, &cli.Int64Flag{Name: "call-limit", Usage: "daily phone call limit"}, &cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"}, &cli.StringFlag{Name: "attachment-file-size-limit", Usage: "per-attachment file size limit"}, &cli.StringFlag{Name: "attachment-total-size-limit", Usage: "total size limit of attachments for the user"}, &cli.StringFlag{Name: "attachment-expiry-duration", Usage: "duration after which attachments are deleted"}, &cli.StringFlag{Name: "attachment-bandwidth-limit", Usage: "daily bandwidth limit for attachment uploads/downloads"}, &cli.StringFlag{Name: "stripe-monthly-price-id", Usage: "Monthly Stripe price ID for paid tiers (e.g. price_12345)"}, &cli.StringFlag{Name: "stripe-yearly-price-id", Usage: "Yearly Stripe price ID for paid tiers (e.g. price_12345)"}, }, Description: `Updates a tier to change the limits. After updating a tier, you may have to restart the ntfy server to apply them to all visitors. 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. Examples: ntfy tier change --name="Pro" pro # Update the name of an existing tier ntfy tier change \ # Update multiple limits and fields --message-expiry-duration=24h \ --stripe-monthly-price-id=price_1234 \ --stripe-monthly-price-id=price_5678 \ pro `, }, { Name: "remove", Aliases: []string{"del", "rm"}, Usage: "Removes a tier", UsageText: "ntfy tier remove CODE", Action: execTierDel, Description: `Remove a tier from the ntfy user database. You cannot remove a tier if there are users associated with a tier. Use "ntfy user change-tier" to remove or switch their tier first. 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. Example: ntfy tier del pro `, }, { Name: "list", Aliases: []string{"l"}, Usage: "Shows a list of tiers", Action: execTierList, Description: `Shows a list of all configured tiers. 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. `, }, }, Description: `Manage tiers of the ntfy server. The command allows you to add/remove/change tiers in the ntfy user database. Tiers are used to grant users higher limits, such as daily message limits, attachment size, or make it possible for users to reserve topics. This is a server-only command. It directly manages the user.db as defined in the server config file server.yml. The command only works if 'auth-file' is properly defined. Examples: ntfy tier add pro # Add tier with code "pro", using the defaults ntfy tier change --name="Pro" pro # Update the name of an existing tier ntfy tier del pro # Delete an existing tier `, } func execTierAdd(c *cli.Context) error { code := c.Args().Get(0) if code == "" { return errors.New("tier code expected, type 'ntfy tier add --help' for help") } else if !user.AllowedTier(code) { return errors.New("tier code must consist only of numbers and letters") } else if c.String("stripe-monthly-price-id") != "" && c.String("stripe-yearly-price-id") == "" { return errors.New("if stripe-monthly-price-id is set, stripe-yearly-price-id must also be set") } else if c.String("stripe-monthly-price-id") == "" && c.String("stripe-yearly-price-id") != "" { return errors.New("if stripe-yearly-price-id is set, stripe-monthly-price-id must also be set") } manager, err := createUserManager(c) if err != nil { return err } if tier, _ := manager.Tier(code); tier != nil { if c.Bool("ignore-exists") { fmt.Fprintf(c.App.Writer, "tier %s already exists (exited successfully)\n", code) return nil } return fmt.Errorf("tier %s already exists", code) } name := c.String("name") if name == "" { name = code } messageExpiryDuration, err := util.ParseDuration(c.String("message-expiry-duration")) if err != nil { return err } attachmentFileSizeLimit, err := util.ParseSize(c.String("attachment-file-size-limit")) if err != nil { return err } attachmentTotalSizeLimit, err := util.ParseSize(c.String("attachment-total-size-limit")) if err != nil { return err } attachmentBandwidthLimit, err := util.ParseSize(c.String("attachment-bandwidth-limit")) if err != nil { return err } attachmentExpiryDuration, err := util.ParseDuration(c.String("attachment-expiry-duration")) if err != nil { return err } tier := &user.Tier{ ID: "", // Generated Code: code, Name: name, MessageLimit: c.Int64("message-limit"), MessageExpiryDuration: messageExpiryDuration, EmailLimit: c.Int64("email-limit"), CallLimit: c.Int64("call-limit"), ReservationLimit: c.Int64("reservation-limit"), AttachmentFileSizeLimit: attachmentFileSizeLimit, AttachmentTotalSizeLimit: attachmentTotalSizeLimit, AttachmentExpiryDuration: attachmentExpiryDuration, AttachmentBandwidthLimit: attachmentBandwidthLimit, StripeMonthlyPriceID: c.String("stripe-monthly-price-id"), StripeYearlyPriceID: c.String("stripe-yearly-price-id"), } if err := manager.AddTier(tier); err != nil { return err } tier, err = manager.Tier(code) if err != nil { return err } fmt.Fprintf(c.App.Writer, "tier added\n\n") printTier(c, tier) return nil } func execTierChange(c *cli.Context) error { code := c.Args().Get(0) if code == "" { return errors.New("tier code expected, type 'ntfy tier change --help' for help") } else if !user.AllowedTier(code) { return errors.New("tier code must consist only of numbers and letters") } manager, err := createUserManager(c) if err != nil { return err } tier, err := manager.Tier(code) if err == user.ErrTierNotFound { return fmt.Errorf("tier %s does not exist", code) } else if err != nil { return err } if c.IsSet("name") { tier.Name = c.String("name") } if c.IsSet("message-limit") { tier.MessageLimit = c.Int64("message-limit") } if c.IsSet("message-expiry-duration") { tier.MessageExpiryDuration, err = util.ParseDuration(c.String("message-expiry-duration")) if err != nil { return err } } if c.IsSet("email-limit") { tier.EmailLimit = c.Int64("email-limit") } if c.IsSet("call-limit") { tier.CallLimit = c.Int64("call-limit") } if c.IsSet("reservation-limit") { tier.ReservationLimit = c.Int64("reservation-limit") } if c.IsSet("attachment-file-size-limit") { tier.AttachmentFileSizeLimit, err = util.ParseSize(c.String("attachment-file-size-limit")) if err != nil { return err } } if c.IsSet("attachment-total-size-limit") { tier.AttachmentTotalSizeLimit, err = util.ParseSize(c.String("attachment-total-size-limit")) if err != nil { return err } } if c.IsSet("attachment-expiry-duration") { tier.AttachmentExpiryDuration, err = util.ParseDuration(c.String("attachment-expiry-duration")) if err != nil { return err } } if c.IsSet("attachment-bandwidth-limit") { tier.AttachmentBandwidthLimit, err = util.ParseSize(c.String("attachment-bandwidth-limit")) if err != nil { return err } } if c.IsSet("stripe-monthly-price-id") { tier.StripeMonthlyPriceID = c.String("stripe-monthly-price-id") } if c.IsSet("stripe-yearly-price-id") { tier.StripeYearlyPriceID = c.String("stripe-yearly-price-id") } if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID == "" { return errors.New("if stripe-monthly-price-id is set, stripe-yearly-price-id must also be set") } else if tier.StripeMonthlyPriceID == "" && tier.StripeYearlyPriceID != "" { return errors.New("if stripe-yearly-price-id is set, stripe-monthly-price-id must also be set") } if err := manager.UpdateTier(tier); err != nil { return err } fmt.Fprintf(c.App.Writer, "tier updated\n\n") printTier(c, tier) return nil } func execTierDel(c *cli.Context) error { code := c.Args().Get(0) if code == "" { return errors.New("tier code expected, type 'ntfy tier del --help' for help") } manager, err := createUserManager(c) if err != nil { return err } if _, err := manager.Tier(code); err == user.ErrTierNotFound { return fmt.Errorf("tier %s does not exist", code) } if err := manager.RemoveTier(code); err != nil { return err } fmt.Fprintf(c.App.Writer, "tier %s removed\n", code) return nil } func execTierList(c *cli.Context) error { manager, err := createUserManager(c) if err != nil { return err } tiers, err := manager.Tiers() if err != nil { return err } for _, tier := range tiers { printTier(c, tier) } return nil } func printTier(c *cli.Context, tier *user.Tier) { prices := "(none)" if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID != "" { prices = fmt.Sprintf("%s / %s", tier.StripeMonthlyPriceID, tier.StripeYearlyPriceID) } 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) } ================================================ FILE: cmd/tier_test.go ================================================ package cmd import ( "github.com/stretchr/testify/require" "github.com/urfave/cli/v2" "heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/test" "testing" ) func TestCLI_Tier_AddListChangeDelete(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) app, _, stdout, _ := newTestApp() require.Nil(t, runTierCommand(app, conf, "add", "--name", "Pro", "--message-limit", "1234", "pro")) 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, _, stdout, _ = newTestApp() require.Nil(t, runTierCommand(app, conf, "list")) 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, _, stdout, _ = newTestApp() require.Nil(t, runTierCommand(app, conf, "change", "--message-limit=999", "--message-expiry-duration=2d", "--email-limit=91", "--reservation-limit=98", "--attachment-file-size-limit=100m", "--attachment-expiry-duration=1d", "--attachment-total-size-limit=10G", "--attachment-bandwidth-limit=100G", "--stripe-monthly-price-id=price_991", "--stripe-yearly-price-id=price_992", "pro", )) 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, _, stdout, _ = newTestApp() require.Nil(t, runTierCommand(app, conf, "remove", "pro")) require.Contains(t, stdout.String(), "tier pro removed") } func runTierCommand(app *cli.App, conf *server.Config, args ...string) error { userArgs := []string{ "ntfy", "--log-level=ERROR", "tier", "--config=" + conf.File, // Dummy config file to avoid lookups of real file "--auth-file=" + conf.AuthFile, "--auth-default-access=" + conf.AuthDefault.String(), } return app.Run(append(userArgs, args...)) } ================================================ FILE: cmd/token.go ================================================ //go:build !noserver package cmd import ( "errors" "fmt" "github.com/urfave/cli/v2" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" "net/netip" "time" ) func init() { commands = append(commands, cmdToken) } var flagsToken = append([]cli.Flag{}, flagsUser...) var cmdToken = &cli.Command{ Name: "token", Usage: "Create, list or delete user tokens", UsageText: "ntfy token [list|add|remove] ...", Flags: flagsToken, Before: initConfigFileInputSourceFunc("config", flagsToken, initLogFunc), Category: categoryServer, Subcommands: []*cli.Command{ { Name: "add", Aliases: []string{"a"}, Usage: "Create a new token", UsageText: "ntfy token add [--expires=] [--label=..] USERNAME", Action: execTokenAdd, Flags: []cli.Flag{ &cli.StringFlag{Name: "expires", Aliases: []string{"e"}, Value: "", Usage: "token expires after"}, &cli.StringFlag{Name: "label", Aliases: []string{"l"}, Value: "", Usage: "token label"}, }, Description: `Create a new user access token. User access tokens can be used to publish, subscribe, or perform any other user-specific tasks. Tokens have full access, and can perform any task a user can do. They are meant to be used to avoid spreading the password to various places. 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. Examples: 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 add -e "tuesday, 8pm" phil # Create token for user phil which expires next Tuesday ntfy token add -l backups phil # Create token for user phil with label "backups"`, }, { Name: "remove", Aliases: []string{"del", "rm"}, Usage: "Removes a token", UsageText: "ntfy token remove USERNAME TOKEN", Action: execTokenDel, Description: `Remove a token from the ntfy user database. Example: ntfy token del phil tk_th2srHVlxrANQHAso5t0HuQ1J1TjN`, }, { Name: "list", Aliases: []string{"l"}, Usage: "Shows a list of tokens", Action: execTokenList, Description: `Shows a list of all tokens. 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. User access tokens can be used to publish, subscribe, or perform any other user-specific tasks. Tokens have full access, and can perform any task a user can do. They are meant to be used to avoid spreading the password to various places. This is a server-only command. It directly manages the user.db as defined in the server config file server.yml. The command only works if 'auth-file' is properly defined. Examples: ntfy token list # Shows list of tokens for all users 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_th2srHVlxr... # Delete token`, } func execTokenAdd(c *cli.Context) error { username := c.Args().Get(0) expiresStr := c.String("expires") label := c.String("label") if username == "" { return errors.New("username expected, type 'ntfy token add --help' for help") } else if username == userEveryone || username == user.Everyone { return errors.New("username not allowed") } expires := time.Unix(0, 0) if expiresStr != "" { var err error expires, err = util.ParseFutureTime(expiresStr, time.Now()) if err != nil { return err } } manager, err := createUserManager(c) if err != nil { return err } u, err := manager.User(username) 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(), false) if err != nil { return err } if expires.Unix() == 0 { fmt.Fprintf(c.App.Writer, "token %s created for user %s, never expires\n", token.Value, u.Name) } else { fmt.Fprintf(c.App.Writer, "token %s created for user %s, expires %v\n", token.Value, u.Name, expires.Format(time.UnixDate)) } return nil } func execTokenDel(c *cli.Context) error { username, token := c.Args().Get(0), c.Args().Get(1) if username == "" || token == "" { return errors.New("username and token expected, type 'ntfy token remove --help' for help") } else if username == userEveryone || username == user.Everyone { return errors.New("username not allowed") } manager, err := createUserManager(c) if err != nil { return err } u, err := manager.User(username) if errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } else if err != nil { return err } if err := manager.RemoveToken(u.ID, token); err != nil { return err } fmt.Fprintf(c.App.Writer, "token %s for user %s removed\n", token, username) return nil } func execTokenList(c *cli.Context) error { username := c.Args().Get(0) if username == userEveryone || username == user.Everyone { return errors.New("username not allowed") } manager, err := createUserManager(c) if err != nil { return err } var users []*user.User if username != "" { u, err := manager.User(username) if errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } else if err != nil { return err } users = append(users, u) } else { users, err = manager.Users() if err != nil { return err } } usersWithTokens := 0 for _, u := range users { tokens, err := manager.Tokens(u.ID) if err != nil { return err } else if len(tokens) == 0 && 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.Writer, "user %s\n", u.Name) for _, t := range tokens { var label, expires, provisioned string if t.Label != "" { label = fmt.Sprintf(" (%s)", t.Label) } if t.Expires.Unix() == 0 { expires = "never expires" } else { expires = fmt.Sprintf("expires %s", t.Expires.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.Writer, "no users with tokens\n") } return nil } func execTokenGenerate(c *cli.Context) error { fmt.Fprintln(c.App.Writer, user.GenerateToken()) return nil } ================================================ FILE: cmd/token_test.go ================================================ package cmd import ( "fmt" "github.com/stretchr/testify/require" "github.com/urfave/cli/v2" "heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/test" "regexp" "testing" ) func TestCLI_Token_AddListRemove(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) app, stdin, stdout, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "phil")) require.Contains(t, stdout.String(), "user phil added with role user") app, _, stdout, _ = newTestApp() require.Nil(t, runTokenCommand(app, conf, "add", "phil")) require.Regexp(t, `token tk_.+ created for user phil, never expires`, stdout.String()) 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 .+`, stdout.String()) re := regexp.MustCompile(`tk_\w+`) token := re.FindString(stdout.String()) app, _, stdout, _ = newTestApp() require.Nil(t, runTokenCommand(app, conf, "remove", "phil", token)) require.Regexp(t, fmt.Sprintf("token %s for user phil removed", token), stdout.String()) app, _, stdout, _ = newTestApp() require.Nil(t, runTokenCommand(app, conf, "list")) require.Equal(t, "no users with tokens\n", stdout.String()) } func runTokenCommand(app *cli.App, conf *server.Config, args ...string) error { userArgs := []string{ "ntfy", "--log-level=ERROR", "token", "--config=" + conf.File, // Dummy config file to avoid lookups of real file "--auth-file=" + conf.AuthFile, } return app.Run(append(userArgs, args...)) } ================================================ FILE: cmd/user.go ================================================ //go:build !noserver package cmd import ( "crypto/subtle" "errors" "fmt" "os" "strings" "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db/pg" "heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) const ( tierReset = "-" ) func init() { commands = append(commands, cmdUser) } var flagsUser = append( append([]cli.Flag{}, flagsDefault...), &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"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores"}), ) var cmdUser = &cli.Command{ Name: "user", Usage: "Manage/show users", UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...", Flags: flagsUser, Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc), Category: categoryServer, Subcommands: []*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\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"}, &cli.BoolFlag{Name: "ignore-exists", Usage: "if the user already exists, perform no action and exit"}, }, Description: `Add a new user to the ntfy user database. A user can be either a regular user, or an admin. A regular user has no read or write access (unless granted otherwise by the auth-default-access setting). An admin user has read and write access to all topics. Examples: ntfy user add phil # Add regular user phil ntfy user add --role=admin phil # Add admin user phil 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, or NTFY_PASSWORD_HASH to pass directly the bcrypt hash. This is useful if you are creating users via scripts. `, }, { Name: "remove", Aliases: []string{"del", "rm"}, Usage: "Removes a user", UsageText: "ntfy user remove USERNAME", Action: execUserDel, Description: `Remove a user from the ntfy user database. Example: ntfy user del phil `, }, { Name: "change-pass", Aliases: []string{"chp"}, Usage: "Changes a user's password", 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. The new password will be read from STDIN, and it'll be confirmed by typing 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 or NTFY_PASSWORD_HASH to pass directly the bcrypt hash. This is useful if you are updating users via scripts. `, }, { Name: "change-role", Aliases: []string{"chr"}, Usage: "Changes the role of a user", UsageText: "ntfy user change-role USERNAME ROLE", Action: execUserChangeRole, Description: `Change the role for the given user to admin or user. This command can be used to change the role of a user either from a regular user to an admin user, or the other way around: - admin: an admin has read/write access to all topics - user: a regular user only has access to what was explicitly granted via 'ntfy access' When changing the role of a user to "admin", all access control entries for that user are removed, since they are no longer necessary. Example: ntfy user change-role phil admin # Make user phil an admin ntfy user change-role phil user # Remove admin role from user phil `, }, { Name: "change-tier", Aliases: []string{"cht"}, Usage: "Changes the tier of a user", UsageText: "ntfy user change-tier USERNAME (TIER|-)", Action: execUserChangeTier, Description: `Change the tier for the given user. This command can be used to change the tier of a user. Tiers define usage limits, such 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 `, }, { Name: "list", Aliases: []string{"l"}, Usage: "Shows a list of users", Action: execUserList, Description: `Shows a list of all configured users, including the everyone ('*') user. This command is an alias to calling 'ntfy access' (display access control list). 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. `, }, }, Description: `Manage users of the ntfy server. The command allows you to add/remove/change users in the ntfy user database, as well as change passwords or roles. This is a server-only command. It directly manages the user.db as defined in the server config file server.yml. The command only works if 'auth-file' is properly defined. Please also refer to the related command 'ntfy access'. Examples: ntfy user list # Shows list of users (alias: 'ntfy access') ntfy user add phil # Add regular user phil NTFY_PASSWORD=... ntfy user add phil # As above, using env variable to set password (for scripts) ntfy user add --role=admin phil # Add admin user phil ntfy user del phil # Delete user phil ntfy user change-pass phil # Change password for user phil NTFY_PASSWORD=.. ntfy user change-pass phil # As above, using env variable to set password (for scripts) ntfy user change-role phil admin # Make user phil an admin For the 'ntfy user add' and 'ntfy user change-pass' commands, you may set the NTFY_PASSWORD environment variable to pass the new password. This is useful if you are creating/updating users via scripts. `, } func execUserAdd(c *cli.Context) error { username := c.Args().Get(0) role := user.Role(c.String("role")) 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 { return errors.New("username not allowed") } else if !user.AllowedRole(role) { return errors.New("role must be either 'user' or 'admin'") } manager, err := createUserManager(c) if err != nil { return err } if user, _ := manager.User(username); user != nil { if c.Bool("ignore-exists") { fmt.Fprintf(c.App.Writer, "user %s already exists (exited successfully)\n", username) return nil } return fmt.Errorf("user %s already exists", username) } if password == "" { p, err := readPasswordAndConfirm(c) if err != nil { return err } password = p } if err := manager.AddUser(username, password, role, hashed); err != nil { return err } fmt.Fprintf(c.App.Writer, "user %s added with role %s\n", username, role) return nil } func execUserDel(c *cli.Context) error { username := c.Args().Get(0) if username == "" { return errors.New("username expected, type 'ntfy user del --help' for help") } else if username == userEveryone || username == user.Everyone { return errors.New("username not allowed") } manager, err := createUserManager(c) if err != nil { return err } 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.Writer, "user %s removed\n", username) return nil } func execUserChangePass(c *cli.Context) error { username := c.Args().Get(0) 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 { return errors.New("username not allowed") } manager, err := createUserManager(c) if err != nil { return err } if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } if password == "" { password, err = readPasswordAndConfirm(c) if err != nil { return err } } if err := manager.ChangePassword(username, password, hashed); err != nil { return err } fmt.Fprintf(c.App.Writer, "changed password for user %s\n", username) return nil } func execUserChangeRole(c *cli.Context) error { username := c.Args().Get(0) role := user.Role(c.Args().Get(1)) if username == "" || !user.AllowedRole(role) { return errors.New("username and new role expected, type 'ntfy user change-role --help' for help") } else if username == userEveryone || username == user.Everyone { return errors.New("username not allowed") } manager, err := createUserManager(c) if err != nil { return err } 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.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 } func execUserChangeTier(c *cli.Context) error { username := c.Args().Get(0) tier := c.Args().Get(1) if username == "" { return errors.New("username and new tier expected, type 'ntfy user change-tier --help' for help") } else if !user.AllowedTier(tier) && tier != tierReset { return errors.New("invalid tier, must be tier code, or - to reset") } else if username == userEveryone || username == user.Everyone { return errors.New("username not allowed") } manager, err := createUserManager(c) if err != nil { return err } 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.Writer, "removed tier from user %s\n", username) } else { if err := manager.ChangeTier(username, tier); err != nil { return err } fmt.Fprintf(c.App.Writer, "changed tier for user %s to %s\n", username, tier) } return nil } func execUserList(c *cli.Context) error { manager, err := createUserManager(c) if err != nil { return err } users, err := manager.Users() if err != nil { return err } return showUsers(c, manager, users) } func createUserManager(c *cli.Context) (*user.Manager, error) { authFile := c.String("auth-file") authStartupQueries := c.String("auth-startup-queries") authDefaultAccess := c.String("auth-default-access") databaseURL := c.String("database-url") authDefault, err := user.ParsePermission(authDefaultAccess) 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'") } authConfig := &user.Config{ DefaultAccess: authDefault, ProvisionEnabled: false, // Hack: Do not re-provision users on manager initialization BcryptCost: user.DefaultUserPasswordBcryptCost, QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval, } if databaseURL != "" { host, dbErr := pg.Open(databaseURL) if dbErr != nil { return nil, dbErr } return user.NewPostgresManager(db.New(host, nil), authConfig) } else if authFile != "" { if !util.FileExists(authFile) { return nil, errors.New("auth-file does not exist; please start the server at least once to create it") } return user.NewSQLiteManager(authFile, authStartupQueries, authConfig) } return nil, errors.New("option database-url or auth-file not set; auth is unconfigured for this server") } func readPasswordAndConfirm(c *cli.Context) (string, error) { fmt.Fprint(c.App.ErrWriter, "password: ") password, err := util.ReadPassword(c.App.Reader) if err != nil { return "", err } else if len(password) == 0 { return "", errors.New("password cannot be empty") } fmt.Fprintf(c.App.ErrWriter, "\r%s\rconfirm: ", strings.Repeat(" ", 25)) confirm, err := util.ReadPassword(c.App.Reader) if err != nil { return "", err } fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 25)) if subtle.ConstantTimeCompare(confirm, password) != 1 { return "", errors.New("passwords do not match: try it again, but this time type slooowwwlly") } return string(password), nil } ================================================ FILE: cmd/user_test.go ================================================ package cmd import ( "github.com/stretchr/testify/require" "github.com/urfave/cli/v2" "heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/test" "heckel.io/ntfy/v2/user" "os" "path/filepath" "testing" ) func TestCLI_User_Add(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) app, stdin, stdout, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "phil")) 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, stdout, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "phil")) require.Contains(t, stdout.String(), "user phil added with role user") app, stdin, _, _ = newTestApp() stdin.WriteString("mypass\nmypass") err := runUserCommand(app, conf, "add", "phil") require.Error(t, err) require.Contains(t, err.Error(), "user phil already exists") } func TestCLI_User_Add_Admin(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) app, stdin, stdout, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil")) require.Contains(t, stdout.String(), "user phil added with role admin") } func TestCLI_User_Add_Password_Mismatch(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) app, stdin, _, _ := newTestApp() stdin.WriteString("mypass\nNOTMATCH") err := runUserCommand(app, conf, "add", "phil") require.Error(t, err) require.Contains(t, err.Error(), "passwords do not match: try it again, but this time type slooowwwlly") } 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, stdout, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "phil")) require.Contains(t, stdout.String(), "user phil added with role user") // Change pass app, stdin, stdout, _ = newTestApp() stdin.WriteString("newpass\nnewpass") require.Nil(t, runUserCommand(app, conf, "change-pass", "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) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) // Add user app, stdin, stdout, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "phil")) require.Contains(t, stdout.String(), "user phil added with role user") // Change role app, _, stdout, _ = newTestApp() require.Nil(t, runUserCommand(app, conf, "change-role", "phil", "admin")) require.Contains(t, stdout.String(), "changed role for user phil to admin") } func TestCLI_User_Delete(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) // Add user app, stdin, stdout, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "phil")) require.Contains(t, stdout.String(), "user phil added with role user") // Delete user app, _, stdout, _ = newTestApp() require.Nil(t, runUserCommand(app, conf, "del", "phil")) require.Contains(t, stdout.String(), "user phil removed") // Delete user again (does not exist) app, _, _, _ = newTestApp() err := runUserCommand(app, conf, "del", "phil") require.Error(t, err) require.Contains(t, err.Error(), "user phil does not exist") } func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) { configFile := filepath.Join(t.TempDir(), "server-dummy.yml") require.Nil(t, os.WriteFile(configFile, []byte(""), 0600)) // Dummy config file to avoid lookup of real server.yml conf = server.NewConfig() conf.File = configFile conf.AuthFile = filepath.Join(t.TempDir(), "user.db") conf.AuthDefault = user.PermissionDenyAll s, port = test.StartServerWithConfig(t, conf) return } func runUserCommand(app *cli.App, conf *server.Config, args ...string) error { userArgs := []string{ "ntfy", "--log-level=ERROR", "user", "--config=" + conf.File, // Dummy config file to avoid lookups of real file "--auth-file=" + conf.AuthFile, "--auth-default-access=" + conf.AuthDefault.String(), } return app.Run(append(userArgs, args...)) } ================================================ FILE: cmd/webpush.go ================================================ //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() { commands = append(commands, cmdWebPush) } var cmdWebPush = &cli.Command{ Name: "webpush", Usage: "Generate keys, in the future manage web push subscriptions", UsageText: "ntfy webpush [keys]", Category: categoryServer, Subcommands: []*cli.Command{ { Action: generateWebPushKeys, Name: "keys", Usage: "Generate VAPID keys to enable browser background push notifications", UsageText: "ntfy webpush keys", Category: categoryServer, Flags: flagsWebPush, }, }, } func generateWebPushKeys(c *cli.Context) error { privateKey, publicKey, err := webpush.GenerateVAPIDKeys() if err != nil { return err } 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 web-push-file: /var/cache/ntfy/webpush.db # or similar web-push-email-address: See https://ntfy.sh/docs/config/#web-push for details. `, publicKey, privateKey) } return err } ================================================ FILE: cmd/webpush_test.go ================================================ package cmd import ( "path/filepath" "testing" "github.com/stretchr/testify/require" "github.com/urfave/cli/v2" "heckel.io/ntfy/v2/server" ) func TestCLI_WebPush_GenerateKeys(t *testing.T) { app, _, stdout, _ := newTestApp() require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys")) 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 { webPushArgs := []string{ "ntfy", "--log-level=ERROR", "webpush", } return app.Run(append(webPushArgs, args...)) } ================================================ FILE: db/db.go ================================================ package db import ( "context" "database/sql" "sync/atomic" "time" "heckel.io/ntfy/v2/log" ) const ( tag = "db" replicaHealthCheckInitialDelay = 5 * time.Second replicaHealthCheckInterval = 30 * time.Second replicaHealthCheckTimeout = 10 * time.Second ) // DB wraps a primary *sql.DB and optional read replicas. All standard query/exec methods // delegate to the primary. The ReadOnly() method returns a *sql.DB from a healthy replica // (round-robin), falling back to the primary if no replicas are configured or all are unhealthy. type DB struct { primary *Host replicas []*Host counter atomic.Uint64 cancel context.CancelFunc } // New creates a new DB that wraps the given primary and optional replica connections. // If replicas is nil or empty, ReadOnly() simply returns the primary. // Replicas start unhealthy and are checked immediately by a background goroutine. func New(primary *Host, replicas []*Host) *DB { ctx, cancel := context.WithCancel(context.Background()) d := &DB{ primary: primary, replicas: replicas, cancel: cancel, } if len(d.replicas) > 0 { go d.healthCheckLoop(ctx) } return d } // Query delegates to the primary database. func (d *DB) Query(query string, args ...any) (*sql.Rows, error) { return d.primary.DB.Query(query, args...) } // QueryRow delegates to the primary database. func (d *DB) QueryRow(query string, args ...any) *sql.Row { return d.primary.DB.QueryRow(query, args...) } // Exec delegates to the primary database. func (d *DB) Exec(query string, args ...any) (sql.Result, error) { return d.primary.DB.Exec(query, args...) } // Begin delegates to the primary database. func (d *DB) Begin() (*sql.Tx, error) { return d.primary.DB.Begin() } // Ping delegates to the primary database. func (d *DB) Ping() error { return d.primary.DB.Ping() } // Primary returns the underlying primary *sql.DB. This is only intended for // one-time schema setup during store initialization, not for regular queries. func (d *DB) Primary() *sql.DB { return d.primary.DB } // ReadOnly returns a *sql.DB suitable for read-only queries. It round-robins across healthy // replicas. If all replicas are unhealthy or none are configured, the primary is returned. func (d *DB) ReadOnly() *sql.DB { if len(d.replicas) == 0 { return d.primary.DB } n := len(d.replicas) start := int(d.counter.Add(1) - 1) for i := 0; i < n; i++ { r := d.replicas[(start+i)%n] if r.healthy.Load() { return r.DB } } return d.primary.DB } // Close closes the primary database and all replicas, and stops the health-check goroutine. func (d *DB) Close() error { d.cancel() for _, r := range d.replicas { r.DB.Close() } return d.primary.DB.Close() } // healthCheckLoop checks replicas immediately, then periodically on a ticker. func (d *DB) healthCheckLoop(ctx context.Context) { select { case <-ctx.Done(): return case <-time.After(replicaHealthCheckInitialDelay): d.checkReplicas(ctx) } for { select { case <-ctx.Done(): return case <-time.After(replicaHealthCheckInterval): d.checkReplicas(ctx) } } } // checkReplicas pings each replica with a timeout and updates its health status. func (d *DB) checkReplicas(ctx context.Context) { for _, r := range d.replicas { wasHealthy := r.healthy.Load() pingCtx, cancel := context.WithTimeout(ctx, replicaHealthCheckTimeout) err := r.DB.PingContext(pingCtx) cancel() if err != nil { r.healthy.Store(false) log.Tag(tag).Error("Database replica %s is unhealthy: %s", r.Addr, err) } else { r.healthy.Store(true) if !wasHealthy { log.Tag(tag).Info("Database replica %s is healthy", r.Addr) } } } } ================================================ FILE: db/pg/pg.go ================================================ package pg import ( "database/sql" "fmt" "net/url" "strconv" "strings" "time" _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver "heckel.io/ntfy/v2/db" ) // Open opens a PostgreSQL connection pool for a primary database. It pings the database // to verify connectivity before returning. func Open(dsn string) (*db.Host, error) { d, err := open(dsn) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } if err := d.DB.Ping(); err != nil { return nil, fmt.Errorf("database ping failed on %v: %w", d.Addr, err) } return d, nil } // OpenReplica opens a PostgreSQL connection pool for a read replica. Unlike Open, it does // not ping the database, since replicas are health-checked in the background by db.DB. func OpenReplica(dsn string) (*db.Host, error) { return open(dsn) } // open opens a PostgreSQL database connection pool from a DSN string. It supports custom // query parameters for pool configuration: pool_max_conns (default 10), pool_max_idle_conns, // pool_conn_max_lifetime, and pool_conn_max_idle_time. These parameters are stripped from // the DSN before passing it to the driver. func open(dsn string) (*db.Host, error) { u, err := url.Parse(dsn) if err != nil { return nil, fmt.Errorf("invalid database URL: %w", err) } switch u.Scheme { case "postgres", "postgresql": // OK default: return nil, fmt.Errorf("invalid database URL scheme %q, must be \"postgres\" or \"postgresql\" (URL: %s)", u.Scheme, censorPassword(u)) } q := u.Query() maxOpenConns, err := extractIntParam(q, "pool_max_conns", 10) if err != nil { return nil, err } maxIdleConns, err := extractIntParam(q, "pool_max_idle_conns", 0) if err != nil { return nil, err } connMaxLifetime, err := extractDurationParam(q, "pool_conn_max_lifetime", 0) if err != nil { return nil, err } connMaxIdleTime, err := extractDurationParam(q, "pool_conn_max_idle_time", 0) if err != nil { return nil, err } u.RawQuery = q.Encode() d, err := sql.Open("pgx", u.String()) if err != nil { return nil, err } d.SetMaxOpenConns(maxOpenConns) if maxIdleConns > 0 { d.SetMaxIdleConns(maxIdleConns) } if connMaxLifetime > 0 { d.SetConnMaxLifetime(connMaxLifetime) } if connMaxIdleTime > 0 { d.SetConnMaxIdleTime(connMaxIdleTime) } return &db.Host{ Addr: u.Host, DB: d, }, nil } func extractIntParam(q url.Values, key string, defaultValue int) (int, error) { s := q.Get(key) if s == "" { return defaultValue, nil } q.Del(key) v, err := strconv.Atoi(s) if err != nil { return 0, fmt.Errorf("invalid %s value %q: %w", key, s, err) } return v, nil } // censorPassword returns a string representation of the URL with the password replaced by "*****". func censorPassword(u *url.URL) string { if password, hasPassword := u.User.Password(); hasPassword { return strings.Replace(u.String(), ":"+password+"@", ":*****@", 1) } return u.String() } func extractDurationParam(q url.Values, key string, defaultValue time.Duration) (time.Duration, error) { s := q.Get(key) if s == "" { return defaultValue, nil } q.Del(key) d, err := time.ParseDuration(s) if err != nil { return 0, fmt.Errorf("invalid %s value %q: %w", key, s, err) } return d, nil } ================================================ FILE: db/pg/pg_test.go ================================================ package pg import ( "net/url" "testing" "github.com/stretchr/testify/require" ) func TestOpen_InvalidScheme(t *testing.T) { _, err := Open("postgresql+psycopg2://user:pass@localhost/db") require.Error(t, err) require.Contains(t, err.Error(), `invalid database URL scheme "postgresql+psycopg2"`) require.Contains(t, err.Error(), "*****") require.NotContains(t, err.Error(), "pass") } func TestOpen_InvalidURL(t *testing.T) { _, err := Open("not a valid url\x00") require.Error(t, err) require.Contains(t, err.Error(), "invalid database URL") } func TestCensorPassword(t *testing.T) { tests := []struct { name string url string expected string }{ { name: "with password", url: "postgres://user:secret@localhost/db", expected: "postgres://user:*****@localhost/db", }, { name: "without password", url: "postgres://localhost/db", expected: "postgres://localhost/db", }, { name: "user only", url: "postgres://user@localhost/db", expected: "postgres://user@localhost/db", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { u, err := url.Parse(tt.url) require.NoError(t, err) require.Equal(t, tt.expected, censorPassword(u)) }) } } ================================================ FILE: db/test/test.go ================================================ package dbtest import ( "fmt" "net/url" "os" "testing" "github.com/stretchr/testify/require" "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db/pg" "heckel.io/ntfy/v2/util" ) const testPoolMaxConns = "2" // CreateTestPostgresSchema creates a temporary PostgreSQL schema and returns the DSN pointing to it. // It registers a cleanup function to drop the schema when the test finishes. // If NTFY_TEST_DATABASE_URL is not set, the test is skipped. func CreateTestPostgresSchema(t *testing.T) string { t.Helper() dsn := os.Getenv("NTFY_TEST_DATABASE_URL") if dsn == "" { t.Skip("NTFY_TEST_DATABASE_URL not set") } schema := fmt.Sprintf("test_%s", util.RandomString(10)) u, err := url.Parse(dsn) require.Nil(t, err) q := u.Query() q.Set("pool_max_conns", testPoolMaxConns) u.RawQuery = q.Encode() dsn = u.String() setupHost, err := pg.Open(dsn) require.Nil(t, err) _, err = setupHost.DB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) require.Nil(t, err) require.Nil(t, setupHost.DB.Close()) q.Set("search_path", schema) u.RawQuery = q.Encode() schemaDSN := u.String() t.Cleanup(func() { cleanHost, err := pg.Open(dsn) if err == nil { cleanHost.DB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) cleanHost.DB.Close() } }) return schemaDSN } // CreateTestPostgres creates a temporary PostgreSQL schema and returns an open *db.DB connection to it. // It registers cleanup functions to close the DB and drop the schema when the test finishes. // If NTFY_TEST_DATABASE_URL is not set, the test is skipped. func CreateTestPostgres(t *testing.T) *db.DB { t.Helper() schemaDSN := CreateTestPostgresSchema(t) testHost, err := pg.Open(schemaDSN) require.Nil(t, err) d := db.New(testHost, nil) t.Cleanup(func() { d.Close() }) return d } ================================================ FILE: db/types.go ================================================ package db import ( "database/sql" "sync/atomic" ) // Beginner is an interface for types that can begin a database transaction. // Both *sql.DB and *DB implement this. type Beginner interface { Begin() (*sql.Tx, error) } // Querier is an interface for types that can execute SQL queries. // *sql.DB, *sql.Tx, and *DB all implement this. type Querier interface { Query(query string, args ...any) (*sql.Rows, error) } // Host pairs a *sql.DB with the host:port it was opened against. type Host struct { Addr string // "host:port" DB *sql.DB healthy atomic.Bool } ================================================ FILE: db/util.go ================================================ package db import "database/sql" // ExecTx executes a function within a database transaction. If the function returns an error, // the transaction is rolled back. Otherwise, the transaction is committed. func ExecTx(db Beginner, f func(tx *sql.Tx) error) error { tx, err := db.Begin() if err != nil { return err } defer tx.Rollback() if err := f(tx); err != nil { return err } return tx.Commit() } // QueryTx executes a function within a database transaction and returns the result. If the function // returns an error, the transaction is rolled back. Otherwise, the transaction is committed. func QueryTx[T any](db Beginner, f func(tx *sql.Tx) (T, error)) (T, error) { tx, err := db.Begin() if err != nil { var zero T return zero, err } defer tx.Rollback() t, err := f(tx) if err != nil { return t, err } if err := tx.Commit(); err != nil { return t, err } return t, nil } ================================================ FILE: docker-compose.yml ================================================ services: ntfy: image: binwiederhier/ntfy container_name: ntfy command: - serve environment: - TZ=UTC # optional: Change to your desired timezone user: UID:GID # optional: Set custom user/group or uid/gid volumes: - /var/cache/ntfy:/var/cache/ntfy - /etc/ntfy:/etc/ntfy ports: - 80:80 restart: unless-stopped ================================================ FILE: docs/_overrides/main.html ================================================ {% extends "base.html" %} {% block announce %} If you like ntfy, please consider sponsoring me via GitHub Sponsors or Liberapay , or subscribing to ntfy Pro. {% endblock %} ================================================ FILE: docs/config.md ================================================ # Configuring the ntfy server The ntfy server can be configured in three ways: using a config file (typically at `/etc/ntfy/server.yml`, see [server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)), via command line arguments or using environment variables. ## Quick start By default, simply running `ntfy serve` will start the server at port 80. No configuration needed. Batteries included 😀. If everything works as it should, you'll see something like this: ``` $ ntfy serve 2021/11/30 19:59:08 Listening on :80 ``` You can immediately start [publishing messages](publish.md), or subscribe via the [Android app](subscribe/phone.md), [the web UI](subscribe/web.md), or simply via [curl or your favorite HTTP client](subscribe/api.md). To configure the server further, check out the [config options table](#config-options) or simply type `ntfy serve --help` to 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. 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. Here are a few working sample configs using a `/etc/ntfy/server.yml` file: === "server.yml (HTTP-only, with cache + attachments)" ``` yaml base-url: "http://ntfy.example.com" cache-file: "/var/cache/ntfy/cache.db" attachment-cache-dir: "/var/cache/ntfy/attachments" ``` === "server.yml (HTTP+HTTPS, with cache + attachments)" ``` yaml base-url: "http://ntfy.example.com" listen-http: ":80" listen-https: ":443" key-file: "/etc/letsencrypt/live/ntfy.example.com.key" cert-file: "/etc/letsencrypt/live/ntfy.example.com.crt" cache-file: "/var/cache/ntfy/cache.db" attachment-cache-dir: "/var/cache/ntfy/attachments" ``` === "server.yml (behind proxy, with cache + attachments)" ``` yaml base-url: "http://ntfy.example.com" listen-http: ":2586" cache-file: "/var/cache/ntfy/cache.db" attachment-cache-dir: "/var/cache/ntfy/attachments" behind-proxy: true ``` === "server.yml (PostgreSQL, behind proxy)" ``` yaml base-url: "https://ntfy.example.com" listen-http: ":2586" database-url: "postgres://ntfy:mypassword@db.example.com:5432/ntfy?sslmode=require" attachment-cache-dir: "/var/cache/ntfy/attachments" behind-proxy: true auth-default-access: "deny-all" ``` === "server.yml (ntfy.sh config)" ``` yaml # All the things: Behind a proxy, Firebase, cache, attachments, # SMTP publishing & receiving base-url: "https://ntfy.sh" listen-http: "127.0.0.1:2586" firebase-key-file: "/etc/ntfy/firebase.json" cache-file: "/var/cache/ntfy/cache.db" behind-proxy: true attachment-cache-dir: "/var/cache/ntfy/attachments" smtp-sender-addr: "email-smtp.us-east-2.amazonaws.com:587" smtp-sender-user: "AKIDEADBEEFAFFE12345" smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG." smtp-sender-from: "ntfy@ntfy.sh" smtp-server-listen: ":25" smtp-server-domain: "ntfy.sh" smtp-server-addr-prefix: "ntfy-" keepalive-interval: "45s" ``` Alternatively, you can also use command line arguments or environment variables to configure the server. Here's an example using Docker Compose (i.e. `docker-compose.yml`): === "Docker Compose (w/ auth, cache, attachments)" ``` yaml services: ntfy: image: binwiederhier/ntfy restart: unless-stopped environment: NTFY_BASE_URL: http://ntfy.example.com 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 volumes: - ./:/var/lib/ntfy ports: - 80:80 command: serve ``` === "Docker Compose (w/ auth, cache, web push, iOS)" ``` yaml services: ntfy: image: binwiederhier/ntfy restart: unless-stopped environment: NTFY_BASE_URL: http://ntfy.example.com NTFY_CACHE_FILE: /var/lib/ntfy/cache.db NTFY_AUTH_FILE: /var/lib/ntfy/auth.db NTFY_AUTH_DEFAULT_ACCESS: deny-all NTFY_BEHIND_PROXY: true NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments NTFY_ENABLE_LOGIN: true NTFY_UPSTREAM_BASE_URL: https://ntfy.sh NTFY_WEB_PUSH_PUBLIC_KEY: NTFY_WEB_PUSH_PRIVATE_KEY: NTFY_WEB_PUSH_FILE: /var/lib/ntfy/webpush.db NTFY_WEB_PUSH_EMAIL_ADDRESS: volumes: - ./:/var/lib/ntfy ports: - 8093:80 command: serve ``` ## Config generator This generator helps you configure your self-hosted ntfy instance. It's not fully featured, but it is a good starting point. Please refer to the relevant sections in the doc for more details.
The config generator helps you create a custom config for your self-hosted ntfy instance. Click to open.
## Database options ntfy uses a database for storing messages ([message cache](#message-cache)), users and [access control](#access-control), and [web push](#web-push) subscriptions. You can choose between **SQLite** and **PostgreSQL** as the database backend. ### SQLite By default, ntfy uses SQLite with separate database files for each store. This is the simplest setup and requires no external dependencies: * `cache-file`: Database file for the [message cache](#message-cache). * `auth-file`: Database file for authentication and [access control](#access-control). If set, enables auth. * `web-push-file`: Database file for [web push](#web-push) subscriptions. ### PostgreSQL (EXPERIMENTAL) As an alternative, you can configure ntfy to use PostgreSQL for **all** database-backed stores by setting the `database-url` option to a PostgreSQL connection string. When `database-url` is set, ntfy will use PostgreSQL for the [message cache](#message-cache), [access control](#access-control), and [web push](#web-push) subscriptions instead of SQLite. The `cache-file`, `auth-file`, and `web-push-file` options **must not** be set in this case. Note that setting `database-url` implicitly enables authentication and access control (equivalent to setting `auth-file` with SQLite). The default access is `read-write`, so anonymous users can still read and write to all topics. To restrict access, set `auth-default-access` to `deny-all` (see [access control](#access-control)). You can also set this via the environment variable `NTFY_DATABASE_URL` or the command line flag `--database-url`. To offload read-heavy queries from the primary database, you can optionally configure one or more read replicas using the `database-replica-urls` option. When configured, non-critical read-only queries (e.g. fetching messages, checking access permissions, etc) are distributed across the replicas using round-robin, while all writes and correctness-critical reads continue to go to the primary. If a replica becomes unhealthy, ntfy automatically falls back to the primary until the replica recovers. You can also set this via the environment variable `NTFY_DATABASE_REPLICA_URLS` (comma-separated) or the command line flag `--database-replica-urls`. Examples: === "Simple" ```yaml database-url: "postgres://user:pass@host:5432/ntfy" ``` === "With SSL and pool tuning" ```yaml database-url: "postgres://user:pass@host:5432/ntfy?sslmode=require&pool_max_conns=50&pool_conn_max_idle_time=5m" ``` === "With CA certificate" ```yaml database-url: "postgres://user:pass@host:25060/ntfy?sslmode=require&sslrootcert=/etc/ntfy/db-ca-cert.pem&pool_max_conns=30" ``` === "With read replicas" ```yaml database-url: "postgres://user:pass@primary:5432/ntfy?sslmode=require&sslrootcert=/etc/ntfy/db-ca-cert.pem&pool_max_conns=30" database-replica-urls: - "postgres://user:pass@replica1:5432/ntfy?sslmode=require&sslrootcert=/etc/ntfy/db-ca-cert.pem&pool_max_conns=30" - "postgres://user:pass@replica2:5432/ntfy?sslmode=require&sslrootcert=/etc/ntfy/db-ca-cert.pem&pool_max_conns=30" ``` The database URL supports the standard [PostgreSQL connection parameters](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS) as query parameters, such as `sslmode`, `connect_timeout`, `sslcert`, `sslkey`, `sslrootcert`, and `application_name`. See the [pgx driver documentation](https://pkg.go.dev/github.com/jackc/pgx/v5) for the full list of supported parameters. In addition, ntfy supports the following custom query parameters to tune the connection pool (these apply to both the primary and replica URLs): | Parameter | Default | Description | |---------------------------|---------|----------------------------------------------------------------------------------| | `pool_max_conns` | 10 | Maximum number of open connections to the database | | `pool_max_idle_conns` | - | Maximum number of idle connections in the pool | | `pool_conn_max_lifetime` | - | Maximum amount of time a connection may be reused (Go duration, e.g. `5m`, `1h`) | | `pool_conn_max_idle_time` | - | Maximum amount of time a connection may be idle (Go duration, e.g. `30s`, `5m`) | ## Message cache If desired, ntfy can temporarily keep notifications in an in-memory or an on-disk cache. Caching messages for a short period of time is important to allow [phones](subscribe/phone.md) and other devices with brittle Internet connections to be able to retrieve notifications that they may have missed. By default, ntfy keeps messages **in-memory for 12 hours**, which means that **cached messages do not survive an application restart**. You can override this behavior by setting `cache-file` (SQLite) or `database-url` (PostgreSQL). * `cache-duration`: defines the duration for which messages are stored in the cache (default is `12h`). You can also entirely disable the cache by setting `cache-duration` to `0`. When the cache is disabled, messages are only passed on to the connected subscribers, but never stored on disk or even kept in memory longer than is needed to forward the message to the subscribers. Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscribe/api.md#poll-for-messages), as well as the [`since=` parameter](subscribe/api.md#fetch-cached-messages). ## Attachments If desired, you may allow users to upload and [attach files to notifications](publish.md#attachments). To enable this feature, you have to simply configure an attachment cache directory and a base URL (`attachment-cache-dir`, `base-url`). Once these options are set and the directory is writable by the server user, you can upload attachments via PUT. By default, attachments are stored in the disk-cache **for only 3 hours**. The main reason for this is to avoid legal issues and such when hosting user controlled content. Typically, this is more than enough time for the user (or the auto download feature) to download the file. The following config options are relevant to attachments: * `base-url` is the root URL for the ntfy server; this is needed for the generated attachment URLs * `attachment-cache-dir` is the cache directory for attached files * `attachment-total-size-limit` is the size limit of the on-disk attachment cache (default: 5G) * `attachment-file-size-limit` is the per-file attachment size limit (e.g. 300k, 2M, 100M, default: 15M) * `attachment-expiry-duration` is the duration after which uploaded attachments will be deleted (e.g. 3h, 20h, default: 3h) Here's an example config using mostly the defaults (except for the cache directory, which is empty by default): === "/etc/ntfy/server.yml (minimal)" ``` yaml base-url: "https://ntfy.sh" attachment-cache-dir: "/var/cache/ntfy/attachments" ``` === "/etc/ntfy/server.yml (all options)" ``` yaml base-url: "https://ntfy.sh" attachment-cache-dir: "/var/cache/ntfy/attachments" attachment-total-size-limit: "5G" attachment-file-size-limit: "15M" attachment-expiry-duration: "3h" visitor-attachment-total-size-limit: "100M" visitor-attachment-daily-bandwidth-limit: "500M" ``` Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-attachment-total-size-limit` and `visitor-attachment-daily-bandwidth-limit`. Setting these conservatively is necessary to avoid abuse. ## Access control By default, the ntfy server is open for everyone, meaning **everyone can read and write to any topic** (this is how ntfy.sh is configured). To restrict access to your own server, you can optionally configure authentication and authorization. ntfy's auth implements two roles (`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, **configure the following options**: * `auth-file` is the user/access database (SQLite); it is created automatically if it doesn't already exist; suggested location `/var/lib/ntfy/user.db` (easiest if deb/rpm package is used). Alternatively, if `database-url` is set, auth is automatically enabled using PostgreSQL (see [database options](#database-options)). * `auth-default-access` defines the default/fallback access if no access control entry is found; it can be set to `read-write` (default), `read-only`, `write-only` or `deny-all`. **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 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)). **Roles:** * Role `user` (default): Users with this role have no special permissions. Manage access using `ntfy access` (see [below](#access-control-list-acl)). * Role `admin`: Users with this role can read/write to all topics. Granular access control is not necessary. **Example commands** (type `ntfy user --help` or `ntfy user COMMAND --help` for more details): ``` ntfy user list # Shows list of users (alias: 'ntfy access') ntfy user add phil # Add regular user phil ntfy user add --role=admin phil # Add admin user phil ntfy user del phil # Delete user phil 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. 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: ``` ntfy access # Shows access control list (alias: 'ntfy user list') ntfy access USERNAME # Shows access control entries for USERNAME ntfy access USERNAME TOPIC PERMISSION # Allow/deny access for USERNAME to TOPIC ``` A `USERNAME` is an existing user, as created with `ntfy user add` (see [users and roles](#users-and-roles)), or the anonymous user `everyone` or `*`, which represents clients that access the API without username/password. A `TOPIC` is either a specific topic name (e.g. `mytopic`, or `phil_alerts`), or a wildcard pattern that matches any number of topics (e.g. `alerts_*` or `ben-*`). Only the wildcard character `*` is supported. It stands for zero to any number of characters. A `PERMISSION` is any of the following supported permissions: * `read-write` (alias: `rw`): Allows [publishing messages](publish.md) to the given topic, as well as [subscribing](subscribe/api.md) and reading messages * `read-only` (aliases: `read`, `ro`): Allows only subscribing and reading messages, but not publishing to the topic * `write-only` (aliases: `write`, `wo`): Allows only publishing to the topic, but not subscribing to it * `deny` (alias: `none`): Allows neither publishing nor subscribing to a topic **Example commands** (type `ntfy access --help` for more details): ``` ntfy access # Shows entire access control list ntfy access phil # Shows access for user phil ntfy access phil mytopic rw # Allow read-write access to mytopic for user phil ntfy access everyone mytopic rw # Allow anonymous read-write access to mytopic ntfy access everyone "up*" write # Allow anonymous write-only access to topics "up..." ntfy access --reset # Reset entire access control list ntfy access --reset phil # Reset all access for user phil ntfy access --reset phil mytopic # Reset access for user phil and topic mytopic ``` **Example ACL:** ``` $ ntfy access user phil (admin) - read-write access to all topics (admin role) user ben (user) - read-write access to topic garagedoor - read-write access to topic alerts* - read-only access to topic furnace user * (anonymous) - read-only access to topic announcements - read-only access to topic server-stats - no access to any (other) topics (server config) ``` In this example, `phil` has the role `admin`, so he has read-write access to all topics (no ACL entries are necessary). User `ben` has three topic-specific entries. He can read, but not write to topic `furnace`, and has read-write access 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 want to use a dedicated token to publish from your backup host, and one from your home automation system. !!! info As of today, access tokens grant users **full access to the user account**. Aside from changing the password, and deleting the account, every action can be performed with a token. Granular access tokens are on the roadmap, but not yet implemented. You can create access tokens in two different ways: * [Using the CLI](#tokens-via-the-cli): Using the `ntfy token` command, you can manually add/update/remove tokens. * [In the config](#tokens-via-the-config): You can provision access tokens in the `server.yml` file via `auth-tokens` key. #### Tokens via the CLI The `ntfy token` command can be used to manage access tokens for users. Tokens can have labels, and they can expire automatically (or never expire). Each user can have up to 60 tokens (hardcoded). **Example commands** (type `ntfy token --help` or `ntfy token COMMAND --help` for more details): ``` ntfy token list # Shows list of tokens for all users 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:** ``` $ ntfy token add --expires=30d --label="backups" phil $ ntfy token list user phil - 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). #### 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. 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 `:[: