Merge pull request #1380 from binwiederhier/ipv6

IPv6 support
This commit is contained in:
Philipp C. Heckel 2025-07-07 21:25:14 +02:00 committed by GitHub
commit 3c8ac4a1e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1038 additions and 552 deletions

View file

@ -5,13 +5,6 @@ package cmd
import (
"errors"
"fmt"
"github.com/stripe/stripe-go/v74"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/server"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"io/fs"
"math"
"net"
@ -22,6 +15,14 @@ import (
"strings"
"syscall"
"time"
"github.com/stripe/stripe-go/v74"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/server"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
func init() {
@ -79,6 +80,7 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-delay-limit", Aliases: []string{"message_delay_limit"}, EnvVars: []string{"NTFY_MESSAGE_DELAY_LIMIT"}, Value: util.FormatDuration(server.DefaultMessageDelayMax), Usage: "max duration a message can be scheduled into the future"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultVisitorAttachmentTotalSizeLimit), Usage: "total storage limit used for attachments per visitor"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
@ -87,10 +89,11 @@ var flagsServe = append(
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorEmailLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv4", Aliases: []string{"visitor_prefix_bits_ipv4"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV4"}, Value: server.DefaultVisitorPrefixBitsIPv4, Usage: "number of bits of the IPv4 address to use for rate limiting (default: 32, full address)"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv6", Aliases: []string{"visitor_prefix_bits_ipv6"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV6"}, Value: server.DefaultVisitorPrefixBitsIPv6, Usage: "number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-addresses", Aliases: []string{"proxy_trusted_addresses"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_ADDRESSES"}, Value: "", Usage: "comma-separated list of trusted IP addresses to remove from forwarded header"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-hosts", Aliases: []string{"proxy_trusted_hosts"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_HOSTS"}, Value: "", Usage: "comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "billing-contact", Aliases: []string{"billing_contact"}, EnvVars: []string{"NTFY_BILLING_CONTACT"}, Value: "", Usage: "e-mail or website to display in upgrade dialog (only if payments are enabled)"}),
@ -191,9 +194,11 @@ func execServe(c *cli.Context) error {
visitorMessageDailyLimit := c.Int("visitor-message-daily-limit")
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
visitorEmailLimitReplenishStr := c.String("visitor-email-limit-replenish")
visitorPrefixBitsIPv4 := c.Int("visitor-prefix-bits-ipv4")
visitorPrefixBitsIPv6 := c.Int("visitor-prefix-bits-ipv6")
behindProxy := c.Bool("behind-proxy")
proxyForwardedHeader := c.String("proxy-forwarded-header")
proxyTrustedAddresses := util.SplitNoEmpty(c.String("proxy-trusted-addresses"), ",")
proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",")
stripeSecretKey := c.String("stripe-secret-key")
stripeWebhookKey := c.String("stripe-webhook-key")
billingContact := c.String("billing-contact")
@ -324,6 +329,10 @@ func execServe(c *cli.Context) error {
return errors.New("web push expiry warning duration cannot be higher than web push expiry duration")
} else if behindProxy && proxyForwardedHeader == "" {
return errors.New("if behind-proxy is set, proxy-forwarded-header must also be set")
} else if visitorPrefixBitsIPv4 < 1 || visitorPrefixBitsIPv4 > 32 {
return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32")
} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {
return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 128")
}
// Backwards compatibility
@ -349,14 +358,24 @@ func execServe(c *cli.Context) error {
}
// Resolve hosts
visitorRequestLimitExemptIPs := make([]netip.Prefix, 0)
visitorRequestLimitExemptPrefixes := make([]netip.Prefix, 0)
for _, host := range visitorRequestLimitExemptHosts {
ips, err := parseIPHostPrefix(host)
prefixes, err := parseIPHostPrefix(host)
if err != nil {
log.Warn("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error())
continue
}
visitorRequestLimitExemptIPs = append(visitorRequestLimitExemptIPs, ips...)
visitorRequestLimitExemptPrefixes = append(visitorRequestLimitExemptPrefixes, prefixes...)
}
// Parse trusted prefixes
trustedProxyPrefixes := make([]netip.Prefix, 0)
for _, host := range proxyTrustedHosts {
prefixes, err := parseIPHostPrefix(host)
if err != nil {
return fmt.Errorf("cannot resolve trusted proxy host %s: %s", host, err.Error())
}
trustedProxyPrefixes = append(trustedProxyPrefixes, prefixes...)
}
// Stripe things
@ -412,18 +431,20 @@ func execServe(c *cli.Context) error {
conf.MessageDelayMax = messageDelayLimit
conf.TotalTopicLimit = totalTopicLimit
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
conf.VisitorAttachmentDailyBandwidthLimit = visitorAttachmentDailyBandwidthLimit
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
conf.VisitorRequestExemptIPAddrs = visitorRequestLimitExemptIPs
conf.VisitorRequestExemptPrefixes = visitorRequestLimitExemptPrefixes
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting
conf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4
conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6
conf.BehindProxy = behindProxy
conf.ProxyForwardedHeader = proxyForwardedHeader
conf.ProxyTrustedAddresses = proxyTrustedAddresses
conf.ProxyTrustedPrefixes = trustedProxyPrefixes
conf.StripeSecretKey = stripeSecretKey
conf.StripeWebhookKey = stripeWebhookKey
conf.BillingContact = billingContact
@ -433,7 +454,6 @@ func execServe(c *cli.Context) error {
conf.EnableMetrics = enableMetrics
conf.MetricsListenHTTP = metricsListenHTTP
conf.ProfileListenHTTP = profileListenHTTP
conf.Version = c.App.Version
conf.WebPushPrivateKey = webPushPrivateKey
conf.WebPushPublicKey = webPushPublicKey
conf.WebPushFile = webPushFile
@ -441,6 +461,7 @@ func execServe(c *cli.Context) error {
conf.WebPushStartupQueries = webPushStartupQueries
conf.WebPushExpiryDuration = webPushExpiryDuration
conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
conf.Version = c.App.Version
// Set up hot-reloading of config
go sigHandlerConfigReload(config)
@ -473,7 +494,7 @@ func sigHandlerConfigReload(config string) {
}
func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
// Try parsing as prefix, e.g. 10.0.1.0/24
// Try parsing as prefix, e.g. 10.0.1.0/24 or 2001:db8::/32
prefix, err := netip.ParsePrefix(host)
if err == nil {
prefixes = append(prefixes, prefix.Masked())

View file

@ -18,8 +18,8 @@ get a list of [command line options](#command-line-options).
## Example config
!!! info
Definitely check out the **[server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)** file.
It contains examples and detailed descriptions of all the settings.
Definitely check out the **[server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)** file. It contains examples and detailed descriptions of all the settings.
You may also want to look at how ntfy.sh is configured in the [ntfy-ansible](https://github.com/binwiederhier/ntfy-ansible) repository.
The most basic settings are `base-url` (the external URL of the ntfy server), the HTTP/HTTPS listen address (`listen-http`
and `listen-https`), and socket path (`listen-unix`). All the other things are additional features.
@ -557,7 +557,7 @@ If you are running ntfy behind a proxy, you should set the `behind-proxy` flag.
as the primary identifier for a visitor, as opposed to the remote IP address.
If the `behind-proxy` flag is not set, all visitors will be counted as one, because from the perspective of the
ntfy server, they all share the proxy's IP address.
ntfy server, they all share the proxy's IP address.
Relevant flags to consider:
@ -566,9 +566,17 @@ Relevant flags to consider:
* `proxy-forwarded-header` is the header to use to identify visitors (default: `X-Forwarded-For`). It may be a single IP address (e.g. `1.2.3.4`),
a comma-separated list of IP addresses (e.g. `1.2.3.4, 5.6.7.8`), or an [RFC 7239](https://datatracker.ietf.org/doc/html/rfc7239)-style
header (e.g. `for=1.2.3.4;by=proxy.example.com, for=5.6.7.8`).
* `proxy-trusted-addresses` is a comma-separated list of IP addresses that are removed from the forwarded header
* `proxy-trusted-hosts` is a comma-separated list of IP addresses, hosts or CIDRs that are removed from the forwarded header
to determine the real IP address. This is only useful if there are multiple proxies involved that add themselves to
the forwarded header (default: empty).
* `visitor-prefix-bits-ipv4` is the number of bits of the IPv4 address to use for rate limiting (default is `32`, which is the entire
IP address). In IPv4 environments, by default, a visitor's **full IPv4 address** is used as-is for rate limiting. This means that
if someone publishes messages from multiple IP addresses, they will be counted as separate visitors. You can adjust this by setting the `visitor-prefix-bits-ipv4` config option. To group visitors in a /24 subnet and count them as one, for instance,
set it to `24`. In that case, `1.2.3.4` and `1.2.3.99` are treated as the same visitor.
* `visitor-prefix-bits-ipv6` is the number of bits of the IPv6 address to use for rate limiting (default is `64`, which is a /64 subnet).
In IPv6 environments, by default, a visitor's IP address is **truncated to the /64 subnet**, meaning that `2001:db8:25:86:1::1` and
`2001:db8:25:86:2::1` are treated as the same visitor. Use the `visitor-prefix-bits-ipv6` config option to adjust this behavior.
See [IPv6 considerations](#ipv6-considerations) for more details.
=== "/etc/ntfy/server.yml (behind a proxy)"
``` yaml
@ -611,7 +619,21 @@ Relevant flags to consider:
# the visitor IP will be 9.9.9.9 (right-most unknown address).
#
behind-proxy: true
proxy-trusted-addresses: "1.2.3.4, 1.2.3.5"
proxy-trusted-hosts: "1.2.3.0/24, 1.2.2.2, 2001:db8::/64"
```
=== "/etc/ntfy/server.yml (adjusted IPv4/IPv6 prefixes proxies)"
``` yaml
# Tell ntfy to treat visitors as being in a /24 subnet (IPv4) or /48 subnet (IPv6)
# as one visitor, so that they are counted as one for rate limiting.
#
# Example 1: If 1.2.3.4 and 1.2.3.5 publish a message, the visitor 1.2.3.0 will have
# used 2 messages.
# Example 2: If 2001:db8:2500:1::1 and 2001:db8:2500:2::1 publish a message, the visitor
# 2001:db8:2500:: will have used 2 messages.
#
visitor-prefix-bits-ipv4: 24
visitor-prefix-bits-ipv6: 48
```
### TLS/SSL
@ -1136,6 +1158,18 @@ If this ever happens, there will be a log message that looks something like this
WARN Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor
```
### IPv6 considerations
By default, rate limiting for IPv6 is done using the `/64` subnet of the visitor's IPv6 address. This means that all visitors
in the same `/64` subnet are treated as one visitor. This is done to prevent abuse, as IPv6 subnet assignments are typically
much larger than IPv4 subnets (and much cheaper), and it is common for ISPs to assign large subnets to their customers.
Other than that, rate limiting for IPv6 is done the same way as for IPv4, using the visitor's IP address or subnet to identify them.
There are two options to configure the number of bits used for rate limiting (for IPv4 and IPv6):
- `visitor-prefix-bits-ipv4` is number of bits of the IPv4 address to use for rate limiting (default: 32, full address)
- `visitor-prefix-bits-ipv6` is number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)
### Subscriber-based rate limiting
By default, ntfy puts almost all rate limits on the message publisher, e.g. number of messages, requests, and attachment
size are all based on the visitor who publishes a message. **Subscriber-based rate limiting is a way to use the rate limits
@ -1300,6 +1334,25 @@ Note that if you run nginx in a container, append `, chain=DOCKER-USER` to the j
is `INPUT`, but `FORWARD` is used when using docker networks. `DOCKER-USER`, available when using docker, is part of the `FORWARD`
chain.
The official ntfy.sh server uses fail2ban to ban IPs. Check out ntfy.sh's [Ansible fail2ban role](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/fail2ban) for details. Ban actors are banned for 1 hour initially, and up to
4 hours at a time for repeated offenses. IPv4 addresses are banned individually, while IPv6 addresses are banned by their `/56` prefix.
## IPv6 support
ntfy fully supports IPv6, though there are a few things to keep in mind.
- **Listening on an IPv6 address**: By default, ntfy listens on `:80` (IPv4-only). If you want to listen on an IPv6 address, you need to
explicitly set the `listen-http` and/or `listen-https` options in your `server.yml` file to an IPv6 address, e.g. `[::]:80`. To listen on
IPv4 and IPv6, you must run ntfy behind a reverse proxy, e.g. `listen :80; listen [::]:80;` in nginx.
- **Rate limiting:** By default, ntfy uses the `/64` subnet of the visitor's IPv6 address for rate limiting. This means that all visitors in the same `/64`
subnet are treated as one visitor. If you want to change this, you can set the `visitor-prefix-bits-ipv6` option in your `server.yml` file to a different
value (e.g. `48` for `/48` subnets). See [IPv6 considerations](#ipv6-considerations) and [IP-based rate limiting](#ip-based-rate-limiting) for more details.
- **Banning IPs with fail2ban:** By default, if you're using the `iptables-multiport` action, fail2ban bans individual IPv4 and IPv6 addresses via `iptables` and `ip6tables`. While this behavior is fine for IPv4, it is not for IPv6, because every host can technically have up to 2^64 addresses. Please ensure that your `actionban` and `actionunban` commands
support IPv6 and also ban the entire prefix (e.g. `/48`). See [Banning bad actors](#banning-bad-actors-fail2ban) for details.
!!! info
The official ntfy.sh server supports IPv6. Check out ntfy.sh's [Ansible repository](https://github.com/binwiederhier/ntfy-ansible) for examples of how to
configure [ntfy](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/ntfy), [nginx](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/nginx) and [fail2ban](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/fail2ban).
## Health checks
A preliminary health check API endpoint is exposed at `/v1/health`. The endpoint returns a `json` response in the format shown below.
If a non-200 HTTP status code is returned or if the returned `healthy` field is `false` the ntfy service should be considered as unhealthy.
@ -1442,7 +1495,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. |
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) |
| `proxy-forwarded-header` | `NTFY_PROXY_FORWARDED_HEADER` | *string* | `X-Forwarded-For` | Use specified header to determine visitor IP address (for rate limiting) |
| `proxy-trusted-addresses` | `NTFY_PROXY_TRUSTED_ADDRESSES` | *comma-separated list of IPs* | - | Comma-separated list of trusted IP addresses to remove from forwarded header |
| `proxy-trusted-hosts` | `NTFY_PROXY_TRUSTED_HOSTS` | *comma-separated host/IP/CIDR list* | - | Comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header |
| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. |
| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. |
| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. |
@ -1472,9 +1525,11 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `visitor-message-daily-limit` | `NTFY_VISITOR_MESSAGE_DAILY_LIMIT` | *number* | - | Rate limiting: Allowed number of messages per day per visitor, reset every day at midnight (UTC). By default, this value is unset. |
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP/CIDR list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
| `visitor-subscriber-rate-limiting` | `NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING` | *bool* | `false` | Rate limiting: Enables subscriber-based rate limiting |
| `visitor-prefix-bits-ipv4` | `NTFY_VISITOR_PREFIX_BITS_IPV4` | *number* | 32 | Rate limiting: Number of bits to use for IPv4 visitor prefix, e.g. 24 for /24 |
| `visitor-prefix-bits-ipv6` | `NTFY_VISITOR_PREFIX_BITS_IPV6` | *number* | 64 | Rate limiting: Number of bits to use for IPv6 visitor prefix, e.g. 48 for /48 |
| `web-root` | `NTFY_WEB_ROOT` | *path*, e.g. `/` or `/app`, or `disable` | `/` | Sets root of the web app (e.g. /, or /app), or disables it entirely (disable) |
| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |
@ -1570,6 +1625,7 @@ OPTIONS:
--message-delay-limit value, --message_delay_limit value max duration a message can be scheduled into the future (default: "3d") [$NTFY_MESSAGE_DELAY_LIMIT]
--global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
--visitor-subscriber-rate-limiting, --visitor_subscriber_rate_limiting enables subscriber-based rate limiting (default: false) [$NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING]
--visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
--visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
--visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
@ -1578,8 +1634,11 @@ OPTIONS:
--visitor-message-daily-limit value, --visitor_message_daily_limit value max messages per visitor per day, derived from request limit if unset (default: 0) [$NTFY_VISITOR_MESSAGE_DAILY_LIMIT]
--visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: "1h") [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
--visitor-subscriber-rate-limiting, --visitor_subscriber_rate_limiting enables subscriber-based rate limiting (default: false) [$NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING]
--behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
--visitor-prefix-bits-ipv4 value, --visitor_prefix_bits_ipv4 value number of bits of the IPv4 address to use for rate limiting (default: 32, full address) (default: 32) [$NTFY_VISITOR_PREFIX_BITS_IPV4]
--visitor-prefix-bits-ipv6 value, --visitor_prefix_bits_ipv6 value number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet) (default: 64) [$NTFY_VISITOR_PREFIX_BITS_IPV6]
--behind-proxy, --behind_proxy, -P if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
--proxy-forwarded-header value, --proxy_forwarded_header value use specified header to determine visitor IP address (for rate limiting) (default: "X-Forwarded-For") [$NTFY_PROXY_FORWARDED_HEADER]
--proxy-trusted-hosts value, --proxy_trusted_hosts value comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header [$NTFY_PROXY_TRUSTED_HOSTS]
--stripe-secret-key value, --stripe_secret_key value key used for the Stripe API communication, this enables payments [$NTFY_STRIPE_SECRET_KEY]
--stripe-webhook-key value, --stripe_webhook_key value key required to validate the authenticity of incoming webhooks from Stripe [$NTFY_STRIPE_WEBHOOK_KEY]
--billing-contact value, --billing_contact value e-mail or website to display in upgrade dialog (only if payments are enabled) [$NTFY_BILLING_CONTACT]
@ -1593,5 +1652,5 @@ OPTIONS:
--web-push-startup-queries value, --web_push_startup_queries value queries run when the web push database is initialized [$NTFY_WEB_PUSH_STARTUP_QUERIES]
--web-push-expiry-duration value, --web_push_expiry_duration value automatically expire unused subscriptions after this time (default: "60d") [$NTFY_WEB_PUSH_EXPIRY_DURATION]
--web-push-expiry-warning-duration value, --web_push_expiry_warning_duration value send web push warning notification after this time before expiring unused subscriptions (default: "55d") [$NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION]
--help, -h show help
--help, -h
```

View file

@ -1437,7 +1437,14 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
**Features:**
* Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-addresses` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha))
* Full [IPv6 support](config.md#ipv6-support) for ntfy and the official ntfy.sh server ([#519](https://github.com/binwiederhier/ntfy/issues/519)/[#1380](https://github.com/binwiederhier/ntfy/pull/1380)/[ansible#4](https://github.com/binwiederhier/ntfy-ansible/pull/4))
* Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-hosts` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha))
* Add STDIN support for `ntfy publish` ([#1382](https://github.com/binwiederhier/ntfy/pull/1382), thanks to [@srevn](https://github.com/srevn))
**Languages**
* Update new languages from Weblate. Thanks to all the contributors!
* Added Estonian (Esti), Galician (Galego), Romanian (Română), Slovak (Slovenčina) as new languages to the web app
### ntfy Android app v1.16.1 (UNRELEASED)

58
go.mod
View file

@ -15,13 +15,13 @@ require (
github.com/mattn/go-sqlite3 v1.14.28
github.com/olebedev/when v1.1.0
github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v2 v2.27.6
golang.org/x/crypto v0.38.0
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.39.0
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.14.0
golang.org/x/sync v0.15.0
golang.org/x/term v0.32.0
golang.org/x/time v0.11.0
google.golang.org/api v0.235.0
golang.org/x/time v0.12.0
google.golang.org/api v0.240.0
gopkg.in/yaml.v2 v2.4.0
)
@ -30,7 +30,7 @@ replace github.com/emersion/go-smtp => github.com/emersion/go-smtp v0.17.0 // Pi
require github.com/pkg/errors v0.9.1 // indirect
require (
firebase.google.com/go/v4 v4.15.2
firebase.google.com/go/v4 v4.16.1
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/microcosm-cc/bluemonday v1.0.27
github.com/prometheus/client_golang v1.22.0
@ -39,17 +39,17 @@ require (
require (
cel.dev/expr v0.24.0 // indirect
cloud.google.com/go v0.121.2 // indirect
cloud.google.com/go/auth v0.16.1 // indirect
cloud.google.com/go v0.121.3 // indirect
cloud.google.com/go/auth v0.16.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.7.0 // indirect
cloud.google.com/go/iam v1.5.2 // indirect
cloud.google.com/go/longrunning v0.6.7 // indirect
cloud.google.com/go/monitoring v1.24.2 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.28.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.52.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.52.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@ -60,7 +60,7 @@ require (
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
@ -75,30 +75,30 @@ require (
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.64.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
github.com/zeebo/errs v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/sdk v1.36.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
golang.org/x/net v0.40.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.37.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/text v0.26.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20250528174236-200df99c418a // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect
google.golang.org/grpc v1.72.2 // indirect
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/grpc v1.73.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

122
go.sum
View file

@ -1,9 +1,9 @@
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
cloud.google.com/go v0.121.2 h1:v2qQpN6Dx9x2NmwrqlesOt3Ys4ol5/lFZ6Mg1B7OJCg=
cloud.google.com/go v0.121.2/go.mod h1:nRFlrHq39MNVWu+zESP2PosMWA0ryJw8KUBZ2iZpxbw=
cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU=
cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI=
cloud.google.com/go v0.121.3 h1:84RD+hQXNdY5Sw/MWVAx5O9Aui/rd5VQ9HEcdN19afo=
cloud.google.com/go v0.121.3/go.mod h1:6vWF3nJWRrEUv26mMB3FEIU/o1MQNVPG1iHdisa2SJc=
cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
@ -22,20 +22,20 @@ cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2zn
cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY=
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
firebase.google.com/go/v4 v4.15.2 h1:KJtV4rAfO2CVCp40hBfVk+mqUqg7+jQKx7yOgFDnXBg=
firebase.google.com/go/v4 v4.15.2/go.mod h1:qkD/HtSumrPMTLs0ahQrje5gTw2WKFKrzVFoqy4SbKA=
firebase.google.com/go/v4 v4.16.1 h1:Kl5cgXmM0VOWDGT1UAx6b0T2UFWa14ak0CvYqeI7Py4=
firebase.google.com/go/v4 v4.16.1/go.mod h1:aAPJq/bOyb23tBlc1K6GR+2E8sOGAeJSc8wIJVgl9SM=
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.28.0 h1:VaFXBL0NJpiFBtw4aVJpKHeKULVTcHpD+/G0ibZkcBw=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.28.0/go.mod h1:JXkPazkEc/dZTHzOlzv2vT1DlpWSTbSLmu/1KY6Ly0I=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.52.0 h1:QFgWzcdmJlgEAwJz/zePYVJQxfoJGRtgIqZfIUFg5oQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.52.0/go.mod h1:ayYHuYU7iNcNtEs1K9k6D/Bju7u1VEHMQm5qQ1n3GtM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.52.0 h1:0l8ynskVvq1dvIn5vJbFMf/a/3TqFpRmCMrruFbzlvk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.52.0/go.mod h1:f/ad5NuHnYz8AOZGuR0cY+l36oSCstdxD73YlIchr6I=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.52.0 h1:wbMd4eG/fOhsCa6+IP8uEDvWF5vl7rNoUWmP5f72Tbs=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.52.0/go.mod h1:gdIm9TxRk5soClCwuB0FtdXsbqtw0aqPwBEurK9tPkw=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
@ -70,8 +70,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@ -131,10 +131,10 @@ github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
@ -149,41 +149,43 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY=
github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=
github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/detectors/gcp v1.36.0 h1:F7q2tNlCaHY9nMKHR6XH9/qkp8FktLnIcy6jJNyOCQw=
go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/contrib/detectors/gcp v1.37.0 h1:B+WbN9RPsvobe6q4vP6KgM8/9plR/HNjgGBrfcOlweA=
go.opentelemetry.io/contrib/detectors/gcp v1.37.0/go.mod h1:K5zQ3TT7p2ru9Qkzk0bKtCql0RGkPj9pRjpXgZJZ+rU=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 h1:rbRJ8BBoVMsQShESYZ0FkvcITu8X8QNwJogcLUmDNNw=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@ -198,8 +200,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -209,8 +211,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -247,10 +249,10 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@ -259,18 +261,18 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.235.0 h1:C3MkpQSRxS1Jy6AkzTGKKrpSCOd2WOGrezZ+icKSkKo=
google.golang.org/api v0.235.0/go.mod h1:QpeJkemzkFKe5VCE/PMv7GsUfn9ZF+u+q1Q7w6ckxTg=
google.golang.org/api v0.240.0 h1:PxG3AA2UIqT1ofIzWV2COM3j3JagKTKSwy7L6RHNXNU=
google.golang.org/api v0.240.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/genproto v0.0.0-20250528174236-200df99c418a h1:KXuwdBmgjb4T3l4ZzXhP6HxxFKXD9FcK5/8qfJI4WwU=
google.golang.org/genproto v0.0.0-20250528174236-200df99c418a/go.mod h1:Nlk93rrS2X7rV8hiC2gh2A/AJspZhElz9Oh2KGsjLEY=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8=
google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=

View file

@ -61,6 +61,8 @@ const (
DefaultVisitorAuthFailureLimitReplenish = time.Minute
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
DefaultVisitorPrefixBitsIPv4 = 32 // Use the entire IPv4 address for rate limiting
DefaultVisitorPrefixBitsIPv6 = 64 // Use /64 for IPv6 rate limiting
)
var (
@ -133,7 +135,7 @@ type Config struct {
VisitorAttachmentDailyBandwidthLimit int64
VisitorRequestLimitBurst int
VisitorRequestLimitReplenish time.Duration
VisitorRequestExemptIPAddrs []netip.Prefix
VisitorRequestExemptPrefixes []netip.Prefix
VisitorMessageDailyLimit int
VisitorEmailLimitBurst int
VisitorEmailLimitReplenish time.Duration
@ -141,11 +143,13 @@ type Config struct {
VisitorAccountCreationLimitReplenish time.Duration
VisitorAuthFailureLimitBurst int
VisitorAuthFailureLimitReplenish time.Duration
VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats
VisitorSubscriberRateLimiting bool // Enable subscriber-based rate limiting for UnifiedPush topics
BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address
ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For"
ProxyTrustedAddresses []string // List of trusted proxy addresses that will be stripped from the Forwarded header if BehindProxy is true
VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats
VisitorSubscriberRateLimiting bool // Enable subscriber-based rate limiting for UnifiedPush topics
VisitorPrefixBitsIPv4 int // Number of bits for IPv4 rate limiting (default: 32)
VisitorPrefixBitsIPv6 int // Number of bits for IPv6 rate limiting (default: 64)
BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address (IPv4 and IPv6 supported)
ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For" (IPv4 and IPv6 supported)
ProxyTrustedPrefixes []netip.Prefix // List of trusted proxy networks (IPv4 or IPv6) that will be stripped from the Forwarded header if BehindProxy is true
StripeSecretKey string
StripeWebhookKey string
StripePriceCacheDuration time.Duration
@ -155,7 +159,6 @@ type Config struct {
EnableReservations bool // Allow users with role "user" to own/reserve topics
EnableMetrics bool
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
Version string // injected by App
WebPushPrivateKey string
WebPushPublicKey string
WebPushFile string
@ -163,6 +166,7 @@ type Config struct {
WebPushStartupQueries string
WebPushExpiryDuration time.Duration
WebPushExpiryWarningDuration time.Duration
Version string // injected by App
}
// NewConfig instantiates a default new server config
@ -220,11 +224,12 @@ func NewConfig() *Config {
TotalTopicLimit: DefaultTotalTopicLimit,
TotalAttachmentSizeLimit: 0,
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
VisitorSubscriberRateLimiting: false,
VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit,
VisitorAttachmentDailyBandwidthLimit: DefaultVisitorAttachmentDailyBandwidthLimit,
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
VisitorRequestExemptIPAddrs: make([]netip.Prefix, 0),
VisitorRequestExemptPrefixes: make([]netip.Prefix, 0),
VisitorMessageDailyLimit: DefaultVisitorMessageDailyLimit,
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
@ -233,9 +238,10 @@ func NewConfig() *Config {
VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst,
VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish,
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
VisitorSubscriberRateLimiting: false,
BehindProxy: false, // If true, the server will trust the proxy client IP header to determine the client IP address
ProxyForwardedHeader: "X-Forwarded-For", // Default header for reverse proxy client IPs
VisitorPrefixBitsIPv4: DefaultVisitorPrefixBitsIPv4, // Default: use full IPv4 address
VisitorPrefixBitsIPv6: DefaultVisitorPrefixBitsIPv6, // Default: use /64 for IPv6
BehindProxy: false, // If true, the server will trust the proxy client IP header to determine the client IP address
ProxyForwardedHeader: "X-Forwarded-For", // Default header for reverse proxy client IPs
StripeSecretKey: "",
StripeWebhookKey: "",
StripePriceCacheDuration: DefaultStripePriceCacheDuration,

View file

@ -760,7 +760,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
// the subscription as invalid if any 400-499 code (except 429/408) is returned.
// See https://github.com/mastodon/mastodon/blob/730bb3e211a84a2f30e3e2bbeae3f77149824a68/app/workers/web/push_notification_worker.rb#L35-L46
return nil, errHTTPInsufficientStorageUnifiedPush.With(t)
} else if !util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) && !vrate.MessageAllowed() {
} else if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() {
return nil, errHTTPTooManyRequestsLimitMessages.With(t)
} else if email != "" && !vrate.EmailAllowed() {
return nil, errHTTPTooManyRequestsLimitEmails.With(t)
@ -1937,7 +1937,7 @@ func (s *Server) authorizeTopic(next handleFunc, perm user.Permission) handleFun
// that subsequent logging calls still have a visitor context.
func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) {
// Read the "Authorization" header value and exit out early if it's not set
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedAddresses)
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)
vip := s.visitor(ip, nil)
if s.userManager == nil {
return vip, nil
@ -2012,7 +2012,7 @@ func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.Us
if err != nil {
return nil, err
}
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedAddresses)
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)
go s.userManager.EnqueueTokenUpdate(token, &user.TokenUpdate{
LastAccess: time.Now(),
LastOrigin: ip,
@ -2023,7 +2023,7 @@ func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.Us
func (s *Server) visitor(ip netip.Addr, user *user.User) *visitor {
s.mu.Lock()
defer s.mu.Unlock()
id := visitorID(ip, user)
id := visitorID(ip, user, s.config)
v, exists := s.visitors[id]
if !exists {
s.visitors[id] = newVisitor(s.config, s.messageCache, s.userManager, ip, user)

View file

@ -105,13 +105,13 @@
# proxy-forwarded-header. Without this, the remote address of the incoming connection is used.
# - proxy-forwarded-header is the header to use to identify visitors. It may be a single IP address (e.g. 1.2.3.4),
# a comma-separated list of IP addresses (e.g. "1.2.3.4, 5.6.7.8"), or an RFC 7239-style header (e.g. "for=1.2.3.4;by=proxy.example.com, for=5.6.7.8").
# - proxy-trusted-addresses is a comma-separated list of IP addresses that are removed from the forwarded header
# - proxy-trusted-hosts is a comma-separated list of IP addresses, hostnames or CIDRs that are removed from the forwarded header
# to determine the real IP address. This is only useful if there are multiple proxies involved that add themselves to
# the forwarded header.
#
# behind-proxy: false
# proxy-forwarded-header: "X-Forwarded-For"
# proxy-trusted-addresses:
# proxy-trusted-hosts:
# If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments
# are "attachment-cache-dir" and "base-url".
@ -292,6 +292,18 @@
# visitor-email-limit-burst: 16
# visitor-email-limit-replenish: "1h"
# Rate limiting: IPv4/IPv6 address prefix bits used for rate limiting
# - visitor-prefix-bits-ipv4: number of bits of the IPv4 address to use for rate limiting (default: 32, full address)
# - visitor-prefix-bits-ipv6: number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)
#
# This is used to group visitors by their IP address or subnet. For example, if you set visitor-prefix-bits-ipv4 to 24,
# all visitors in the 1.2.3.0/24 network are treated as one.
#
# By default, ntfy uses the full IPv4 address (32 bits) and the /64 subnet of the IPv6 address (64 bits).
#
# visitor-prefix-bits-ipv4: 32
# visitor-prefix-bits-ipv6: 64
# Rate limiting: Attachment size and bandwidth limits per visitor:
# - visitor-attachment-total-size-limit is the total storage limit used for attachments per visitor
# - visitor-attachment-daily-bandwidth-limit is the total daily attachment download/upload traffic limit per visitor

View file

@ -16,7 +16,7 @@ const (
func (s *Server) limitRequests(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) {
if util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) {
return next(w, r, v)
} else if !v.RequestAllowed() {
return errHTTPTooManyRequestsLimitRequests
@ -40,7 +40,7 @@ func (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc {
contextRateVisitor: vrate,
contextTopic: t,
})
if util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) {
if util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) {
return next(w, r, v)
} else if !vrate.RequestAllowed() {
return errHTTPTooManyRequestsLimitRequests

View file

@ -1169,7 +1169,7 @@ func (t *testMailer) Count() int {
return t.count
}
func TestServer_PublishTooRequests_Defaults(t *testing.T) {
func TestServer_PublishTooManyRequests_Defaults(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
for i := 0; i < 60; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
@ -1179,10 +1179,53 @@ func TestServer_PublishTooRequests_Defaults(t *testing.T) {
require.Equal(t, 429, response.Code)
}
func TestServer_PublishTooRequests_Defaults_ExemptHosts(t *testing.T) {
func TestServer_PublishTooManyRequests_Defaults_IPv6(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
overrideRemoteAddr1 := func(r *http.Request) {
r.RemoteAddr = "[2001:db8:9999:8888:1::1]:1234"
}
overrideRemoteAddr2 := func(r *http.Request) {
r.RemoteAddr = "[2001:db8:9999:8888:2::1]:1234" // Same /64
}
for i := 0; i < 30; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr1)
require.Equal(t, 200, response.Code)
}
for i := 0; i < 30; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr2)
require.Equal(t, 200, response.Code)
}
response := request(t, s, "PUT", "/mytopic", "message", nil, overrideRemoteAddr1)
require.Equal(t, 429, response.Code)
}
func TestServer_PublishTooManyRequests_IPv6_Slash48(t *testing.T) {
c := newTestConfig(t)
c.VisitorRequestLimitBurst = 6
c.VisitorPrefixBitsIPv6 = 48 // Use /48 for IPv6 prefixes
s := newTestServer(t, c)
overrideRemoteAddr1 := func(r *http.Request) {
r.RemoteAddr = "[2001:db8:9999::1]:1234"
}
overrideRemoteAddr2 := func(r *http.Request) {
r.RemoteAddr = "[2001:db8:9999::2]:1234" // Same /48
}
for i := 0; i < 3; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr1)
require.Equal(t, 200, response.Code)
}
for i := 0; i < 3; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr2)
require.Equal(t, 200, response.Code)
}
response := request(t, s, "PUT", "/mytopic", "message", nil, overrideRemoteAddr1)
require.Equal(t, 429, response.Code)
}
func TestServer_PublishTooManyRequests_Defaults_ExemptHosts(t *testing.T) {
c := newTestConfig(t)
c.VisitorRequestLimitBurst = 3
c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
s := newTestServer(t, c)
for i := 0; i < 5; i++ { // > 3
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
@ -1190,11 +1233,25 @@ func TestServer_PublishTooRequests_Defaults_ExemptHosts(t *testing.T) {
}
}
func TestServer_PublishTooRequests_Defaults_ExemptHosts_MessageDailyLimit(t *testing.T) {
func TestServer_PublishTooManyRequests_Defaults_ExemptHosts_IPv6(t *testing.T) {
c := newTestConfig(t)
c.VisitorRequestLimitBurst = 3
c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("2001:db8:9999::/48")}
s := newTestServer(t, c)
overrideRemoteAddr := func(r *http.Request) {
r.RemoteAddr = "[2001:db8:9999::1]:1234"
}
for i := 0; i < 5; i++ { // > 3
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr)
require.Equal(t, 200, response.Code)
}
}
func TestServer_PublishTooManyRequests_Defaults_ExemptHosts_MessageDailyLimit(t *testing.T) {
c := newTestConfig(t)
c.VisitorRequestLimitBurst = 10
c.VisitorMessageDailyLimit = 4
c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
s := newTestServer(t, c)
for i := 0; i < 8; i++ { // 4
response := request(t, s, "PUT", "/mytopic", "message", nil)
@ -1202,7 +1259,7 @@ func TestServer_PublishTooRequests_Defaults_ExemptHosts_MessageDailyLimit(t *tes
}
}
func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) {
func TestServer_PublishTooManyRequests_ShortReplenish(t *testing.T) {
t.Parallel()
c := newTestConfig(t)
c.VisitorRequestLimitBurst = 60
@ -2244,11 +2301,24 @@ func TestServer_Visitor_Custom_ClientIP_Header(t *testing.T) {
require.Equal(t, "1.2.3.4", v.ip.String())
}
func TestServer_Visitor_Custom_ClientIP_Header_IPv6(t *testing.T) {
c := newTestConfig(t)
c.BehindProxy = true
c.ProxyForwardedHeader = "X-Client-IP"
s := newTestServer(t, c)
r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "[2001:db8:9999::1]:1234"
r.Header.Set("X-Client-IP", "2001:db8:7777::1")
v, err := s.maybeAuthenticate(r)
require.Nil(t, err)
require.Equal(t, "2001:db8:7777::1", v.ip.String())
}
func TestServer_Visitor_Custom_Forwarded_Header(t *testing.T) {
c := newTestConfig(t)
c.BehindProxy = true
c.ProxyForwardedHeader = "Forwarded"
c.ProxyTrustedAddresses = []string{"1.2.3.4"}
c.ProxyTrustedPrefixes = []netip.Prefix{netip.MustParsePrefix("1.2.3.0/24")}
s := newTestServer(t, c)
r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11:1234"
@ -2258,6 +2328,20 @@ func TestServer_Visitor_Custom_Forwarded_Header(t *testing.T) {
require.Equal(t, "5.6.7.8", v.ip.String())
}
func TestServer_Visitor_Custom_Forwarded_Header_IPv6(t *testing.T) {
c := newTestConfig(t)
c.BehindProxy = true
c.ProxyForwardedHeader = "Forwarded"
c.ProxyTrustedPrefixes = []netip.Prefix{netip.MustParsePrefix("2001:db8:1111::/64")}
s := newTestServer(t, c)
r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "[2001:db8:2222::1]:1234"
r.Header.Set("Forwarded", " for=[2001:db8:1111::1], by=example.com;for=[2001:db8:3333::1]")
v, err := s.maybeAuthenticate(r)
require.Nil(t, err)
require.Equal(t, "2001:db8:3333::1", v.ip.String())
}
func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
t.Parallel()
count := 50000

View file

@ -5,8 +5,6 @@ import (
"encoding/base64"
"errors"
"fmt"
"github.com/emersion/go-smtp"
"github.com/microcosm-cc/bluemonday"
"io"
"mime"
"mime/multipart"
@ -18,6 +16,9 @@ import (
"regexp"
"strings"
"sync"
"github.com/emersion/go-smtp"
"github.com/microcosm-cc/bluemonday"
)
var (
@ -191,9 +192,9 @@ func (s *smtpSession) publishMessage(m *message) error {
// Call HTTP handler with fake HTTP request
url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic)
req, err := http.NewRequest("POST", url, strings.NewReader(m.Message))
req.RequestURI = "/" + m.Topic // just for the logs
req.RemoteAddr = remoteAddr // rate limiting!!
req.Header.Set("X-Forwarded-For", remoteAddr)
req.RequestURI = "/" + m.Topic // just for the logs
req.RemoteAddr = remoteAddr // rate limiting!!
req.Header.Set(s.backend.config.ProxyForwardedHeader, remoteAddr) // Set X-Forwarded-For header
if err != nil {
return err
}

View file

@ -4,14 +4,14 @@ import (
"context"
"errors"
"fmt"
"heckel.io/ntfy/v2/util"
"io"
"mime"
"net/http"
"net/netip"
"regexp"
"slices"
"strings"
"heckel.io/ntfy/v2/util"
)
var (
@ -20,8 +20,14 @@ var (
// priorityHeaderIgnoreRegex matches specific patterns of the "Priority" header (RFC 9218), so that it can be ignored
priorityHeaderIgnoreRegex = regexp.MustCompile(`^u=\d,\s*(i|\d)$|^u=\d$`)
// forwardedHeaderRegex parses IPv4 addresses from the "Forwarded" header (RFC 7239)
forwardedHeaderRegex = regexp.MustCompile(`(?i)\bfor="?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"?`)
// forwardedHeaderRegex parses IPv4 and IPv6 addresses from the "Forwarded" header (RFC 7239)
// IPv6 addresses in Forwarded header are enclosed in square brackets. The port is optional.
//
// Examples:
// for="1.2.3.4"
// for="[2001:db8::1]"; for=1.2.3.4:8080, by=phil
// for="1.2.3.4:8080"
forwardedHeaderRegex = regexp.MustCompile(`(?i)\bfor="?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[[0-9a-f:]+])(?::\d+)?"?`)
)
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
@ -77,9 +83,9 @@ func readQueryParam(r *http.Request, names ...string) string {
// extractIPAddress extracts the IP address of the visitor from the request,
// either from the TCP socket or from a proxy header.
func extractIPAddress(r *http.Request, behindProxy bool, proxyForwardedHeader string, proxyTrustedAddresses []string) netip.Addr {
func extractIPAddress(r *http.Request, behindProxy bool, proxyForwardedHeader string, proxyTrustedPrefixes []netip.Prefix) netip.Addr {
if behindProxy && proxyForwardedHeader != "" {
if addr, err := extractIPAddressFromHeader(r, proxyForwardedHeader, proxyTrustedAddresses); err == nil {
if addr, err := extractIPAddressFromHeader(r, proxyForwardedHeader, proxyTrustedPrefixes); err == nil {
return addr
}
// Fall back to the remote address if the header is not found or invalid
@ -102,7 +108,7 @@ func extractIPAddress(r *http.Request, behindProxy bool, proxyForwardedHeader st
// If there are multiple addresses, we first remove the trusted IP addresses from the list, and
// then take the right-most address in the list (as this is the one added by our proxy server).
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For for details.
func extractIPAddressFromHeader(r *http.Request, forwardedHeader string, trustedAddresses []string) (netip.Addr, error) {
func extractIPAddressFromHeader(r *http.Request, forwardedHeader string, trustedPrefixes []netip.Prefix) (netip.Addr, error) {
value := strings.TrimSpace(strings.ToLower(r.Header.Get(forwardedHeader)))
if value == "" {
return netip.IPv4Unspecified(), fmt.Errorf("no %s header found", forwardedHeader)
@ -111,17 +117,27 @@ func extractIPAddressFromHeader(r *http.Request, forwardedHeader string, trusted
addrsStrs := util.Map(util.SplitNoEmpty(value, ","), strings.TrimSpace)
var validAddrs []netip.Addr
for _, addrStr := range addrsStrs {
if addr, err := netip.ParseAddr(addrStr); err == nil {
validAddrs = append(validAddrs, addr)
} else if m := forwardedHeaderRegex.FindStringSubmatch(addrStr); len(m) == 2 {
if addr, err := netip.ParseAddr(m[1]); err == nil {
// Handle Forwarded header with for="[IPv6]" or for="IPv4"
if m := forwardedHeaderRegex.FindStringSubmatch(addrStr); len(m) == 2 {
addrRaw := m[1]
if strings.HasPrefix(addrRaw, "[") && strings.HasSuffix(addrRaw, "]") {
addrRaw = addrRaw[1 : len(addrRaw)-1]
}
if addr, err := netip.ParseAddr(addrRaw); err == nil {
validAddrs = append(validAddrs, addr)
}
} else if addr, err := netip.ParseAddr(addrStr); err == nil {
validAddrs = append(validAddrs, addr)
}
}
// Filter out proxy addresses
clientAddrs := util.Filter(validAddrs, func(addr netip.Addr) bool {
return !slices.Contains(trustedAddresses, addr.String())
for _, prefix := range trustedPrefixes {
if prefix.Contains(addr) {
return false // Address is in the trusted range, ignore it
}
}
return true
})
if len(clientAddrs) == 0 {
return netip.IPv4Unspecified(), fmt.Errorf("no client IP address found in %s header: %s", forwardedHeader, value)

View file

@ -4,10 +4,13 @@ import (
"bytes"
"crypto/rand"
"fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/user"
"net/http"
"net/netip"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestReadBoolParam(t *testing.T) {
@ -97,7 +100,7 @@ func TestExtractIPAddress(t *testing.T) {
r.Header.Set("X-Real-IP", "13.14.15.16, 1.1.1.1")
r.Header.Set("Forwarded", "for=17.18.19.20;by=proxy.example.com, by=2.2.2.2;for=1.1.1.1")
trustedProxies := []string{"1.1.1.1"}
trustedProxies := []netip.Prefix{netip.MustParsePrefix("1.1.1.1/32")}
require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
require.Equal(t, "9.10.11.12", extractIPAddress(r, true, "X-Client-IP", trustedProxies).String())
@ -112,9 +115,50 @@ func TestExtractIPAddress_UnixSocket(t *testing.T) {
r.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8, 1.1.1.1")
r.Header.Set("Forwarded", "by=bla.example.com;for=17.18.19.20")
trustedProxies := []string{"1.1.1.1"}
trustedProxies := []netip.Prefix{netip.MustParsePrefix("1.1.1.1/32")}
require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
require.Equal(t, "17.18.19.20", extractIPAddress(r, true, "Forwarded", trustedProxies).String())
require.Equal(t, "0.0.0.0", extractIPAddress(r, false, "X-Forwarded-For", trustedProxies).String())
}
func TestExtractIPAddress_MixedIPv4IPv6(t *testing.T) {
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil)
r.RemoteAddr = "[2001:db8:abcd::1]:1234"
r.Header.Set("X-Forwarded-For", "1.2.3.4, 2001:db8:abcd::2, 5.6.7.8")
trustedProxies := []netip.Prefix{netip.MustParsePrefix("1.2.3.0/24")}
require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
}
func TestExtractIPAddress_TrustedIPv6Prefix(t *testing.T) {
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil)
r.RemoteAddr = "[2001:db8:abcd::1]:1234"
r.Header.Set("X-Forwarded-For", "2001:db8:aaaa::1, 2001:db8:aaaa::2, 2001:db8:abcd:2::3")
trustedProxies := []netip.Prefix{netip.MustParsePrefix("2001:db8:aaaa::/48")}
require.Equal(t, "2001:db8:abcd:2::3", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
}
func TestVisitorID(t *testing.T) {
confWithDefaults := &Config{
VisitorPrefixBitsIPv4: 32,
VisitorPrefixBitsIPv6: 64,
}
confWithShortenedPrefixes := &Config{
VisitorPrefixBitsIPv4: 16,
VisitorPrefixBitsIPv6: 56,
}
userWithTier := &user.User{
ID: "u_123",
Tier: &user.Tier{},
}
require.Equal(t, "ip:1.2.3.4", visitorID(netip.MustParseAddr("1.2.3.4"), nil, confWithDefaults))
require.Equal(t, "ip:2a01:599:b26:2397::", visitorID(netip.MustParseAddr("2a01:599:b26:2397:dbe7:5aa2:95ce:1e83"), nil, confWithDefaults))
require.Equal(t, "ip:2001:db8:25:86::", visitorID(netip.MustParseAddr("2001:db8:25:86:1::1"), nil, confWithDefaults))
require.Equal(t, "ip:2001:db8:25:86::", visitorID(netip.MustParseAddr("2001:db8:25:86:2::1"), nil, confWithDefaults))
require.Equal(t, "user:u_123", visitorID(netip.MustParseAddr("1.2.3.4"), userWithTier, confWithDefaults))
require.Equal(t, "user:u_123", visitorID(netip.MustParseAddr("2a01:599:b26:2397:dbe7:5aa2:95ce:1e83"), userWithTier, confWithDefaults))
require.Equal(t, "ip:1.2.0.0", visitorID(netip.MustParseAddr("1.2.3.4"), nil, confWithShortenedPrefixes))
require.Equal(t, "ip:2a01:599:b26:2300::", visitorID(netip.MustParseAddr("2a01:599:b26:2397:dbe7:5aa2:95ce:1e83"), nil, confWithShortenedPrefixes))
}

View file

@ -2,13 +2,13 @@ package server
import (
"fmt"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
"net/netip"
"sync"
"time"
"golang.org/x/time/rate"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
@ -151,7 +151,7 @@ func (v *visitor) Context() log.Context {
func (v *visitor) contextNoLock() log.Context {
info := v.infoLightNoLock()
fields := log.Context{
"visitor_id": visitorID(v.ip, v.user),
"visitor_id": visitorID(v.ip, v.user, v.config),
"visitor_ip": v.ip.String(),
"visitor_seen": util.FormatTime(v.seen),
"visitor_messages": info.Stats.Messages,
@ -524,9 +524,15 @@ func dailyLimitToRate(limit int64) rate.Limit {
return rate.Limit(limit) * rate.Every(oneDay)
}
func visitorID(ip netip.Addr, u *user.User) string {
// visitorID returns a unique identifier for a visitor based on user or IP, using configurable prefix bits for IPv4/IPv6
func visitorID(ip netip.Addr, u *user.User, conf *Config) string {
if u != nil && u.Tier != nil {
return fmt.Sprintf("user:%s", u.ID)
}
if ip.Is4() {
ip = netip.PrefixFrom(ip, conf.VisitorPrefixBitsIPv4).Masked().Addr()
} else if ip.Is6() {
ip = netip.PrefixFrom(ip, conf.VisitorPrefixBitsIPv6).Masked().Addr()
}
return fmt.Sprintf("ip:%s", ip.String())
}

716
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,7 @@
{
"nav_button_documentation": "Documentació",
"action_bar_profile_title": "Perfil",
"action_bar_settings": "Configuració",
"action_bar_account": "Compte",
"common_add": "Afegir"
}

View file

@ -31,12 +31,12 @@
"notifications_attachment_open_title": "Gehe zu {{url}}",
"notifications_none_for_any_title": "Du hast keine Benachrichtigungen empfangen.",
"action_bar_send_test_notification": "Test-Benachrichtigung senden",
"alert_notification_permission_required_description": "Dem Browser erlauben, Desktop-Benachrichtigungen anzuzeigen.",
"alert_notification_permission_required_description": "Browser erlauben, Desktop-Benachrichtigungen anzuzeigen",
"notifications_tags": "Tags",
"message_bar_type_message": "Gib hier eine Nachricht ein",
"message_bar_error_publishing": "Fehler beim Senden der Benachrichtigung",
"alert_not_supported_title": "Benachrichtigungen werden nicht unterstützt",
"alert_not_supported_description": "Benachrichtigungen werden von Deinem Browser nicht unterstützt",
"alert_not_supported_description": "Benachrichtigungen werden von deinem Browser nicht unterstützt",
"action_bar_settings": "Einstellungen",
"action_bar_clear_notifications": "Alle Benachrichtigungen löschen",
"alert_notification_permission_required_button": "Jetzt erlauben",
@ -208,11 +208,11 @@
"action_bar_change_display_name": "Anzeigenamen ändern",
"action_bar_reservation_add": "Thema reservieren",
"action_bar_reservation_edit": "Reservierung ändern",
"action_bar_reservation_delete": "Reservierung löschen",
"action_bar_reservation_delete": "Reservierung entfernen",
"action_bar_reservation_limit_reached": "Grenze erreicht",
"action_bar_profile_title": "Profil",
"action_bar_profile_settings": "Einstellungen",
"action_bar_profile_logout": "Abmelden",
"action_bar_profile_logout": "Ausloggen",
"action_bar_sign_in": "Anmelden",
"signup_form_password": "Kennwort",
"signup_form_toggle_password_visibility": "Kennwort-Sichtbarkeit umschalten",
@ -382,7 +382,7 @@
"account_usage_calls_none": "Noch keine Anrufe mit diesem Account getätigt",
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} Telefonanrufe pro Tag",
"action_bar_mute_notifications": "Benachrichtigungen stummschalten",
"action_bar_unmute_notifications": "Benachrichtigungen laut schalten",
"action_bar_unmute_notifications": "Stummschaltung von Benachrichtigungen aufheben",
"alert_notification_permission_denied_title": "Benachrichtigungen sind blockiert",
"alert_notification_permission_denied_description": "Bitte reaktiviere diese in deinem Browser",
"notifications_actions_failed_notification": "Aktion nicht erfolgreich",
@ -390,8 +390,8 @@
"alert_notification_ios_install_required_description": "Klicke auf das Teilen-Symbol und “Zum Home-Bildschirm” um auf iOS Benachrichtigungen zu aktivieren",
"subscribe_dialog_subscribe_use_another_background_info": "Benachrichtigungen von anderen Servern werden nicht empfangen, wenn die Web App nicht geöffnet ist",
"publish_dialog_checkbox_markdown": "Als Markdown formatieren",
"prefs_notifications_web_push_title": "Hintergrund-Benachrichtigungen",
"prefs_notifications_web_push_disabled_description": "Benachrichtigungen werden empfangen wenn die Web App läuft (über WebSocket)",
"prefs_notifications_web_push_title": "Hintergrundbenachrichtigungen",
"prefs_notifications_web_push_disabled_description": "Benachrichtigungen werden empfangen, wenn die Web App geöffnet ist (via WebSocket)",
"prefs_notifications_web_push_enabled": "Aktiviert für {{server}}",
"prefs_notifications_web_push_disabled": "Deaktiviert",
"prefs_appearance_theme_title": "Thema",
@ -401,7 +401,7 @@
"error_boundary_button_reload_ntfy": "ntfy neu laden",
"web_push_subscription_expiring_title": "Benachrichtigungen werden pausiert",
"web_push_subscription_expiring_body": "Öffne ntfy um weiterhin Benachrichtigungen zu erhalten",
"web_push_unknown_notification_title": "Unbekannte Benachrichtigung vom server empfangen",
"web_push_unknown_notification_body": "Du musst möglicherweise ntfy aktualisieren, indem du die Web App öffnest.",
"prefs_notifications_web_push_enabled_description": "Benachrichtigungen werden empfangen, auch wenn die Web App nicht läuft (über Web Push)"
"web_push_unknown_notification_title": "Unbekannte Benachrichtigung vom Server empfangen",
"web_push_unknown_notification_body": "Du musst möglicherweise ntfy aktualisieren, indem du die Web App öffnest",
"prefs_notifications_web_push_enabled_description": "Benachrichtigungen werden empfangen, auch wenn die Web App nicht geöffnet ist (via Web Push)"
}

View file

@ -99,5 +99,176 @@
"account_tokens_table_create_token_button": "Loo ligipääsuks vajalik tunnusluba",
"account_tokens_dialog_title_create": "Loo ligipääsuks vajalik tunnusluba",
"account_tokens_dialog_title_edit": "Muuda ligipääsuks vajalikku tunnusluba",
"account_tokens_dialog_title_delete": "Kustuta ligipääsuks vajalik tunnusluba"
"account_tokens_dialog_title_delete": "Kustuta ligipääsuks vajalik tunnusluba",
"subscribe_dialog_login_password_label": "Salasõna",
"publish_dialog_filename_label": "Failinimi",
"prefs_reservations_table_access_header": "Ligipääs",
"publish_dialog_chip_click_label": "Klõpsi võrguaadressi",
"subscribe_dialog_subscribe_button_cancel": "Katkesta",
"publish_dialog_delay_label": "Viivitus",
"account_basics_password_title": "Salasõna",
"account_upgrade_dialog_button_cancel": "Katkesta",
"notifications_example": "Näide",
"account_usage_title": "Kasutus",
"account_basics_title": "Kasutajakonto",
"prefs_reservations_table_topic_header": "Teema",
"account_delete_dialog_button_cancel": "Katkesta",
"account_delete_dialog_label": "Salasõna",
"publish_dialog_message_label": "Sõnum",
"account_basics_phone_numbers_dialog_channel_call": "Kõne",
"prefs_users_dialog_password_label": "Salasõna",
"subscribe_dialog_subscribe_button_subscribe": "Telli",
"publish_dialog_priority_label": "Prioriteet",
"subscribe_dialog_login_button_login": "Logi sisse",
"subscribe_dialog_error_user_anonymous": "anonüümne",
"prefs_appearance_theme_title": "Kujundus",
"publish_dialog_button_cancel": "Katkesta",
"account_usage_unlimited": "Piiramatu",
"prefs_notifications_delete_after_never": "Mitte kunagi",
"account_upgrade_dialog_interval_monthly": "Iga kuu",
"account_upgrade_dialog_tier_price_per_month": "kuu",
"prefs_notifications_web_push_disabled": "Pole kasutusel",
"prefs_appearance_title": "Välimus",
"prefs_appearance_language_title": "Keel",
"prefs_reservations_dialog_topic_label": "Teema",
"publish_dialog_priority_min": "Väikseim tähtsus",
"notifications_actions_failed_notification": "Ebaõnnestunud toiming",
"publish_dialog_title_label": "Pealkiri",
"publish_dialog_tags_label": "Sildid",
"publish_dialog_email_label": "E-post",
"display_name_dialog_placeholder": "Kuvatav nimi",
"publish_dialog_title_no_topic": "Avalda teavitus",
"publish_dialog_progress_uploading": "Laadin üles…",
"publish_dialog_message_published": "Teavitus on saadetud",
"publish_dialog_emoji_picker_show": "Vali emoji",
"publish_dialog_priority_low": "Vähetähtis",
"publish_dialog_priority_default": "Vaikimisi tähtsus",
"publish_dialog_priority_high": "Oluline",
"publish_dialog_priority_max": "Väga oluline",
"publish_dialog_base_url_label": "Teenuse võrguaadress",
"publish_dialog_topic_label": "Teema nimi",
"publish_dialog_topic_reset": "Lähtesta teema",
"publish_dialog_click_label": "Klõpsi võrguaadressi",
"publish_dialog_call_label": "Telefonikõne",
"publish_dialog_button_send": "Saada",
"publish_dialog_attach_label": "Manuse võrguaadress",
"publish_dialog_filename_placeholder": "Manuse failinimi",
"publish_dialog_other_features": "Lisavõimalused:",
"publish_dialog_chip_call_label": "Telefonikõne",
"publish_dialog_chip_delay_label": "Viivita saatmisega",
"publish_dialog_chip_topic_label": "Muuda teemat",
"publish_dialog_button_cancel_sending": "Katkesta saatmine",
"account_basics_username_title": "Kasutajanimi",
"account_basics_phone_numbers_dialog_channel_sms": "Tekstisõnum",
"account_basics_tier_admin": "Peakasutaja",
"account_basics_tier_basic": "Baasteenus",
"account_basics_tier_free": "Tasuta",
"account_basics_tier_interval_monthly": "kord kuus",
"account_basics_tier_interval_yearly": "kord aastas",
"account_basics_tier_change_button": "Muuda",
"account_upgrade_dialog_interval_yearly": "Kord aastas",
"account_upgrade_dialog_tier_selected_label": "Valitud",
"account_upgrade_dialog_tier_current_label": "Praegune",
"account_tokens_dialog_button_cancel": "Katkesta",
"prefs_notifications_title": "Teavitused",
"prefs_users_table_user_header": "Kasutaja",
"prefs_reservations_dialog_access_label": "Ligipääs",
"priority_min": "min",
"priority_low": "madal",
"priority_default": "vaikimisi",
"priority_high": "kõrge",
"priority_max": "kõrgeim",
"alert_notification_ios_install_required_description": "Teavituste lubamiseks iOS-is klõpsi „Jaga“ ikooni ja vali „Lisa avaekraanile“",
"notifications_none_for_topic_title": "Sul pole selles teemas veel ühtegi teavitust.",
"notifications_none_for_topic_description": "Selles teemas teavituste saatmiseks tee PUT või POST meetodiga päring teema võrguaadressile.",
"publish_dialog_base_url_placeholder": "Teenuse võrguaadress, nt. https://toresait.com",
"notifications_loading": "Laadin teavitusi…",
"publish_dialog_title_topic": "Avalda teemas {{topic}}",
"publish_dialog_progress_uploading_detail": "Üleslaadimisel {{loaded}}/{{total}} ({{percent}}%) …",
"publish_dialog_topic_placeholder": "Teema nimi, nt. kati_teavitused",
"publish_dialog_title_placeholder": "Teavituse pealkiri, nt. Andmeruumi teavitus",
"publish_dialog_message_placeholder": "Siia sisesta sõnum",
"notifications_none_for_any_title": "Sa pole veel saanud ühtegi teavitust.",
"publish_dialog_chip_attach_file_label": "Lisa kohalik fail",
"publish_dialog_chip_attach_url_label": "Lisa fail võrguaadressilt",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Kinnitatud telefoninumbreid ei leidu",
"publish_dialog_chip_email_label": "Edasta e-posti aadressile",
"subscribe_dialog_subscribe_base_url_label": "Teenuse võrguaadress",
"subscribe_dialog_subscribe_button_generate_topic_name": "Loo nimi",
"publish_dialog_checkbox_markdown": "Kasuta Markdown-vormingut",
"subscribe_dialog_login_title": "Vajalik on sisselogimine",
"subscribe_dialog_login_username_label": "Kasutajanimi, nt. kadri",
"account_basics_phone_numbers_dialog_verify_button_sms": "Saada SMS",
"account_basics_username_description": "Hei, see oled sina ❤",
"account_basics_username_admin_tooltip": "Sina oled peakasutaja",
"account_basics_phone_numbers_dialog_verify_button_call": "Helista mulle",
"account_basics_phone_numbers_dialog_code_label": "Kinnituskood",
"account_basics_phone_numbers_dialog_code_placeholder": "nt. 123456",
"account_basics_phone_numbers_dialog_check_verification_button": "Korda koodi",
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} sõnum päevas",
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} sõnumit päevas",
"account_upgrade_dialog_button_redirect_signup": "Liitu kohe",
"notifications_actions_http_request_title": "Tee päring HTTP {{method}}-meetodiga võrguaadressile {{url}}",
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} e-kirja päevas",
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} e-kiri päevas",
"alert_not_supported_context_description": "Teavitused võivad kasutada vaid HTTPS-ühendust. See on <mdnLink>Teavituste API</mdnLink> piirang.",
"publish_dialog_tags_placeholder": "Komadega eraldatud siltide loend, nt. hoiatus, srv1-varundus",
"display_name_dialog_title": "Muuda kuvatavat nime",
"display_name_dialog_description": "Lisa teemale alternatiivne nimi, mida kuvatakse tellimuste loendis. See on näiteks abiks keerukate nimedega teemade tuvastamiseks.",
"reserve_dialog_checkbox_label": "Reserveeri teema ja seadista ligipääs",
"publish_dialog_attachment_limits_file_reached": "ületab failisuuruse piiri: {{fileSizeLimit}}",
"publish_dialog_attachment_limits_quota_reached": "ületab kvooti, jäänud on {{remainingBytes}}",
"publish_dialog_attachment_limits_file_and_quota_reached": "ületab failisuuruse ülempiiri ({{fileSizeLimit}}) ja kvooti, jäänud on {{remainingBytes}}",
"publish_dialog_click_placeholder": "Teavituse klõpsimisel avatav võrguaadress",
"publish_dialog_click_reset": "Eemalda klikatav võrguaadress",
"publish_dialog_email_placeholder": "Aadress, kuhu teavitus edastatakse, nt. kadri@torefirma.com",
"publish_dialog_email_reset": "Eemalda edastamiseks kasutatav e-posti aadress",
"publish_dialog_call_item": "Helista telefoninumbrile {{number}}",
"publish_dialog_call_reset": "Eemalda helistamine",
"publish_dialog_attach_placeholder": "Lisa fail võrguaadressilt, nt. https://f-droid.org/F-Droid.apk",
"publish_dialog_attach_reset": "Eemalda manuse lisamisel kasutatav võrguaadress",
"publish_dialog_delay_reset": "Eemalda viivitus teavituse edastamisel",
"account_basics_password_description": "Muuda oma kasutajakonto salasõna",
"account_basics_password_dialog_title": "Salasõna muutmine",
"account_basics_password_dialog_current_password_label": "Senine salasõna",
"account_basics_password_dialog_button_submit": "Muuda salasõna",
"account_basics_password_dialog_current_password_incorrect": "Salasõna pole korrektne",
"account_basics_phone_numbers_title": "Telefoninumbrid",
"account_basics_phone_numbers_description": "Kõneteavituste jaoks",
"account_basics_tier_title": "Kasutajakonto tüüp",
"account_basics_tier_description": "Sinu kasutajakonto õigused",
"account_delete_dialog_button_submit": "Kustuta kasutajakonto jäädavalt",
"prefs_appearance_theme_system": "Süsteemi kujundus",
"prefs_appearance_theme_dark": "Tume kujundus",
"prefs_appearance_theme_light": "Hele kujundus",
"prefs_reservations_title": "Reserveeritud teemad",
"prefs_users_table": "Kasutajate loend",
"prefs_users_add_button": "Lisa kasutaja",
"prefs_users_edit_button": "Muuda kasutajat",
"prefs_users_delete_button": "Kustuta kasutaja",
"prefs_users_table_cannot_delete_or_edit": "Sisselogitud kasutajat ei saa kustutada ega muuta",
"prefs_users_table_base_url_header": "Teenuse võrguaadress",
"prefs_users_dialog_title_add": "Lisa kasutaja",
"prefs_users_dialog_title_edit": "Muuda kasutajat",
"prefs_users_dialog_base_url_label": "Teenuse võrguaadress, nt. https://ntfy.sh",
"prefs_users_dialog_username_label": "Kasutajanimi, nt. kadri",
"prefs_notifications_delete_after_three_hours": "Kolme tunni möödumisel",
"prefs_notifications_delete_after_three_hours_description": "Teavitused kustutatakse automaatselt kolme tunni möödumisel",
"prefs_notifications_delete_after_one_day_description": "Teavitused kustutatakse automaatselt ühe päeva möödumisel",
"prefs_notifications_delete_after_one_week_description": "Teavitused kustutatakse automaatselt ühe nädala möödumisel",
"prefs_notifications_delete_after_one_month_description": "Teavitused kustutatakse automaatselt ühe kuu möödumisel",
"prefs_notifications_delete_after_never_description": "Mitte kunagi ei kustutata teavitusi automaatselt",
"prefs_notifications_delete_after_title": "Kustuta teavitused",
"publish_dialog_delay_placeholder": "Viivitus teavituse edastamisel, nt. {{unixTimestamp}}, {{relativeTime}} või „{{naturalLanguage}}“ (vaid inglise keeles)",
"account_basics_password_dialog_new_password_label": "Uus salasõna",
"account_basics_password_dialog_confirm_password_label": "Korda salasõna",
"account_basics_phone_numbers_dialog_description": "Kõneteavituse kasutamiseks pead lisama ja kinnitama vähemalt ühe telefoninumbri. Kinnitamist saad teha SMS-i või kõne abil.",
"account_basics_phone_numbers_dialog_number_placeholder": "nt. +37256123456",
"account_basics_phone_numbers_no_phone_numbers_yet": "Telefoninumbreid veel pole",
"account_basics_phone_numbers_copied_to_clipboard": "Telefoninumber on kopeeritud lõikelauale",
"account_basics_phone_numbers_dialog_title": "Lisa telefoninumber",
"account_basics_phone_numbers_dialog_number_label": "Telefoninumber",
"prefs_notifications_delete_after_one_week": "Ühe nädala möödumisel",
"prefs_notifications_delete_after_one_day": "Ühe päeva möödumisel",
"prefs_notifications_delete_after_one_month": "Ühe kuu möödumisel"
}

View file

@ -228,5 +228,19 @@
"account_basics_password_dialog_new_password_label": "Parola nouă",
"account_basics_password_title": "Parolă",
"account_basics_tier_description": "Nivelul de putere al contului",
"account_basics_tier_free": "Gratuit"
"account_basics_tier_free": "Gratuit",
"account_delete_description": "Șterge definitiv contul tău",
"account_usage_messages_title": "Mesaje publicate",
"account_basics_tier_manage_billing_button": "Gestionare facturare",
"account_usage_emails_title": "Emailuri trimise",
"account_usage_calls_title": "Apeluri telefonice efectuate",
"account_usage_calls_none": "Nu se pot efectua apeluri telefonice cu acest cont",
"account_usage_reservations_title": "Subiecte rezervate",
"account_usage_cannot_create_portal_session": "Nu s-a putut deschide portalul de facturare",
"account_delete_title": "Șterge contul",
"account_usage_attachment_storage_description": "{{filesize}} per fișier, șters după {{expiry}}",
"account_usage_attachment_storage_title": "Stocare atașamente",
"account_usage_basis_ip_description": "Statistica și limitele de utilizare pentru acest cont se bazează pe adresa ta IP, așadar pot fi partajate cu alți utilizatori. Limitele afișate mai sus sunt aproximative, bazate pe limitele de viteză existente.",
"account_usage_reservations_none": "Nu există subiecte rezervate pentru acest cont",
"account_basics_tier_canceled_subscription": "Abonamentul tău a fost anulat și va fi retrogradat la un cont gratuit în data de {{date}}."
}

View file

@ -5,10 +5,10 @@
"signup_form_toggle_password_visibility": "Hiện mật khẩu",
"login_form_button_submit": "Đăng nhập",
"common_copy_to_clipboard": "Lưu vào clipboard",
"signup_form_username": "Tên user",
"signup_form_username": "Tên đăng nhập",
"signup_already_have_account": "Đã có tài khoản? Đăng nhập!",
"signup_disabled": "Đăng kí bị đóng",
"signup_error_username_taken": "Tên {{username}} đã được sử dụng",
"signup_disabled": "Đăng kí bị khoá",
"signup_error_username_taken": "Tên đăng nhập {{username}} đã được sử dụng",
"signup_error_creation_limit_reached": "Đã đạt giới hạn tạo tài khoản",
"login_title": "Đăng nhập vào tài khoản ntfy",
"login_link_signup": "Đăng kí",
@ -27,5 +27,57 @@
"action_bar_unsubscribe": "Hủy đăng kí",
"action_bar_unmute_notifications": "Bật thông báo",
"action_bar_toggle_mute": "Bật/tắt thông báo",
"action_bar_mute_notifications": "Tắt thông báo"
"action_bar_mute_notifications": "Tắt thông báo",
"common_save": "Lưu",
"common_cancel": "Hủy",
"nav_button_all_notifications": "Tất cả thông báo",
"nav_button_connecting": "đang kết nối",
"nav_upgrade_banner_label": "Nâng cấp tài khoản ntfy Pro",
"alert_not_supported_title": "Thông báo không được hỗ trợ",
"alert_not_supported_description": "Thông báo không được hỗ trợ trên trình duyệt của bạn",
"notifications_list": "Danh sách thông báo",
"notifications_list_item": "Thông báo",
"notifications_mark_read": "Đánh dấu đã đọc",
"notifications_delete": "Xoá",
"notifications_attachment_copy_url_title": "Sao chép URL đính kèm vào clipboard",
"notifications_attachment_copy_url_button": "Sao chép URL",
"notifications_attachment_open_title": "Truy cập {{url}}",
"notifications_click_copy_url_button": "Sao chép liên kết",
"notifications_click_open_button": "Mở liên kết",
"notifications_actions_not_supported": "Không được hỗ trợ trên nên tảng web",
"notifications_actions_http_request_title": "Gởi HTTP {{method}} tới {{url}}",
"action_bar_profile_settings": "Cài đặt",
"message_bar_type_message": "Gõ nội dung tại đây",
"nav_button_account": "Tài khoản",
"nav_button_settings": "Cài đặt",
"nav_button_documentation": "Tài liệu",
"alert_notification_permission_required_title": "Thông báo đã bị khoá",
"alert_notification_permission_required_button": "Cấp quyền ngay",
"alert_notification_permission_denied_title": "Thông báo đã bị chặn",
"alert_notification_ios_install_required_title": "Yêu cầu cài đặt iOS",
"alert_notification_ios_install_required_description": "Nhấn vào biểu tượng Chia sẻ và Thêm vào màn hình chính để kích hoạt thông báo trên iOS",
"alert_notification_permission_required_description": "Cấp quyền để trình duyệt hiển thị thông báo trên màn hình",
"alert_notification_permission_denied_description": "Hãy kích hoạt lại trên trình duyệt của bạn",
"notifications_copied_to_clipboard": "Đã lưu vào clipboard",
"notifications_attachment_file_video": "tập tin video",
"notifications_attachment_file_audio": "tập tin âm thanh",
"notifications_actions_failed_notification": "Thực thi thất bại",
"notifications_new_indicator": "Thông báo mới",
"notifications_click_copy_url_title": "Sao liên kết URL vào clipboard",
"notifications_actions_open_url_title": "Truy cập {{url}}",
"notifications_priority_x": "Độ ưu tiên {{priority}}",
"notifications_attachment_link_expired": "liên kết tải đã hết hạn",
"notifications_attachment_file_image": "tập tin hình ảnh",
"notifications_tags": "Thẻ",
"notifications_attachment_file_document": "tập tin khác",
"action_bar_sign_in": "Đăng nhập",
"notifications_attachment_image": "Hình ảnh đính kèm",
"action_bar_sign_up": "Đăng ký",
"action_bar_profile_title": "Hồ sơ",
"action_bar_toggle_action_menu": "Mở/Đóng bảng điều khiển",
"action_bar_profile_logout": "Đăng xuất",
"notifications_attachment_file_app": "tập tin Android",
"notifications_attachment_link_expires": "liên kết đã hết hạn {{date}}",
"alert_not_supported_context_description": "Thông báo chỉ được hỗ trợ qua giao thức HTTPS. Đây là hạn chế của <mdnLink>API thông báo</mdnLink>.",
"notifications_attachment_open_button": "Mở đính kèm"
}

View file

@ -576,8 +576,10 @@ const Language = () => {
<MenuItem value="zh_Hans">简体中文</MenuItem>
<MenuItem value="da">Dansk</MenuItem>
<MenuItem value="de">Deutsch</MenuItem>
<MenuItem value="et">Eesti</MenuItem>
<MenuItem value="es">Español</MenuItem>
<MenuItem value="fr">Français</MenuItem>
<MenuItem value="gl">Galego</MenuItem>
<MenuItem value="it">Italiano</MenuItem>
<MenuItem value="hu">Magyar</MenuItem>
<MenuItem value="ko">한국어</MenuItem>
@ -589,6 +591,8 @@ const Language = () => {
<MenuItem value="pt_BR">Português (Brasil)</MenuItem>
<MenuItem value="pl">Polski</MenuItem>
<MenuItem value="ru">Русский</MenuItem>
<MenuItem value="ro">Română</MenuItem>
<MenuItem value="sk">Slovenčina</MenuItem>
<MenuItem value="fi">Suomi</MenuItem>
<MenuItem value="sv">Svenska</MenuItem>
<MenuItem value="tr">Türkçe</MenuItem>