mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-11-10 14:44:04 +08:00
A weight like 1e16 overflowed to a negative token count, which the limiter always allows, so the code never banned. Weights above 1,000,000 and thresholds above 1,000,000,000 are now rejected at startup.
845 lines
57 KiB
Go
845 lines
57 KiB
Go
//go:build !noserver
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"math"
|
|
"net"
|
|
"net/netip"
|
|
"net/url"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"text/template"
|
|
"time"
|
|
|
|
"github.com/urfave/cli/v2"
|
|
"github.com/urfave/cli/v2/altsrc"
|
|
"heckel.io/ntfy/v2/ban"
|
|
"heckel.io/ntfy/v2/cluster"
|
|
"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; several comma-separated addresses are allowed"}),
|
|
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: "experimental-listen-admin-unix", Aliases: []string{"experimental_listen_admin_unix"}, EnvVars: []string{"NTFY_EXPERIMENTAL_LISTEN_ADMIN_UNIX"}, Usage: "experimental: unix socket path for credential-less local admin API access, e.g. /run/ntfy/admin.sock; empty or \"-\" disables it"}),
|
|
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: "experimental-cluster-node-id", Aliases: []string{"experimental_cluster_node_id"}, EnvVars: []string{"NTFY_EXPERIMENTAL_CLUSTER_NODE_ID"}, Usage: "stable per-node identifier for the cluster node registry (required in cluster mode)"}),
|
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "experimental-cluster-listen", Aliases: []string{"experimental_cluster_listen"}, EnvVars: []string{"NTFY_EXPERIMENTAL_CLUSTER_LISTEN"}, Usage: "ip:port for the dedicated cluster fan-out listener; bind it to the private network (e.g. 10.0.0.5:2587)"}),
|
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "experimental-cluster-advertise-url", Aliases: []string{"experimental_cluster_advertise_url"}, EnvVars: []string{"NTFY_EXPERIMENTAL_CLUSTER_ADVERTISE_URL"}, Usage: "base URL peer nodes use to reach this node's fan-out listener (defaults to http://<experimental-cluster-listen>)"}),
|
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "experimental-cluster-secret", Aliases: []string{"experimental_cluster_secret"}, EnvVars: []string{"NTFY_EXPERIMENTAL_CLUSTER_SECRET"}, Usage: "shared secret authenticating node-to-node fan-out requests"}),
|
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "experimental-cluster-role", Aliases: []string{"experimental_cluster_role"}, EnvVars: []string{"NTFY_EXPERIMENTAL_CLUSTER_ROLE"}, Value: string(server.DefaultClusterRole), Usage: "role of this cluster node: active (takes client traffic, may lead) or passive (hidden: never leads, never fences)"}),
|
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "experimental-cluster-batch-linger", Aliases: []string{"experimental_cluster_batch_linger"}, EnvVars: []string{"NTFY_EXPERIMENTAL_CLUSTER_BATCH_LINGER"}, Value: util.FormatDuration(server.DefaultClusterBatchLinger), Usage: "how long fan-out messages wait to form a batch per peer node (0 = send immediately)"}),
|
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "experimental-cluster-drain-delay", Aliases: []string{"experimental_cluster_drain_delay"}, EnvVars: []string{"NTFY_EXPERIMENTAL_CLUSTER_DRAIN_DELAY"}, Value: util.FormatDuration(server.DefaultClusterDrainDelay), Usage: "on shutdown (SIGTERM), how long an active cluster node withdraws (not ready, but still serves everything), so the load balancers stop sending it requests before it turns read-only"}),
|
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "experimental-cluster-drain-duration", Aliases: []string{"experimental_cluster_drain_duration"}, EnvVars: []string{"NTFY_EXPERIMENTAL_CLUSTER_DRAIN_DURATION"}, Value: util.FormatDuration(server.DefaultClusterDrainDuration), Usage: "on shutdown (SIGTERM), how long an active cluster node takes to close its subscriber connections, so they reconnect to the other nodes gradually (0 = all at once)"}),
|
|
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.NewBoolFlag(&cli.BoolFlag{Name: "auth-access-cache", Aliases: []string{"auth_access_cache"}, EnvVars: []string{"NTFY_AUTH_ACCESS_CACHE"}, Value: user.DefaultAccessCacheEnabled, Usage: "enables the in-memory ACL cache (high-volume servers only)"}),
|
|
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, or S3 URL (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT])"}),
|
|
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.NewBoolFlag(&cli.BoolFlag{Name: "smtp-sender-verify", Aliases: []string{"smtp_sender_verify"}, EnvVars: []string{"NTFY_SMTP_SENDER_VERIFY"}, Value: false, Usage: "require verified email addresses for sending email notifications"}),
|
|
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 bandwidth limit per visitor, for attachment downloads/uploads and messages replayed from the cache by poll requests"}),
|
|
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-topic-creation-limit-burst", Aliases: []string{"visitor_topic_creation_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_TOPIC_CREATION_LIMIT_BURST"}, Value: server.DefaultVisitorTopicCreationLimitBurst, Usage: "burst of new topic creations per visitor (0 = disabled)"}),
|
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-topic-creation-limit-replenish", Aliases: []string{"visitor_topic_creation_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorTopicCreationLimitReplenish), Usage: "interval at which topic-creation tokens are refilled (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.NewStringFlag(&cli.StringFlag{Name: "ban-file", Aliases: []string{"ban_file"}, EnvVars: []string{"NTFY_BAN_FILE"}, Value: "", Usage: "if set, append IPs of abusive visitors to this file for fail2ban to tail (empty disables)"}),
|
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "ban-window", Aliases: []string{"ban_window"}, EnvVars: []string{"NTFY_BAN_WINDOW"}, Value: util.FormatDuration(server.DefaultBanWindow), Usage: "rolling window over which weighted strikes are counted for the ban file"}),
|
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "ban-threshold", Aliases: []string{"ban_threshold"}, EnvVars: []string{"NTFY_BAN_THRESHOLD"}, Value: server.DefaultBanThreshold, Usage: "weighted strikes per window before an offender is banned"}),
|
|
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "ban-weights", Aliases: []string{"ban_weights"}, EnvVars: []string{"NTFY_BAN_WEIGHTS"}, Value: cli.NewStringSlice(server.DefaultBanWeights...), Usage: "per-code strike weights as KEY:WEIGHT, where KEY is an ntfy code, an HTTP status, a PREFIX*, or '*' (weight 0 exempts)"}),
|
|
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"), "/")
|
|
listenHTTPRaw := c.String("listen-http")
|
|
listenHTTPS := c.String("listen-https")
|
|
listenUnix := c.String("listen-unix")
|
|
listenUnixMode := c.Int("listen-unix-mode")
|
|
listenAdminUnix := c.String("experimental-listen-admin-unix")
|
|
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")
|
|
clusterNodeID := c.String("experimental-cluster-node-id")
|
|
clusterListen := c.String("experimental-cluster-listen")
|
|
clusterAdvertiseURL := c.String("experimental-cluster-advertise-url")
|
|
clusterSecret := c.String("experimental-cluster-secret")
|
|
clusterBatchLingerStr := c.String("experimental-cluster-batch-linger")
|
|
clusterRoleStr := c.String("experimental-cluster-role")
|
|
clusterDrainDelayStr := c.String("experimental-cluster-drain-delay")
|
|
clusterDrainDurationStr := c.String("experimental-cluster-drain-duration")
|
|
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")
|
|
authAccessCacheEnabled := c.Bool("auth-access-cache")
|
|
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")
|
|
smtpSenderVerify := c.Bool("smtp-sender-verify")
|
|
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")
|
|
visitorTopicCreationLimitBurst := c.Int("visitor-topic-creation-limit-burst")
|
|
visitorTopicCreationLimitReplenishStr := c.String("visitor-topic-creation-limit-replenish")
|
|
visitorPrefixBitsIPv4 := c.Int("visitor-prefix-bits-ipv4")
|
|
visitorPrefixBitsIPv6 := c.Int("visitor-prefix-bits-ipv6")
|
|
banFile := c.String("ban-file")
|
|
banWindowStr := c.String("ban-window")
|
|
banThreshold := c.Int("ban-threshold")
|
|
banWeightsRaw := c.StringSlice("ban-weights")
|
|
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)
|
|
}
|
|
clusterBatchLinger, err := util.ParseDuration(clusterBatchLingerStr)
|
|
if err != nil || clusterBatchLinger < 0 {
|
|
return fmt.Errorf("invalid cluster batch linger: %s", clusterBatchLingerStr)
|
|
}
|
|
clusterDrainDelay, err := util.ParseDuration(clusterDrainDelayStr)
|
|
if err != nil || clusterDrainDelay < 0 {
|
|
return fmt.Errorf("invalid cluster drain delay: %s", clusterDrainDelayStr)
|
|
}
|
|
clusterDrainDuration, err := util.ParseDuration(clusterDrainDurationStr)
|
|
if err != nil || clusterDrainDuration < 0 {
|
|
return fmt.Errorf("invalid cluster drain duration: %s", clusterDrainDurationStr)
|
|
}
|
|
clusterRole := cluster.Role(clusterRoleStr)
|
|
if !clusterRole.Valid() {
|
|
return fmt.Errorf("invalid experimental-cluster-role: %s (must be %s or %s)", clusterRoleStr, cluster.RoleActive, cluster.RolePassive)
|
|
}
|
|
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)
|
|
}
|
|
visitorTopicCreationLimitReplenish, err := util.ParseDuration(visitorTopicCreationLimitReplenishStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid visitor topic creation limit replenish: %s", visitorTopicCreationLimitReplenishStr)
|
|
}
|
|
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)
|
|
}
|
|
banWindow, err := util.ParseDuration(banWindowStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid ban window: %s", banWindowStr)
|
|
}
|
|
|
|
// Parse abuse ban-feed weights ("KEY:WEIGHT" list, "*" fallback)
|
|
banWeights, err := ban.ParseWeights(banWeightsRaw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 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; checks that don't return stay out of the else-if chain, which would end there
|
|
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)
|
|
}
|
|
}
|
|
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 clusterListen != "" && databaseURL == "" {
|
|
return errors.New("experimental-cluster-listen requires database-url to be set")
|
|
} else if clusterListen != "" && clusterSecret == "" {
|
|
return errors.New("experimental-cluster-listen requires experimental-cluster-secret to be set")
|
|
} else if clusterListen != "" && clusterNodeID == "" {
|
|
return errors.New("experimental-cluster-listen requires experimental-cluster-node-id to be set")
|
|
} else if clusterListen == "" && clusterSecret != "" {
|
|
return errors.New("experimental-cluster-secret can only be used if experimental-cluster-listen is set")
|
|
} else if clusterListen != "" && clusterAdvertiseURL == "" && wildcardAddr(clusterListen) {
|
|
return errors.New("experimental-cluster-advertise-url must be set if experimental-cluster-listen binds a wildcard address")
|
|
} 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 smtpSenderVerify && smtpSenderAddr == "" {
|
|
return errors.New("if smtp-sender-verify is set, smtp-sender-addr 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 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 > 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 banFile != "" && banWindow <= 0 {
|
|
return errors.New("if ban-file is set, ban-window must be greater than zero")
|
|
} else if banFile != "" && banThreshold <= 0 {
|
|
return errors.New("if ban-file is set, ban-threshold must be greater than zero")
|
|
} else if banFile != "" && banThreshold > ban.MaxThreshold {
|
|
return fmt.Errorf("if ban-file is set, ban-threshold must be at most %d", ban.MaxThreshold)
|
|
} else if banFile != "" && len(banWeights) == 0 {
|
|
return errors.New("if ban-file is set, ban-weights must not be empty")
|
|
} else if banFile != "" && !util.FileExists(filepath.Dir(banFile)) {
|
|
return fmt.Errorf("if ban-file is set, its directory (%s) must exist", filepath.Dir(banFile))
|
|
} else if runtime.GOOS == "windows" && listenUnix != "" {
|
|
return errors.New("listen-unix is not supported on Windows")
|
|
}
|
|
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 cacheDuration > 0 && attachmentExpiryDuration > cacheDuration {
|
|
log.Warn("attachment-expiry-duration (%s) is longer than cache-duration (%s); attachments never outlive their message, so they will be deleted after %s", attachmentExpiryDurationStr, cacheDurationStr, cacheDurationStr)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
listenHTTP, err := parseListenHTTP(listenHTTPRaw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if listenAdminUnix == "-" {
|
|
listenAdminUnix = "" // Unsets the default, like listen-http
|
|
}
|
|
|
|
// 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.ListenAdminUnix = listenAdminUnix
|
|
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.AuthAccessCacheEnabled = authAccessCacheEnabled
|
|
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.SMTPSenderVerify = smtpSenderVerify
|
|
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.VisitorTopicCreationLimitBurst = visitorTopicCreationLimitBurst
|
|
conf.VisitorTopicCreationLimitReplenish = visitorTopicCreationLimitReplenish
|
|
conf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4
|
|
conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6
|
|
conf.BanFile = banFile
|
|
conf.BanWindow = banWindow
|
|
conf.BanThreshold = banThreshold
|
|
conf.BanWeights = banWeights
|
|
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.ClusterNodeID = clusterNodeID
|
|
conf.ClusterListen = clusterListen
|
|
conf.ClusterAdvertiseURL = clusterAdvertiseURL
|
|
conf.ClusterSecret = clusterSecret
|
|
conf.ClusterBatchLinger = clusterBatchLinger
|
|
conf.ClusterRole = clusterRole
|
|
conf.ClusterDrainDelay = clusterDrainDelay
|
|
conf.ClusterDrainDuration = clusterDrainDuration
|
|
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())
|
|
}
|
|
stopping := make(chan struct{})
|
|
go sigHandlerShutdown(s, stopping)
|
|
err = s.Run()
|
|
select {
|
|
case <-stopping:
|
|
// Run returns as soon as the listeners close; Stop blocks until the signal-triggered
|
|
// shutdown (incl. flushing the message cache) has completed
|
|
s.Stop()
|
|
default:
|
|
if 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
|
|
}
|
|
|
|
// wildcardAddr reports whether the given listen address binds all interfaces (e.g. ":2587",
|
|
// "0.0.0.0:2587", "[::]:2587"), in which case peers cannot derive a reachable URL from it.
|
|
func wildcardAddr(addr string) bool {
|
|
host, _, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return true // Unparseable -> cannot derive a URL either
|
|
}
|
|
return host == "" || host == "0.0.0.0" || host == "::"
|
|
}
|
|
|
|
// parseListenHTTP parses the comma-separated listen-http addresses ("-" unsets the default)
|
|
func parseListenHTTP(raw string) ([]string, error) {
|
|
addrs := make([]string, 0)
|
|
if raw == "" || raw == "-" {
|
|
return addrs, nil
|
|
}
|
|
seen := make(map[string]struct{})
|
|
for _, addr := range strings.Split(raw, ",") {
|
|
addr = strings.TrimSpace(addr)
|
|
if addr == "" {
|
|
return nil, fmt.Errorf("invalid listen-http: %s, empty address", raw)
|
|
}
|
|
if _, ok := seen[addr]; ok {
|
|
return nil, fmt.Errorf("invalid listen-http: %s, duplicate address %s", raw, addr)
|
|
}
|
|
seen[addr] = struct{}{}
|
|
addrs = append(addrs, addr)
|
|
}
|
|
return addrs, nil
|
|
}
|