mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-09 13:25:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81a589483f | ||
|
|
4c2b69e059 | ||
|
|
ea6b1ca520 | ||
|
|
423063893d | ||
|
|
143b9fb55c | ||
|
|
3104ad20e2 | ||
|
|
00e3c1351a | ||
|
|
c23dc0b30c | ||
|
|
01af61d228 | ||
|
|
9328f1f3c8 | ||
|
|
61873b593f | ||
|
|
9dc30e4a97 | ||
|
|
cd333f130f | ||
|
|
f42326605e | ||
|
|
7ed7fea081 | ||
|
|
01c13e186a | ||
|
|
0ecba37334 | ||
|
|
ccfbc2309d | ||
|
|
fc808db251 | ||
|
|
5a6c4277ad | ||
|
|
dc11655153 | ||
|
|
bd96177fdf | ||
|
|
a3d43190c9 | ||
|
|
95ad323d1c | ||
|
|
e7efdaeb3b | ||
|
|
244a9bc06d | ||
|
|
5e13ca05d5 | ||
|
|
fdaf3316a0 | ||
|
|
310a5aa8df | ||
|
|
0ff1cd5bab | ||
|
|
4abdeb8d57 | ||
|
|
7fb1d25740 | ||
|
|
ef121a3f6c | ||
|
|
4a0f66e258 | ||
|
|
53bbd12cd7 | ||
|
|
e620fbe95b | ||
|
|
b703627d7f | ||
|
|
6f4f9e6407 | ||
|
|
f2d5c1ce6c | ||
|
|
7680cb4906 | ||
|
|
706fa3b491 | ||
|
|
2bc145f3ae | ||
|
|
311138ef7b | ||
|
|
f6b03b44dd | ||
|
|
c674985699 | ||
|
|
f8d2fcd7a6 | ||
|
|
ac63a2eea0 | ||
|
|
07e1260b89 | ||
|
|
af4d85ec03 | ||
|
|
24bc50b585 | ||
|
|
b55e78a918 | ||
|
|
6638699d48 | ||
|
|
3f56dae54a | ||
|
|
1e4e3b6e36 | ||
|
|
75c687de1c | ||
|
|
b15213e531 |
@@ -8,13 +8,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: '.go-version'
|
||||
- name: Install node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
@@ -9,10 +9,10 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout ntfy code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
-
|
||||
name: Checkout docs pages code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: binwiederhier/ntfy-docs.github.io
|
||||
path: build/ntfy-docs.github.io
|
||||
|
||||
@@ -25,19 +25,19 @@ jobs:
|
||||
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: '.go-version'
|
||||
- name: Install node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: './web/package-lock.json'
|
||||
- name: Docker login
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
|
||||
with:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
|
||||
@@ -25,13 +25,13 @@ jobs:
|
||||
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: '.go-version'
|
||||
- name: Install node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ representative at an online or offline event.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement via Discord/Matrix (binwiederhier),
|
||||
or email (ntfy@heckel.io). All complaints will be reviewed and investigated promptly
|
||||
or email (contact@mail.ntfy.sh). All complaints will be reviewed and investigated promptly
|
||||
and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
|
||||
@@ -48,6 +48,9 @@ ADD ./webpush ./webpush
|
||||
ADD ./attachment ./attachment
|
||||
ADD ./mail ./mail
|
||||
ADD ./s3 ./s3
|
||||
ADD ./action ./action
|
||||
ADD ./template/gotext ./template/gotext
|
||||
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server
|
||||
|
||||
FROM alpine
|
||||
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
// Package ban implements the abuse ban-feed: it tracks per-prefix weighted "strikes" from rejected
|
||||
// requests and appends breaching prefixes to a ban file that fail2ban tails. Keying by prefix (not
|
||||
// by visitor) makes the accounting match the unit fail2ban bans, even for shared account visitors.
|
||||
package ban
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
)
|
||||
|
||||
const (
|
||||
tag = "ban"
|
||||
pruneInterval = 10 * time.Minute
|
||||
writeInterval = 3 * time.Second
|
||||
)
|
||||
|
||||
// Config is the Service's config, kept separate from server.Config to avoid an import cycle.
|
||||
type Config struct {
|
||||
File string // Ban file that fail2ban tails (must be non-empty; the caller decides whether the feature is enabled)
|
||||
Window time.Duration // Rolling window over which weighted strikes are counted
|
||||
Threshold int // Weighted strikes per Window before a prefix is banned
|
||||
Weights Weights // Code matcher -> strike weight (0 = exempt)
|
||||
PrefixBitsIPv4 int // Mask width for the ban unit, e.g. 32 (matches rate-limiting granularity)
|
||||
PrefixBitsIPv6 int // Mask width for the ban unit, e.g. 64
|
||||
}
|
||||
|
||||
// tracker is the per-prefix strike state: a weighted breach detector plus timestamps for pruning and throttling.
|
||||
type tracker struct {
|
||||
limiter *rate.Limiter
|
||||
seen time.Time // Last strike, for pruning idle prefixes
|
||||
emitted time.Time // Last ban-line write for this prefix, throttles re-emits to once per Window
|
||||
}
|
||||
|
||||
// Service owns the ban-feed: per-prefix strike accounting, buffered file writes, and idle-prefix
|
||||
// pruning. The caller owns the enable/disable decision -- only construct a Service when the feature
|
||||
// is on (see server.New, which builds one only when a ban file is configured).
|
||||
type Service struct {
|
||||
conf *Config
|
||||
mu sync.Mutex // Guards trackers and pending
|
||||
trackers map[netip.Prefix]*tracker
|
||||
pending []string // Formatted ban lines buffered by Record, flushed to the ban file by runWriteLoop
|
||||
writeDone chan struct{} // Closed when runWriteLoop exits after its final flush
|
||||
closeChan chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// NewService builds a Service and starts its background loops. The caller must only call it when the
|
||||
// feature is enabled (conf non-nil, File non-empty); the Service does not model a disabled state.
|
||||
func NewService(conf *Config) *Service {
|
||||
s := &Service{
|
||||
conf: conf,
|
||||
trackers: make(map[netip.Prefix]*tracker),
|
||||
closeChan: make(chan struct{}),
|
||||
writeDone: make(chan struct{}),
|
||||
}
|
||||
go s.runPruneLoop()
|
||||
go s.runWriteLoop()
|
||||
return s
|
||||
}
|
||||
|
||||
// Record counts one rejection against the IP's prefix bucket and, on breach, buffers a ban line
|
||||
// (throttled to once per Window per prefix). No-ops for a non-4xx/5xx status or a zero-weight code.
|
||||
func (s *Service) Record(ip netip.Addr, httpCode, errorCode int) {
|
||||
if httpCode < 400 {
|
||||
return
|
||||
}
|
||||
weight := s.conf.Weights.WeightFor(errorCode)
|
||||
if weight == 0 {
|
||||
return // Weight 0: exempt, no strike
|
||||
}
|
||||
prefix := s.prefix(ip)
|
||||
now := time.Now()
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
t := s.trackers[prefix]
|
||||
if t == nil {
|
||||
t = &tracker{limiter: rate.NewLimiter(rate.Limit(float64(s.conf.Threshold)/s.conf.Window.Seconds()), s.conf.Threshold)}
|
||||
s.trackers[prefix] = t
|
||||
}
|
||||
t.seen = now
|
||||
if t.limiter.AllowN(now, weight) {
|
||||
return // Within the strike budget, no breach
|
||||
}
|
||||
if !t.emitted.IsZero() && now.Sub(t.emitted) < s.conf.Window {
|
||||
return // Already emitted this prefix within the window (one ban line per prefix per window)
|
||||
}
|
||||
t.emitted = now
|
||||
s.pending = append(s.pending, formatBanLine(now, ip, prefix, httpCode, errorCode))
|
||||
}
|
||||
|
||||
// prefix masks ip to the ban unit (PrefixBitsIPv4/IPv6) -- what fail2ban bans, e.g. a whole /64.
|
||||
func (s *Service) prefix(ip netip.Addr) netip.Prefix {
|
||||
if ip.Is4() {
|
||||
return netip.PrefixFrom(ip, s.conf.PrefixBitsIPv4).Masked()
|
||||
}
|
||||
return netip.PrefixFrom(ip, s.conf.PrefixBitsIPv6).Masked()
|
||||
}
|
||||
|
||||
// formatBanLine builds the "<RFC3339-UTC> <ip> <prefix> <http> <ntfy>" line the fail2ban filter
|
||||
// parses. The timestamp is captured at breach time, not flush time.
|
||||
func formatBanLine(t time.Time, ip netip.Addr, prefix netip.Prefix, httpCode, errorCode int) string {
|
||||
return fmt.Sprintf("%s %s %s %d %d\n", t.UTC().Format(time.RFC3339), ip.String(), prefix.String(), httpCode, errorCode)
|
||||
}
|
||||
|
||||
// runPruneLoop prunes idle prefixes until Close.
|
||||
func (s *Service) runPruneLoop() {
|
||||
ticker := time.NewTicker(pruneInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.prune()
|
||||
case <-s.closeChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runWriteLoop flushes buffered ban lines every writeInterval, plus a final flush on Close.
|
||||
func (s *Service) runWriteLoop() {
|
||||
defer close(s.writeDone)
|
||||
ticker := time.NewTicker(writeInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.flush()
|
||||
case <-s.closeChan:
|
||||
s.flush()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flush appends the buffered lines to the file in one open/write. Best-effort: a batch is dropped on
|
||||
// error. Concurrent calls are safe -- pending is drained under mu, so only one flush writes a batch.
|
||||
func (s *Service) flush() {
|
||||
s.mu.Lock()
|
||||
lines := s.pending
|
||||
s.pending = nil
|
||||
s.mu.Unlock()
|
||||
if len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(s.conf.File, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
log.Tag(tag).Err(err).Warn("Cannot open ban file %s, dropped %d ban(s)", s.conf.File, len(lines))
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
for i, line := range lines {
|
||||
if _, err := f.WriteString(line); err != nil {
|
||||
log.Tag(tag).Err(err).Warn("Cannot write to ban file %s, dropped %d ban(s)", s.conf.File, len(lines)-i)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// prune drops prefixes idle for a full Window -- their bucket has refilled, so forgetting them is a
|
||||
// no-op that bounds memory under a flood of distinct IPs.
|
||||
func (s *Service) prune() {
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for prefix, t := range s.trackers {
|
||||
if now.Sub(t.seen) >= s.conf.Window {
|
||||
delete(s.trackers, prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the loops and blocks until the final flush completes. Idempotent.
|
||||
func (s *Service) Close() {
|
||||
s.closeOnce.Do(func() {
|
||||
close(s.closeChan)
|
||||
<-s.writeDone
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package ban
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var testIP = netip.MustParseAddr("1.2.3.4")
|
||||
|
||||
// newTestService creates a Service wired for testing, with the given ban file, weighted-bucket
|
||||
// threshold, and weights, plus a 1-minute window (so the emit throttle only fires once per test).
|
||||
func newTestService(t *testing.T, banFile string, threshold int, weights map[string]int) *Service {
|
||||
t.Helper()
|
||||
s := NewService(&Config{
|
||||
File: banFile,
|
||||
Window: time.Minute,
|
||||
Threshold: threshold,
|
||||
Weights: weights,
|
||||
PrefixBitsIPv4: 32,
|
||||
PrefixBitsIPv6: 64,
|
||||
})
|
||||
t.Cleanup(s.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// flushAndRead forces a synchronous flush of the buffered bans (writes are otherwise async, on the
|
||||
// runWriteLoop ticker) and returns the ban file's lines.
|
||||
func flushAndRead(t *testing.T, s *Service, path string) []string {
|
||||
t.Helper()
|
||||
s.flush()
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
return strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
}
|
||||
|
||||
func TestService_Record_Weight2BansAtHalfThreshold(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
// Threshold 10, code weight 2 -> the budget covers exactly 5 hits, so the 6th breaches.
|
||||
s := newTestService(t, banFile, 10, map[string]int{"*": 2})
|
||||
for i := 0; i < 5; i++ {
|
||||
s.Record(testIP, 400, 40001)
|
||||
}
|
||||
s.flush()
|
||||
require.NoFileExists(t, banFile) // 5 hits * weight 2 = 10 == budget, exactly at the limit, not over
|
||||
s.Record(testIP, 400, 40001) // 6th hit cannot be covered -> breach
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 400 40001")) // <ip> <prefix> <http> <ntfy-code>
|
||||
}
|
||||
|
||||
func TestService_Record_Weight10BansFast(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
// Threshold 10, code weight 10 -> a single hit drains the whole budget, so the 2nd breaches.
|
||||
s := newTestService(t, banFile, 10, map[string]int{"42909": 10, "*": 1})
|
||||
s.Record(testIP, 429, 42909)
|
||||
s.flush()
|
||||
require.NoFileExists(t, banFile)
|
||||
s.Record(testIP, 429, 42909) // 2nd hit cannot be covered -> breach
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 429 42909"))
|
||||
}
|
||||
|
||||
func TestService_Record_Weight0NeverBans(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
// The legit-quota code is exempt (weight 0), so no number of hits ever bans.
|
||||
s := newTestService(t, banFile, 10, map[string]int{"42908": 0, "*": 1})
|
||||
for i := 0; i < 100; i++ {
|
||||
s.Record(testIP, 429, 42908)
|
||||
}
|
||||
s.flush()
|
||||
require.NoFileExists(t, banFile)
|
||||
}
|
||||
|
||||
func TestService_Record_SingleBucketNoRelaxation(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
// One shared bucket per prefix: different codes draw down the SAME budget, so mixing them creates
|
||||
// no extra headroom (unlike per-code buckets, which would relax the effective limit for a mixed
|
||||
// offender).
|
||||
s := newTestService(t, banFile, 10, map[string]int{"403*": 2, "*": 1})
|
||||
s.Record(testIP, 403, 40301) // weight 2 -> 8 left
|
||||
s.Record(testIP, 403, 40301) // weight 2 -> 6 left
|
||||
s.Record(testIP, 403, 40301) // weight 2 -> 4 left
|
||||
s.flush()
|
||||
require.NoFileExists(t, banFile)
|
||||
for i := 0; i < 4; i++ {
|
||||
s.Record(testIP, 400, 40001) // weight 1 each -> drains the remaining 4 -> 0 left
|
||||
}
|
||||
s.flush()
|
||||
require.NoFileExists(t, banFile) // 6 + 4 = 10 == budget exactly, still not over
|
||||
s.Record(testIP, 400, 40001) // one more cannot be covered -> breach
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
}
|
||||
|
||||
func TestService_Record_ExactLineFormat(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
|
||||
before := time.Now().UTC().Truncate(time.Second)
|
||||
for i := 0; i < 3; i++ {
|
||||
s.Record(testIP, 429, 42901)
|
||||
}
|
||||
after := time.Now().UTC()
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
parts := strings.Split(lines[0], " ")
|
||||
require.Len(t, parts, 5) // "<timestamp> <ip> <prefix> <http-code> <ntfy-code>"
|
||||
ts, err := time.Parse(time.RFC3339, parts[0])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.UTC, ts.Location())
|
||||
require.False(t, ts.Before(before))
|
||||
require.False(t, ts.After(after.Add(time.Second)))
|
||||
require.Equal(t, "1.2.3.4", parts[1]) // full IP
|
||||
require.Equal(t, "1.2.3.4/32", parts[2]) // masked to the default IPv4 prefix (/32)
|
||||
require.Equal(t, "429", parts[3]) // HTTP status
|
||||
require.Equal(t, "42901", parts[4]) // ntfy code
|
||||
}
|
||||
|
||||
func TestService_Record_IPv6MaskedToPrefix(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
|
||||
ip := netip.MustParseAddr("2001:db8::abcd")
|
||||
for i := 0; i < 3; i++ {
|
||||
s.Record(ip, 429, 42901)
|
||||
}
|
||||
parts := strings.Split(flushAndRead(t, s, banFile)[0], " ")
|
||||
require.Len(t, parts, 5)
|
||||
require.Equal(t, "2001:db8::abcd", parts[1]) // full IPv6 address
|
||||
require.Equal(t, "2001:db8::/64", parts[2]) // masked to the default IPv6 prefix (/64)
|
||||
}
|
||||
|
||||
func TestService_Record_PerPrefixIsolation(t *testing.T) {
|
||||
// Each source prefix gets its own bucket: one IP hammering to a breach must not push a different,
|
||||
// quiet IP over the edge. This is the whole point of keying by prefix instead of by visitor.
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 2, map[string]int{"*": 1})
|
||||
noisy := netip.MustParseAddr("1.1.1.1")
|
||||
quiet := netip.MustParseAddr("2.2.2.2")
|
||||
for i := 0; i < 5; i++ {
|
||||
s.Record(noisy, 429, 42901) // breaches its own bucket
|
||||
}
|
||||
s.Record(quiet, 429, 42901) // single hit, well under threshold
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1) // only the noisy prefix is written
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.1.1.1 1.1.1.1/32 429 42901"))
|
||||
}
|
||||
|
||||
func TestService_Record_OncePerWindowThrottle(t *testing.T) {
|
||||
// Once a prefix has been written, further breaches within the window must not re-append it, so a
|
||||
// persistent offender produces exactly one line per window (mirrors the old per-visitor banEmit).
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
|
||||
for i := 0; i < 50; i++ {
|
||||
s.Record(testIP, 429, 42901)
|
||||
}
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
}
|
||||
|
||||
func TestService_Record_BansPassedIP(t *testing.T) {
|
||||
// The Service bans the exact IP passed to Record -- the caller passes the offending request's IP,
|
||||
// which for an account-keyed visitor is not the visitor's stored IP.
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
|
||||
offender := netip.MustParseAddr("5.6.7.8")
|
||||
for i := 0; i < 3; i++ {
|
||||
s.Record(offender, 429, 42901)
|
||||
}
|
||||
parts := strings.Split(flushAndRead(t, s, banFile)[0], " ")
|
||||
require.Equal(t, "5.6.7.8", parts[1]) // the IP passed to Record
|
||||
require.Equal(t, "5.6.7.8/32", parts[2]) // its prefix
|
||||
}
|
||||
|
||||
func TestService_Record_Ignores2xx3xx(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 3, map[string]int{"*": 1})
|
||||
// Success and redirects must never count toward a ban, even over the threshold -- otherwise a
|
||||
// legit high-volume publisher (lots of 200s) would get banned.
|
||||
for i := 0; i < 20; i++ {
|
||||
s.Record(testIP, 200, 20000)
|
||||
s.Record(testIP, 302, 30000)
|
||||
}
|
||||
s.flush()
|
||||
require.NoFileExists(t, banFile)
|
||||
// A 4xx over the same budget still gets written.
|
||||
for i := 0; i < 5; i++ {
|
||||
s.Record(testIP, 400, 40001)
|
||||
}
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 400 40001"))
|
||||
}
|
||||
|
||||
func TestService_Record_BuffersUntilFlush(t *testing.T) {
|
||||
// Writes are async: a breach buffers the ban line rather than writing it synchronously on the
|
||||
// request path. The line only reaches the file when runWriteLoop (or an explicit flush) runs.
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
|
||||
for i := 0; i < 3; i++ {
|
||||
s.Record(testIP, 429, 42901)
|
||||
}
|
||||
require.NoFileExists(t, banFile) // not written synchronously
|
||||
s.mu.Lock()
|
||||
require.Len(t, s.pending, 1) // one line buffered (throttled to once per window)
|
||||
s.mu.Unlock()
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 429 42901"))
|
||||
}
|
||||
|
||||
func TestService_Close_FlushesPending(t *testing.T) {
|
||||
// Close must flush buffered bans so nothing is lost on shutdown, and must block until it has.
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := NewService(&Config{File: banFile, Window: time.Minute, Threshold: 1, Weights: Weights{"*": 1}, PrefixBitsIPv4: 32, PrefixBitsIPv6: 64})
|
||||
for i := 0; i < 3; i++ {
|
||||
s.Record(testIP, 429, 42901)
|
||||
}
|
||||
require.NoFileExists(t, banFile) // still buffered
|
||||
s.Close() // blocks until the final flush completes
|
||||
data, err := os.ReadFile(banFile)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, strings.Split(strings.TrimRight(string(data), "\n"), "\n"), 1)
|
||||
}
|
||||
|
||||
func TestService_Prune_DropsIdlePrefixes(t *testing.T) {
|
||||
// A prefix idle for a full window has a refilled bucket, so prune drops it to bound memory. An
|
||||
// active prefix (seen within the window) is kept.
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 10, map[string]int{"*": 1})
|
||||
idle := netip.MustParseAddr("9.9.9.9")
|
||||
s.Record(idle, 400, 40001)
|
||||
s.Record(testIP, 400, 40001)
|
||||
require.Len(t, s.trackers, 2)
|
||||
|
||||
// Backdate the idle prefix past the window, then prune.
|
||||
idlePrefix := s.prefix(idle)
|
||||
s.mu.Lock()
|
||||
s.trackers[idlePrefix].seen = time.Now().Add(-2 * time.Minute)
|
||||
s.mu.Unlock()
|
||||
s.prune()
|
||||
|
||||
require.Len(t, s.trackers, 1)
|
||||
_, ok := s.trackers[idlePrefix]
|
||||
require.False(t, ok) // idle prefix dropped
|
||||
_, ok = s.trackers[s.prefix(testIP)]
|
||||
require.True(t, ok) // active prefix kept
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package ban
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Weights maps a matcher key to a strike weight for the abuse ban-feed (see ParseWeights, WeightFor).
|
||||
type Weights map[string]int
|
||||
|
||||
// ParseWeights normalizes a list like ["42909:10","403:2","*:1"] into a Weights map. A key is an
|
||||
// exact ntfy code, a family ("429*"), a bare HTTP status ("403" -> "403*"), or "*"; weights are ints
|
||||
// >= 0 (0 = exempt). Malformed entries are rejected so misconfiguration fails at startup.
|
||||
func ParseWeights(entries []string) (Weights, error) {
|
||||
out := make(Weights, len(entries))
|
||||
for _, entry := range entries {
|
||||
key, weightStr, ok := strings.Cut(entry, ":")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid ban-weight %q, want KEY:WEIGHT", entry)
|
||||
}
|
||||
weight, err := strconv.Atoi(strings.TrimSpace(weightStr))
|
||||
if err != nil || weight < 0 {
|
||||
return nil, fmt.Errorf("invalid ban-weight value in %q, want a non-negative integer", entry)
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if !validWeightKey(key) {
|
||||
return nil, fmt.Errorf("invalid ban-weight key in %q, want %q, an ntfy code, an HTTP status, or a PREFIX*", entry, "*")
|
||||
}
|
||||
// A bare 3-digit HTTP status is shorthand for the whole family (e.g. "403" -> "403*").
|
||||
if len(key) == 3 && isAllDigits(key) {
|
||||
key += "*"
|
||||
}
|
||||
out[key] = weight
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WeightFor returns the strike weight for an ntfy error code, longest-match-wins (exact > family > "*").
|
||||
// If nothing matches (no "*" catch-all) it returns the implied default 1, so a forgotten "*" still
|
||||
// bans; use "*:0" to exempt everything not explicitly weighted.
|
||||
func (w Weights) WeightFor(errorCode int) int {
|
||||
code := strconv.Itoa(errorCode)
|
||||
weight, bestLen, matched := 0, -1, false
|
||||
for key, wt := range w {
|
||||
matchLen := -1
|
||||
switch {
|
||||
case key == "*":
|
||||
matchLen = 0
|
||||
case strings.HasSuffix(key, "*"):
|
||||
if prefix := strings.TrimSuffix(key, "*"); strings.HasPrefix(code, prefix) {
|
||||
matchLen = len(prefix)
|
||||
}
|
||||
case key == code:
|
||||
matchLen = len(code)
|
||||
}
|
||||
if matchLen > bestLen {
|
||||
weight, bestLen, matched = wt, matchLen, true
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return 1
|
||||
}
|
||||
return weight
|
||||
}
|
||||
|
||||
// validWeightKey reports whether key is a legal matcher: "*", an all-digits code, or DIGITS*.
|
||||
func validWeightKey(key string) bool {
|
||||
if key == "*" {
|
||||
return true
|
||||
}
|
||||
digits := strings.TrimSuffix(key, "*")
|
||||
return digits != "" && isAllDigits(digits)
|
||||
}
|
||||
|
||||
func isAllDigits(s string) bool {
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return s != ""
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package ban
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseWeights(t *testing.T) {
|
||||
// Exact codes, a bare 3-digit HTTP status (normalized to a family), an exempt code, and "*".
|
||||
weights, err := ParseWeights([]string{"42909:10", "403:2", "42908:0", "*:1"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Weights{"42909": 10, "403*": 2, "42908": 0, "*": 1}, weights)
|
||||
|
||||
// A bare 3-digit HTTP status normalizes to its family.
|
||||
weights, err = ParseWeights([]string{"429:5"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Weights{"429*": 5}, weights)
|
||||
|
||||
// An explicit family key stays as-is.
|
||||
weights, err = ParseWeights([]string{"429*:5"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Weights{"429*": 5}, weights)
|
||||
|
||||
// Weight 0 is valid and means exempt.
|
||||
weights, err = ParseWeights([]string{"42908:0"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Weights{"42908": 0}, weights)
|
||||
|
||||
_, err = ParseWeights([]string{"401"}) // Missing weight
|
||||
require.Error(t, err)
|
||||
_, err = ParseWeights([]string{"401:-1"}) // Negative weight
|
||||
require.Error(t, err)
|
||||
_, err = ParseWeights([]string{"401:abc"}) // Non-integer weight
|
||||
require.Error(t, err)
|
||||
_, err = ParseWeights([]string{"abc:10"}) // Non-numeric key
|
||||
require.Error(t, err)
|
||||
_, err = ParseWeights([]string{"4*3:10"}) // Star not at the end
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestWeights_WeightFor(t *testing.T) {
|
||||
weights, err := ParseWeights([]string{"42908:0", "42903:0", "42905:0", "42910:0", "42909:10", "429*:1", "403*:2", "4*:1", "5*:1"})
|
||||
require.NoError(t, err)
|
||||
// Longest-match-wins: exact 5-digit beats "429*" beats "4*" beats "*".
|
||||
require.Equal(t, 0, weights.WeightFor(42908))
|
||||
require.Equal(t, 10, weights.WeightFor(42909))
|
||||
require.Equal(t, 1, weights.WeightFor(42901))
|
||||
require.Equal(t, 2, weights.WeightFor(40311))
|
||||
require.Equal(t, 1, weights.WeightFor(40011))
|
||||
require.Equal(t, 1, weights.WeightFor(50312))
|
||||
// No rule matches (this config has 4*/5* but no "*"), so the implied default weight 1 applies.
|
||||
require.Equal(t, 1, weights.WeightFor(30012))
|
||||
}
|
||||
|
||||
func TestWeights_WeightFor_NoStarRuleImpliesWeight1(t *testing.T) {
|
||||
// With no "*" rule, a code that matches nothing defaults to weight 1 (can be banned), so the
|
||||
// feature can't be silently turned into a no-op by forgetting "*". Explicit codes still win.
|
||||
weights, err := ParseWeights([]string{"42908:0", "42909:10"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, weights.WeightFor(42908)) // explicitly exempt
|
||||
require.Equal(t, 10, weights.WeightFor(42909)) // explicit
|
||||
require.Equal(t, 1, weights.WeightFor(42901)) // unmatched -> implied 1
|
||||
require.Equal(t, 1, weights.WeightFor(40001)) // unmatched -> implied 1
|
||||
}
|
||||
|
||||
func TestWeights_WeightFor_ExplicitStarZeroExemptsAll(t *testing.T) {
|
||||
// An explicit "*:0" is the opt-out: exempt everything not otherwise weighted.
|
||||
weights, err := ParseWeights([]string{"42909:10", "*:0"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 10, weights.WeightFor(42909)) // explicit
|
||||
require.Equal(t, 0, weights.WeightFor(42901)) // *:0 -> exempt everything else
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Package cluster implements cross-node message delivery for a multi-node ntfy cluster. Nodes
|
||||
// register themselves in a PostgreSQL node registry (control plane) and fan published messages
|
||||
// out to each other directly over HTTP (data plane); PostgreSQL is never on the message path.
|
||||
// The single-node default is the nop cluster, which does nothing.
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
// The internal peer API: every kind of node-to-node communication is a path under
|
||||
// /v1/internal/, served only on the dedicated cluster listener. Future concerns (rate limit
|
||||
// counters, stats) become new paths or new sections of the state envelope.
|
||||
const (
|
||||
// MessagePath receives batches of published messages (NDJSON, one apiMessage per line).
|
||||
MessagePath = "/v1/internal/message"
|
||||
// StatePath receives peer state (JSON apiState): full subscription snapshots and
|
||||
// incremental updates.
|
||||
StatePath = "/v1/internal/state"
|
||||
)
|
||||
|
||||
// NodeID identifies a cluster node; it keys the registry, the per-peer queues, and the peer
|
||||
// state table.
|
||||
//
|
||||
// Naming convention: a "node" is any cluster member in the absolute sense (identity, registry,
|
||||
// config); a "peer" is another node as seen from this one (Peers, peerQueue, peerState). A peer
|
||||
// IS a node, which is why peer values carry a NodeID.
|
||||
type NodeID string
|
||||
|
||||
const (
|
||||
// secretHeader carries the shared secret authenticating node-to-node fan-out requests.
|
||||
secretHeader = "X-Cluster-Secret"
|
||||
|
||||
// originHeader carries the sending node's ID on fan-out requests, so a node can skip
|
||||
// requests that carry its own broadcasts (loop prevention).
|
||||
originHeader = "X-Cluster-Origin"
|
||||
)
|
||||
|
||||
// Content types of the peer API: message bodies are NDJSON (one JSON message per line, matching
|
||||
// the framing of ntfy's own /topic/json subscribe stream), state bodies are plain JSON. Future
|
||||
// node-to-node request types get their own paths on the cluster listener; an old node answering
|
||||
// 404 on an unknown path keeps mixed-version clusters working during rolling deploys.
|
||||
const (
|
||||
contentTypeNDJSON = "application/x-ndjson"
|
||||
contentTypeJSON = "application/json"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHeartbeatInterval = 3 * time.Second // How often a node refreshes its registry heartbeat
|
||||
defaultNodeTTL = 30 * time.Second // A node counts as live if its heartbeat is newer than this; generous to avoid false-dead flapping (see plans)
|
||||
defaultStateInterval = 15 * time.Second // How often the full subscription state is pushed to peers
|
||||
|
||||
// DefaultBatchLinger is how long a fan-out message may wait in a peer's queue for more
|
||||
// messages to arrive, so they are delivered as one batch. It trades up to this much
|
||||
// cross-node latency for a bounded request rate per peer.
|
||||
DefaultBatchLinger = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// Cluster fans published messages out to peer cluster nodes and receives their fan-out requests.
|
||||
// Local delivery to a node's own subscribers still happens inline in the server; the cluster
|
||||
// only covers the cross-node hop.
|
||||
type Cluster interface {
|
||||
http.Handler
|
||||
// ForwardMessage sends a locally published message on to the peer nodes that may have subscribers
|
||||
// for its topic (all of them, when subscription knowledge is missing or stale). It is
|
||||
// fire-and-forget and must not block the caller's request path.
|
||||
ForwardMessage(m *model.Message) error
|
||||
// BroadcastState pushes a subscription-state delta to ALL peers (unlike ForwardMessage,
|
||||
// which routes), closing the routing-knowledge window to ~one round trip. Nop single-node.
|
||||
BroadcastState(state *State)
|
||||
// IsLeader reports whether this node holds the cluster leader lock. Singleton background
|
||||
// jobs (e.g. the Firebase keepaliver) are gated on the leader.
|
||||
IsLeader() bool
|
||||
// Healthy reports whether this node is fit to serve: its registry heartbeat is fresh
|
||||
// enough (within NodeTTL) that peers still forward messages to it. Health checkers must
|
||||
// fail open (never pull ALL nodes): during a full database outage every node reports
|
||||
// unhealthy while the mesh keeps delivering on stale peer caches.
|
||||
Healthy() bool
|
||||
// Close stops the cluster and releases its resources.
|
||||
Close() error
|
||||
}
|
||||
|
||||
// New creates the cluster for the given config: the nop cluster when clustering is disabled (the
|
||||
// single-node default), or the peer-mesh cluster otherwise.
|
||||
func New(conf *Config, pool *db.DB, deliver DeliverFunc, topics TopicsFunc) (Cluster, error) {
|
||||
if !conf.Enabled {
|
||||
return &nopCluster{}, nil
|
||||
}
|
||||
if pool == nil {
|
||||
return nil, errors.New("cluster mode requires a PostgreSQL database (set database-url)")
|
||||
}
|
||||
if conf.AdvertiseURL == "" {
|
||||
return nil, errors.New("cluster mode requires an advertise URL (set cluster-advertise-url)")
|
||||
}
|
||||
if conf.NodeID == "" {
|
||||
return nil, errors.New("cluster mode requires a stable node ID (set cluster-node-id)")
|
||||
}
|
||||
if conf.HeartbeatInterval == 0 {
|
||||
conf.HeartbeatInterval = defaultHeartbeatInterval
|
||||
}
|
||||
if conf.NodeTTL == 0 {
|
||||
conf.NodeTTL = defaultNodeTTL
|
||||
}
|
||||
if conf.StateInterval == 0 {
|
||||
conf.StateInterval = defaultStateInterval
|
||||
}
|
||||
return newMeshCluster(conf, pool, deliver, topics)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
dbtest "heckel.io/ntfy/v2/db/test"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
// TestMesh_Soak floods the mesh with concurrent publishers and asserts exact delivery: every
|
||||
// message reaches the peer exactly once, nothing is dropped, and batching keeps the request
|
||||
// count far below the message count. Skipped unless NTFY_TEST_SOAK is set (it takes a few
|
||||
// seconds and is meant for pre-deploy verification, not the regular suite).
|
||||
func TestMesh_Soak(t *testing.T) {
|
||||
if os.Getenv("NTFY_TEST_SOAK") == "" {
|
||||
t.Skip("NTFY_TEST_SOAK not set")
|
||||
}
|
||||
// ~1000 msg/s aggregate (10x the ntfy.sh peak of ~88 msg/s): each publisher paces itself to
|
||||
// 100 msg/s. Unthrottled publishing intentionally overruns the bounded per-peer queue (load
|
||||
// shedding by design), so a zero-drop assertion only holds below the drain ceiling.
|
||||
const (
|
||||
publishers = 10
|
||||
messagesPerPublisher = 300
|
||||
publishInterval = 10 * time.Millisecond
|
||||
total = publishers * messagesPerPublisher
|
||||
)
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
var mu sync.Mutex
|
||||
received := make(map[string]int, total) // message body -> count, to catch duplicates
|
||||
requests := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
messages, err := unmarshalMessageBody(body, 1<<20)
|
||||
require.Nil(t, err)
|
||||
mu.Lock()
|
||||
requests++
|
||||
for _, m := range messages {
|
||||
received[m.Message]++
|
||||
}
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
conf := newTestMeshConfig("node-a", "http://127.0.0.1:1")
|
||||
conf.BatchLinger = 50 * time.Millisecond
|
||||
conf.NodeTTL = time.Minute // The fake peer never heartbeats; liveness is not under test here
|
||||
registerFakePeer(t, pool, "node-peer", srv.URL)
|
||||
mesh, err := newMeshCluster(conf, pool, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer mesh.Close()
|
||||
start := time.Now()
|
||||
var wg sync.WaitGroup
|
||||
for p := 0; p < publishers; p++ {
|
||||
wg.Add(1)
|
||||
go func(p int) {
|
||||
defer wg.Done()
|
||||
ticker := time.NewTicker(publishInterval)
|
||||
defer ticker.Stop()
|
||||
for i := 0; i < messagesPerPublisher; i++ {
|
||||
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", fmt.Sprintf("p%d-m%d", p, i))))
|
||||
<-ticker.C
|
||||
}
|
||||
}(p)
|
||||
}
|
||||
wg.Wait()
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(received) == total
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for body, count := range received {
|
||||
require.Equalf(t, 1, count, "message %s delivered %d times", body, count)
|
||||
}
|
||||
require.Less(t, requests, total/10, "expected strong batching under load")
|
||||
t.Logf("soak: %d messages, %d requests (%.1f msgs/request), %.0f msgs/s",
|
||||
total, requests, float64(total)/float64(requests), float64(total)/elapsed.Seconds())
|
||||
}
|
||||
|
||||
// BenchmarkForwardMessage measures the publish-path cost of ForwardMessage: marshal + peer lookup (cached)
|
||||
// + enqueue. The peer never drains, so enqueued fragments are dropped once the queue fills;
|
||||
// the benchmark measures the hot path, not HTTP delivery.
|
||||
func BenchmarkForwardMessage(b *testing.B) {
|
||||
if os.Getenv("NTFY_TEST_DATABASE_URL") == "" {
|
||||
b.Skip("NTFY_TEST_DATABASE_URL not set")
|
||||
}
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(b)
|
||||
pool := openTestPool(b, schemaDSN)
|
||||
conf := newTestMeshConfig("node-a", "http://127.0.0.1:1")
|
||||
conf.BatchLinger = time.Minute // Never flush; we measure enqueue only
|
||||
mesh, err := newMeshCluster(conf, pool, nil, nil)
|
||||
require.Nil(b, err)
|
||||
defer mesh.Close()
|
||||
registerFakePeer(b, pool, "node-peer", "http://127.0.0.1:1")
|
||||
m := model.NewDefaultMessage("mytopic", "benchmark message body of typical size for a push")
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := mesh.ForwardMessage(m); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkDecodeFanout measures the receive-path cost of decoding a 100-message NDJSON body.
|
||||
func BenchmarkDecodeFanout(b *testing.B) {
|
||||
frags := make([][]byte, 100)
|
||||
for i := range frags {
|
||||
frag, err := marshalMessage(model.NewDefaultMessage("mytopic", fmt.Sprintf("benchmark message %d", i)))
|
||||
require.Nil(b, err)
|
||||
frags[i] = frag
|
||||
}
|
||||
body := assembleMessageBody(frags)
|
||||
b.SetBytes(int64(len(body)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
messages, err := unmarshalMessageBody(body, 1<<20)
|
||||
if err != nil || len(messages) != 100 {
|
||||
b.Fatal("decode failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/cluster/registry"
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/metrics"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
meshHTTPTimeout = 5 * time.Second
|
||||
peerQueueSize = 1024 // Bounded per-peer fan-out queue (drop on overflow)
|
||||
batchMaxMessages = 100 // Flush a batch early when it reaches this many messages
|
||||
batchMaxBytes = 256 * 1024 // Flush a batch early when it reaches this size
|
||||
stateMaxBytes = 4 * 1024 * 1024 // Upper bound for inbound state bodies (filter over ~1M topics)
|
||||
stateFilterFPRate = 0.01 // Bloom false-positive rate; a false positive is one wasted send
|
||||
tag = "cluster"
|
||||
)
|
||||
|
||||
// meshCluster fans messages out directly to peer nodes over HTTP (the data plane), using
|
||||
// PostgreSQL only as a control plane: the node_registry table for membership/discovery, and a
|
||||
// Postgres advisory lock for singleton-job leader election. Fan-out never touches the database on
|
||||
// the message path (only the cached peer list does). See plans/260715-scale-out-mesh.md.
|
||||
//
|
||||
// Each peer has its own bounded send queue and delivery worker, so a slow or wedged peer only
|
||||
// backs up (and eventually drops) its own queue and never delays delivery to healthy peers.
|
||||
type meshCluster struct {
|
||||
conf *Config
|
||||
deliver DeliverFunc
|
||||
topics TopicsFunc
|
||||
registry *registry.Registry
|
||||
leader *pg.Leader
|
||||
httpClient *http.Client
|
||||
mux *http.ServeMux // The internal peer API; Cluster is an http.Handler
|
||||
queues map[NodeID]*peerQueue // per-peer send queues; reconciled against the registry
|
||||
closed bool // Guards against ForwardMessage spawning new workers after Close
|
||||
states map[NodeID]*peerState // what each peer last told us (subscription knowledge)
|
||||
lastStatePush time.Time // Only touched by the heartbeat goroutine
|
||||
knownPeers map[NodeID]string // Peers seen in the last reconcile, for join/leave logging
|
||||
lastRegistered time.Time // Last successful registry heartbeat, for Healthy
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex // Protects queues, closed, knownPeers and lastRegistered
|
||||
statesMu sync.Mutex // Protects states
|
||||
}
|
||||
|
||||
// newMeshCluster creates the mesh cluster: it sets up the registry schema, registers this node
|
||||
// (synchronously, so it is discoverable before New returns), and starts the heartbeat loop.
|
||||
// Peer delivery workers are started lazily as peers appear in the registry.
|
||||
func newMeshCluster(conf *Config, pool *db.DB, deliver DeliverFunc, topics TopicsFunc) (*meshCluster, error) {
|
||||
if topics == nil {
|
||||
topics = func() []string { return nil } // No known topics; peers will broadcast to us
|
||||
}
|
||||
reg, err := registry.New(pool, string(conf.NodeID), conf.AdvertiseURL, conf.NodeTTL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Register synchronously so the node is discoverable before the constructor returns; the
|
||||
// heartbeat loop refreshes the registration from here on
|
||||
if err := reg.Register(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c := &meshCluster{
|
||||
conf: conf,
|
||||
deliver: deliver,
|
||||
topics: topics,
|
||||
registry: reg,
|
||||
// Renews its lease on its own fixed cadence; see pg.Leader for the semantics
|
||||
leader: pg.NewLeader(pool.Primary(), pg.LeaderLockKey, conf.LeaderRenewInterval),
|
||||
httpClient: &http.Client{Timeout: meshHTTPTimeout},
|
||||
queues: make(map[NodeID]*peerQueue),
|
||||
lastRegistered: time.Now(), // The synchronous Register above just succeeded
|
||||
states: make(map[NodeID]*peerState),
|
||||
knownPeers: make(map[NodeID]string),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
c.mux = http.NewServeMux()
|
||||
c.mux.HandleFunc("POST "+MessagePath, c.authenticated(c.handleMessage))
|
||||
c.mux.HandleFunc("POST "+StatePath, c.authenticated(c.handleState))
|
||||
c.wg.Add(1)
|
||||
go c.heartbeatLoop()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ServeHTTP serves the internal peer API. Auth lives in the authenticated middleware, so every
|
||||
// endpoint gets the same shared-secret and origin handling.
|
||||
func (c *meshCluster) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
c.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// authenticated wraps a peer API handler with the checks every endpoint needs: the shared
|
||||
// secret (constant-time compare, rejected before any body is read), a present origin, and the
|
||||
// origin self-skip (a request carrying this node's own traffic is acknowledged but ignored).
|
||||
func (c *meshCluster) authenticated(h func(origin NodeID, w http.ResponseWriter, r *http.Request)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if c.conf.Secret == "" || subtle.ConstantTimeCompare([]byte(r.Header.Get(secretHeader)), []byte(c.conf.Secret)) != 1 {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
origin := NodeID(r.Header.Get(originHeader))
|
||||
if origin == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if origin == c.conf.NodeID {
|
||||
w.WriteHeader(http.StatusOK) // Our own traffic; nothing to do
|
||||
return
|
||||
}
|
||||
h(origin, w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// heartbeatLoop runs one heartbeat immediately (the ticker first fires a full interval after
|
||||
// startup, and a fresh node should be leader-capable and state-visible right away), then one per
|
||||
// interval until shutdown.
|
||||
func (c *meshCluster) heartbeatLoop() {
|
||||
defer c.wg.Done()
|
||||
ticker := time.NewTicker(c.conf.HeartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
if err := c.heartbeat(); err != nil {
|
||||
log.Tag(tag).Err(err).Warn("Cluster heartbeat failed")
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := c.heartbeat(); err != nil {
|
||||
log.Tag(tag).Err(err).Warn("Cluster heartbeat failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// heartbeat is one control-plane tick: refresh this node's registry row, retry/confirm the
|
||||
// leader lock, prune long-dead registry rows (as leader), reconcile the per-peer queues, and
|
||||
// periodically push our subscription state to peers.
|
||||
//
|
||||
// A node that cannot even register itself aborts the tick: the remaining database work would
|
||||
// fail against the same database, and everything downstream degrades safely without it -- ForwardMessage
|
||||
// serves the stale peer cache on its own, and peers fall back to broadcasting to us once our
|
||||
// last pushed state expires.
|
||||
func (c *meshCluster) heartbeat() error {
|
||||
if err := c.registry.Register(); err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.lastRegistered = time.Now()
|
||||
c.mu.Unlock()
|
||||
// Effective leadership: pg.Leader's lease semantics guarantee a no-leader gap on
|
||||
// failover, never two leaders
|
||||
if c.leader.IsLeader() {
|
||||
metrics.ClusterLeader.Set(1)
|
||||
if err := c.registry.Prune(); err != nil {
|
||||
log.Tag(tag).Err(err).Warn("Failed to prune stale nodes") // Housekeeping only; not fatal for the tick
|
||||
}
|
||||
} else {
|
||||
metrics.ClusterLeader.Set(0)
|
||||
}
|
||||
peers, err := c.registry.Peers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.reconcilePeers(peers)
|
||||
if time.Since(c.lastStatePush) >= c.conf.StateInterval {
|
||||
c.pushState(peers)
|
||||
c.lastStatePush = time.Now()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcilePeers aligns this node's per-peer attachments with the live peer set: it retires the
|
||||
// queues (and workers) of peers that have left the registry or re-registered under a new
|
||||
// advertise URL (the retired queue's remainder was headed for a dead address anyway), and prunes
|
||||
// the stale state of departed peers. New and replacement queues are created lazily by ForwardMessage, not
|
||||
// here, so a freshly joined peer is reachable immediately.
|
||||
func (c *meshCluster) reconcilePeers(peers []*registry.Peer) {
|
||||
metrics.ClusterPeers.Set(float64(len(peers)))
|
||||
alive := make(map[NodeID]string, len(peers)) // node ID -> advertise URL
|
||||
for _, p := range peers {
|
||||
alive[NodeID(p.NodeID)] = p.AdvertiseURL
|
||||
}
|
||||
c.mu.Lock()
|
||||
// Log joins and leaves (as seen through the up-to-NodeTTL-stale registry view)
|
||||
for nodeID, url := range alive {
|
||||
if _, ok := c.knownPeers[nodeID]; !ok {
|
||||
log.Tag(tag).Info("Peer %s (%s) joined the cluster", nodeID, url)
|
||||
}
|
||||
}
|
||||
for nodeID := range c.knownPeers {
|
||||
if _, ok := alive[nodeID]; !ok {
|
||||
log.Tag(tag).Info("Peer %s left the cluster", nodeID)
|
||||
}
|
||||
}
|
||||
c.knownPeers = alive
|
||||
for nodeID, q := range c.queues {
|
||||
if url, ok := alive[nodeID]; !ok || q.advertiseURL != url {
|
||||
q.queue.Close() // Flushes the remainder; the worker exits when the queue is drained
|
||||
delete(c.queues, nodeID)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
// Prune the state of departed peers, but only once stale: state is push-driven and can
|
||||
// arrive before a new peer is visible in the (up to NodeTTL stale) registry view, so fresh
|
||||
// state must survive even when its peer is not in the live set. Without this, the states of
|
||||
// long-gone nodes would accumulate forever.
|
||||
c.statesMu.Lock()
|
||||
for nodeID, state := range c.states {
|
||||
if _, ok := alive[nodeID]; !ok && time.Since(state.updatedAt) > 3*c.conf.StateInterval {
|
||||
delete(c.states, nodeID)
|
||||
}
|
||||
}
|
||||
c.statesMu.Unlock()
|
||||
}
|
||||
|
||||
// queueFor returns the send queue for the given peer, creating it (and its delivery worker) if it
|
||||
// does not exist yet. The caller must hold c.mu.
|
||||
func (c *meshCluster) queueFor(p *registry.Peer) *peerQueue {
|
||||
nodeID := NodeID(p.NodeID)
|
||||
q, ok := c.queues[nodeID]
|
||||
if ok {
|
||||
return q
|
||||
}
|
||||
q = &peerQueue{
|
||||
advertiseURL: p.AdvertiseURL,
|
||||
queue: util.NewLingerQueue(peerQueueSize, batchMaxMessages, batchMaxBytes,
|
||||
func(frag []byte) int { return len(frag) }, c.conf.BatchLinger),
|
||||
}
|
||||
c.queues[nodeID] = q
|
||||
c.wg.Add(1)
|
||||
go c.peerWorker(nodeID, q)
|
||||
return q
|
||||
}
|
||||
|
||||
// ForwardMessage enqueues the message for delivery to every live peer node that may have subscribers for
|
||||
// its topic (all of them, absent fresh knowledge). Delivery is fire-and-forget via each peer's
|
||||
// bounded batching queue; if a peer's queue is full the message is dropped for that peer
|
||||
// (subscribers reconnect and re-poll history from the database).
|
||||
func (c *meshCluster) ForwardMessage(msg *model.Message) error {
|
||||
peers, err := c.registry.Peers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(peers) == 0 {
|
||||
return nil // Cluster of one; skip the marshal
|
||||
}
|
||||
frag, err := marshalMessage(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metrics.ClusterMessagesForwarded.Inc()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.closed {
|
||||
return nil // Shutting down; the message is dropped like any other in-flight fan-out
|
||||
}
|
||||
for _, p := range peers {
|
||||
// Route around peers whose fresh state provably excludes this topic; anything less
|
||||
// certain (no state, stale state) falls back to broadcasting
|
||||
if !c.mayNeed(NodeID(p.NodeID), msg.Topic) {
|
||||
metrics.ClusterRouteSkipped.Inc()
|
||||
if ev := log.Tag(tag); ev.IsTrace() {
|
||||
ev.Trace("Skipping peer %s for message %s: no subscribers for topic %s", p.NodeID, msg.ID, msg.Topic)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !c.queueFor(p).queue.TryEnqueue(frag) {
|
||||
metrics.ClusterQueueDropped.Inc()
|
||||
log.Tag(tag).Warn("Fan-out queue for peer %s full, dropping message %s", p.NodeID, msg.ID)
|
||||
} else if ev := log.Tag(tag); ev.IsTrace() {
|
||||
ev.Trace("Enqueued message %s (topic %s) for peer %s", msg.ID, msg.Topic, p.NodeID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mayNeed reports whether the peer may have a subscriber for the topic. Conservative by
|
||||
// construction: it returns false only when a fresh state snapshot provably excludes the topic.
|
||||
// A false positive costs one wasted send; a false negative would lose a message and cannot
|
||||
// happen for topics a peer has reported (Bloom filters have no false negatives).
|
||||
func (c *meshCluster) mayNeed(peer NodeID, topic string) bool {
|
||||
c.statesMu.Lock()
|
||||
defer c.statesMu.Unlock()
|
||||
state, ok := c.states[peer]
|
||||
if !ok || time.Since(state.updatedAt) > 3*c.conf.StateInterval {
|
||||
return true // No knowledge, or too old to trust for skipping
|
||||
}
|
||||
return state.topics.Contains(topic)
|
||||
}
|
||||
|
||||
// peerWorker delivers batches of queued fan-out messages to a single peer. Batches form in the
|
||||
// peer's LingerQueue (up to BatchLinger delay, flushed early on size/count caps); the worker
|
||||
// exits when the queue is closed (peer left the registry, or mesh shutdown) and drained.
|
||||
func (c *meshCluster) peerWorker(nodeID NodeID, q *peerQueue) {
|
||||
defer c.wg.Done()
|
||||
for frags := range q.queue.Dequeue() {
|
||||
body := assembleMessageBody(frags)
|
||||
log.Tag(tag).Debug("Sending batch of %d message(s) (%d bytes) to peer %s", len(frags), len(body), nodeID)
|
||||
c.postToPeer(nodeID, messageURL(q.advertiseURL), contentTypeNDJSON, body)
|
||||
metrics.ClusterBatchesSent.Inc()
|
||||
}
|
||||
}
|
||||
|
||||
// postToPeer POSTs a peer API payload, authenticated with the shared cluster secret. Failures
|
||||
// are logged and counted, never retried: peer traffic is best-effort by design (messages are
|
||||
// recovered via since= replay, state via the next periodic push).
|
||||
func (c *meshCluster) postToPeer(nodeID NodeID, url, contentType string, payload []byte) {
|
||||
req, err := http.NewRequestWithContext(c.ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
metrics.ClusterSendErrors.Inc()
|
||||
log.Tag(tag).Err(err).Warn("Failed to build request for peer %s", nodeID)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set(secretHeader, c.conf.Secret)
|
||||
req.Header.Set(originHeader, string(c.conf.NodeID))
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
if c.ctx.Err() == nil {
|
||||
metrics.ClusterSendErrors.Inc()
|
||||
log.Tag(tag).Err(err).Warn("Failed to send to peer %s (%s)", nodeID, url)
|
||||
}
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
metrics.ClusterSendErrors.Inc()
|
||||
log.Tag(tag).Warn("Peer %s (%s) rejected request with HTTP %d", nodeID, url, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// handleMessage receives a batch of peer messages (NDJSON) and streams them to local
|
||||
// subscribers line by line, delivering each message as it is decoded.
|
||||
func (c *meshCluster) handleMessage(origin NodeID, w http.ResponseWriter, r *http.Request) {
|
||||
// A batch can exceed its byte cap by one message, plus framing overhead
|
||||
maxBodyBytes := int64(batchMaxBytes) + c.conf.MaxMessageBytes + 1024
|
||||
received := 0
|
||||
deliver := func(m *model.Message) {
|
||||
received++
|
||||
if ev := log.Tag(tag); ev.IsTrace() {
|
||||
ev.Trace("Delivering message %s (topic %s) from peer %s", m.ID, m.Topic, origin)
|
||||
}
|
||||
c.deliver(m)
|
||||
}
|
||||
if err := decodeMessageBody(io.LimitReader(r.Body, maxBodyBytes), int(c.conf.MaxMessageBytes), deliver); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Tag(tag).Debug("Received batch of %d message(s) from peer %s", received, origin)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleState receives a peer's state envelope and applies each section it carries.
|
||||
func (c *meshCluster) handleState(origin NodeID, w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, stateMaxBytes))
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var state apiState
|
||||
if err := json.Unmarshal(body, &state); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if state.Topics != nil {
|
||||
if err := c.applyTopicState(origin, state.Topics); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// applyTopicState updates what we know about a peer's subscriptions: a full snapshot replaces
|
||||
// all prior knowledge, an incremental add merges into it. Increments without a baseline are
|
||||
// ignored on purpose -- without a snapshot the peer is broadcast to anyway.
|
||||
func (c *meshCluster) applyTopicState(origin NodeID, topics *apiStateTopics) error {
|
||||
c.statesMu.Lock()
|
||||
defer c.statesMu.Unlock()
|
||||
if len(topics.Filter) > 0 {
|
||||
filter, err := util.UnmarshalBloomFilter(topics.Filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.states[origin] = &peerState{topics: filter, updatedAt: time.Now()}
|
||||
log.Tag(tag).Debug("Received subscription state from peer %s (%d filter bytes)", origin, len(topics.Filter))
|
||||
return nil
|
||||
}
|
||||
if state, ok := c.states[origin]; ok {
|
||||
for _, topic := range topics.Added {
|
||||
state.topics.Add(topic)
|
||||
}
|
||||
state.updatedAt = time.Now()
|
||||
log.Tag(tag).Debug("Received %d announced topic(s) from peer %s", len(topics.Added), origin)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushState sends a full state snapshot to every live peer: a Bloom filter over the topics that
|
||||
// currently have local subscribers. Sent directly (not via the linger queues -- state must not
|
||||
// wait behind message batches); a lost push self-heals at the next interval. Topics without
|
||||
// subscribers disappear simply by not being in the next snapshot.
|
||||
func (c *meshCluster) pushState(peers []*registry.Peer) {
|
||||
if len(peers) == 0 {
|
||||
return
|
||||
}
|
||||
topics := c.topics()
|
||||
filter := util.NewBloomFilter(len(topics), stateFilterFPRate)
|
||||
for _, topic := range topics {
|
||||
filter.Add(topic)
|
||||
}
|
||||
data, err := filter.MarshalBinary()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(&apiState{Topics: &apiStateTopics{Filter: data}})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
log.Tag(tag).Debug("Pushing subscription state (%d topics, %d bytes) to %d peer(s)", len(topics), len(body), len(peers))
|
||||
for _, p := range peers {
|
||||
go c.postToPeer(NodeID(p.NodeID), stateURL(p.AdvertiseURL), contentTypeJSON, body)
|
||||
}
|
||||
metrics.ClusterStatePushes.Inc()
|
||||
}
|
||||
|
||||
// BroadcastState immediately tells all live peers that these topics gained their first local
|
||||
// subscriber, shrinking the window in which a publisher could wrongly skip this node from a
|
||||
// full state interval down to about one round trip.
|
||||
func (c *meshCluster) BroadcastState(state *State) {
|
||||
if len(state.AddedTopics) == 0 {
|
||||
return
|
||||
}
|
||||
peers, err := c.registry.Peers()
|
||||
if err != nil || len(peers) == 0 {
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(&apiState{Topics: &apiStateTopics{Added: state.AddedTopics}})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
log.Tag(tag).Debug("Broadcasting state (%d new topics) to %d peer(s)", len(state.AddedTopics), len(peers))
|
||||
for _, p := range peers {
|
||||
go c.postToPeer(NodeID(p.NodeID), stateURL(p.AdvertiseURL), contentTypeJSON, body)
|
||||
}
|
||||
}
|
||||
|
||||
// IsLeader reports whether this node currently holds singleton-job leadership.
|
||||
func (c *meshCluster) IsLeader() bool {
|
||||
return c.leader.IsLeader()
|
||||
}
|
||||
|
||||
// Healthy reports whether this node's registry heartbeat is fresh enough that peers still
|
||||
// forward messages to it (see the Cluster interface for the checker's fail-open duty).
|
||||
func (c *meshCluster) Healthy() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return time.Since(c.lastRegistered) < c.conf.NodeTTL
|
||||
}
|
||||
|
||||
// Close stops the mesh: it deregisters this node, releases leadership, stops all peer workers,
|
||||
// and waits for them to exit.
|
||||
func (c *meshCluster) Close() error {
|
||||
c.cancel() // Stops the heartbeat loop and aborts in-flight peer deliveries
|
||||
// Close the peer queues so their workers flush and exit; final sends are best-effort since
|
||||
// the context is already canceled (parity with fire-and-forget delivery)
|
||||
c.mu.Lock()
|
||||
c.closed = true
|
||||
for nodeID, q := range c.queues {
|
||||
q.queue.Close()
|
||||
delete(c.queues, nodeID)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
// Wait for the loops BEFORE deregistering: an in-flight heartbeat's Register would otherwise
|
||||
// re-insert our row right after Deregister deleted it
|
||||
c.wg.Wait()
|
||||
if err := c.registry.Deregister(); err != nil {
|
||||
log.Tag(tag).Err(err).Warn("Failed to deregister node")
|
||||
}
|
||||
c.leader.Close()
|
||||
metrics.ClusterLeader.Set(0)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/cluster/registry"
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
dbtest "heckel.io/ntfy/v2/db/test"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
testSecret = "s3cret"
|
||||
)
|
||||
|
||||
// openTestPool opens a dedicated connection pool to the given test schema, so that each simulated
|
||||
// node has its own pool like real nodes would.
|
||||
func openTestPool(t testing.TB, dsn string) *db.DB {
|
||||
host, err := pg.Open(dsn)
|
||||
require.Nil(t, err)
|
||||
d := db.New(host, nil)
|
||||
t.Cleanup(func() { d.Close() })
|
||||
return d
|
||||
}
|
||||
|
||||
func newTestMeshConfig(nodeID, advertiseURL string) *Config {
|
||||
return &Config{
|
||||
Enabled: true,
|
||||
NodeID: NodeID(nodeID),
|
||||
AdvertiseURL: advertiseURL,
|
||||
Secret: testSecret,
|
||||
HeartbeatInterval: 100 * time.Millisecond,
|
||||
LeaderRenewInterval: 20 * time.Millisecond, // Lease duration 60ms, hold-off 120ms; keeps leadership tests fast
|
||||
NodeTTL: time.Second, // Also the peer cache bound; short so fake peers registered mid-test are seen quickly
|
||||
MaxMessageBytes: 1 << 20,
|
||||
StateInterval: time.Minute, // Individual tests lower this to exercise state pushes
|
||||
}
|
||||
}
|
||||
|
||||
// registerFakePeer registers a fake peer via the registry (creating the table if the mesh has
|
||||
// not been constructed yet): tests register fakes before the mesh boots, since its first
|
||||
// heartbeat caches the peer list. The fake never refreshes its heartbeat.
|
||||
func registerFakePeer(t testing.TB, pool *db.DB, nodeID NodeID, url string) {
|
||||
t.Helper()
|
||||
reg, err := registry.New(pool, string(nodeID), url, time.Minute)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, reg.Register())
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, f func() bool) {
|
||||
t.Helper()
|
||||
for i := 0; i < 100; i++ {
|
||||
if f() {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("timed out waiting for condition")
|
||||
}
|
||||
|
||||
func TestMesh_CrossNodeDelivery(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
poolA, poolB := openTestPool(t, schemaDSN), openTestPool(t, schemaDSN)
|
||||
var mu sync.Mutex
|
||||
var received []*model.Message
|
||||
var meshB *meshCluster
|
||||
srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
meshB.ServeHTTP(w, r)
|
||||
}))
|
||||
defer srvB.Close()
|
||||
meshB, err := newMeshCluster(newTestMeshConfig("node-b", srvB.URL), poolB, func(m *model.Message) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
received = append(received, m)
|
||||
}, nil)
|
||||
require.Nil(t, err)
|
||||
defer meshB.Close()
|
||||
meshA, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), poolA, func(m *model.Message) {
|
||||
t.Error("node A must not receive its own relayed message")
|
||||
}, nil)
|
||||
require.Nil(t, err)
|
||||
defer meshA.Close()
|
||||
msg := model.NewDefaultMessage("mytopic", "hello cross-node")
|
||||
require.Nil(t, meshA.ForwardMessage(msg))
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(received) == 1
|
||||
})
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.Equal(t, "mytopic", received[0].Topic)
|
||||
require.Equal(t, "hello cross-node", received[0].Message)
|
||||
}
|
||||
|
||||
func TestMesh_PeerAPI_Auth(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
var delivered int
|
||||
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, func(m *model.Message) {
|
||||
delivered++
|
||||
}, nil)
|
||||
require.Nil(t, err)
|
||||
defer mesh.Close()
|
||||
frag, err := marshalMessage(model.NewDefaultMessage("mytopic", "hi"))
|
||||
require.Nil(t, err)
|
||||
payload := assembleMessageBody([][]byte{frag})
|
||||
|
||||
// Wrong secret -> 401, not delivered
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", MessagePath, strings.NewReader(string(payload)))
|
||||
req.Header.Set(secretHeader, "wrong")
|
||||
req.Header.Set(originHeader, "node-b")
|
||||
mesh.ServeHTTP(rr, req)
|
||||
require.Equal(t, 401, rr.Code)
|
||||
|
||||
// Missing secret -> 401, not delivered
|
||||
rr = httptest.NewRecorder()
|
||||
mesh.ServeHTTP(rr, httptest.NewRequest("POST", MessagePath, strings.NewReader(string(payload))))
|
||||
require.Equal(t, 401, rr.Code)
|
||||
require.Equal(t, 0, delivered)
|
||||
|
||||
// Missing origin -> 400, not delivered
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest("POST", MessagePath, strings.NewReader(string(payload)))
|
||||
req.Header.Set(secretHeader, testSecret)
|
||||
mesh.ServeHTTP(rr, req)
|
||||
require.Equal(t, 400, rr.Code)
|
||||
require.Equal(t, 0, delivered)
|
||||
|
||||
// Correct secret and origin -> 200, delivered
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest("POST", MessagePath, strings.NewReader(string(payload)))
|
||||
req.Header.Set(secretHeader, testSecret)
|
||||
req.Header.Set(originHeader, "node-b")
|
||||
mesh.ServeHTTP(rr, req)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, 1, delivered)
|
||||
}
|
||||
|
||||
func TestMesh_PeerAPI_SelfOrigin(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
var delivered int
|
||||
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, func(m *model.Message) {
|
||||
delivered++
|
||||
}, nil)
|
||||
require.Nil(t, err)
|
||||
defer mesh.Close()
|
||||
|
||||
// A request that carries this node's own broadcasts must not be re-delivered (loop prevention)
|
||||
frag, err := marshalMessage(model.NewDefaultMessage("mytopic", "loop"))
|
||||
require.Nil(t, err)
|
||||
payload := assembleMessageBody([][]byte{frag})
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", MessagePath, strings.NewReader(string(payload)))
|
||||
req.Header.Set(secretHeader, testSecret)
|
||||
req.Header.Set(originHeader, "node-a") // Same as the receiving node's ID
|
||||
mesh.ServeHTTP(rr, req)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, 0, delivered)
|
||||
}
|
||||
|
||||
func TestMesh_SlowPeerIsolation(t *testing.T) {
|
||||
// A wedged peer must not delay delivery to healthy peers: each peer has its own queue and
|
||||
// delivery worker. With a shared send queue (the design this replaces), the slow peer's
|
||||
// requests would occupy all delivery workers and starve the fast peer.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
var mu sync.Mutex
|
||||
fastReceived := 0 // Messages, not requests: with batching, one request can carry many
|
||||
srvFast := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
messages, err := unmarshalMessageBody(body, 1<<20)
|
||||
require.Nil(t, err)
|
||||
mu.Lock()
|
||||
fastReceived += len(messages)
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srvFast.Close()
|
||||
release := make(chan struct{})
|
||||
srvSlow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-release // Wedged until the end of the test
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srvSlow.Close()
|
||||
defer close(release)
|
||||
// Register the fake peers before the mesh boots; its first heartbeat caches the peer list
|
||||
for i, url := range []string{srvFast.URL, srvSlow.URL} {
|
||||
registerFakePeer(t, pool, NodeID(fmt.Sprintf("node-fake-%d", i)), url)
|
||||
}
|
||||
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer mesh.Close()
|
||||
const n = 20
|
||||
for i := 0; i < n; i++ {
|
||||
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", fmt.Sprintf("message %d", i))))
|
||||
}
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return fastReceived == n
|
||||
})
|
||||
}
|
||||
|
||||
func TestMesh_BatchCoalescing(t *testing.T) {
|
||||
// Messages published within the linger window arrive as batches: fewer HTTP requests than
|
||||
// messages, with nothing lost. Fails against a one-request-per-message sender.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
var mu sync.Mutex
|
||||
requests, messages := 0, 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
decoded, err := unmarshalMessageBody(body, 1<<20)
|
||||
require.Nil(t, err)
|
||||
mu.Lock()
|
||||
requests++
|
||||
messages += len(decoded)
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
registerFakePeer(t, pool, "node-fake", srv.URL)
|
||||
conf := newTestMeshConfig("node-a", "http://127.0.0.1:1")
|
||||
conf.BatchLinger = 150 * time.Millisecond
|
||||
mesh, err := newMeshCluster(conf, pool, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer mesh.Close()
|
||||
const n = 20
|
||||
for i := 0; i < n; i++ {
|
||||
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", fmt.Sprintf("message %d", i))))
|
||||
}
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return messages == n
|
||||
})
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.Less(t, requests, 5, "expected %d messages coalesced into few requests, got %d", n, requests)
|
||||
}
|
||||
|
||||
func TestMesh_DeadPeerRemovedAndRejoin(t *testing.T) {
|
||||
// A peer that dies ungracefully (no Deregister) stops refreshing its heartbeat: after the
|
||||
// TTL it no longer counts as live (no more sends), its queue/worker are reconciled away, the
|
||||
// leader prunes its registry row, and a re-registered peer starts receiving again.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
var mu sync.Mutex
|
||||
received := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
messages, err := unmarshalMessageBody(body, 1<<20)
|
||||
require.Nil(t, err)
|
||||
mu.Lock()
|
||||
received += len(messages)
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
conf := newTestMeshConfig("node-a", "http://127.0.0.1:1")
|
||||
conf.NodeTTL = 300 * time.Millisecond // Fast expiry so the test observes TTL-based removal
|
||||
mesh, err := newMeshCluster(conf, pool, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer mesh.Close()
|
||||
// The fake peer registers once and then "dies": its heartbeat is never refreshed
|
||||
registerFakePeer(t, pool, "node-dead", srv.URL)
|
||||
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", "while alive")))
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return received == 1
|
||||
})
|
||||
// After the TTL, the peer is no longer live: its queue is reconciled away and its registry
|
||||
// row is pruned by the leader (this mesh is the only real node, so it holds the lock)
|
||||
waitFor(t, func() bool {
|
||||
mesh.mu.Lock()
|
||||
defer mesh.mu.Unlock()
|
||||
return len(mesh.queues) == 0
|
||||
})
|
||||
waitFor(t, func() bool {
|
||||
var count int
|
||||
require.Nil(t, pool.QueryRow(`SELECT COUNT(*) FROM node_registry WHERE node_id = 'node-dead'`).Scan(&count))
|
||||
return count == 0
|
||||
})
|
||||
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", "while dead")))
|
||||
time.Sleep(250 * time.Millisecond) // Give a wrong implementation time to deliver anyway
|
||||
mu.Lock()
|
||||
require.Equal(t, 1, received) // Only the first message arrived
|
||||
mu.Unlock()
|
||||
// The peer comes back (same node ID, fresh heartbeat) and receives messages again; the
|
||||
// relay retries because the peer list is cached for up to the node TTL
|
||||
registerFakePeer(t, pool, "node-dead", srv.URL)
|
||||
waitFor(t, func() bool {
|
||||
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", "after rejoin")))
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return received > 1
|
||||
})
|
||||
}
|
||||
|
||||
func TestMesh_ForwardAfterClose(t *testing.T) {
|
||||
// A ForwardMessage racing shutdown (e.g. an in-flight publish during server Stop) must not spawn
|
||||
// a new peer queue and worker after Close: the worker would never exit (its queue is never
|
||||
// closed) and nothing waits for it.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
|
||||
require.Nil(t, err)
|
||||
registerFakePeer(t, pool, "node-peer", "http://127.0.0.1:1")
|
||||
require.Nil(t, mesh.Close())
|
||||
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", "too late"))) // Dropped silently
|
||||
mesh.mu.Lock()
|
||||
defer mesh.mu.Unlock()
|
||||
require.Empty(t, mesh.queues)
|
||||
}
|
||||
|
||||
func TestMesh_LeaderFailover(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
poolA, poolB := openTestPool(t, schemaDSN), openTestPool(t, schemaDSN)
|
||||
meshA, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), poolA, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer meshA.Close()
|
||||
meshB, err := newMeshCluster(newTestMeshConfig("node-b", "http://127.0.0.1:1"), poolB, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer meshB.Close()
|
||||
// Exactly one node becomes leader
|
||||
waitFor(t, func() bool {
|
||||
return meshA.IsLeader() != meshB.IsLeader() // Exactly one
|
||||
})
|
||||
// The leader steps down; the follower takes over
|
||||
leader, follower := meshA, meshB
|
||||
if meshB.IsLeader() {
|
||||
leader, follower = meshB, meshA
|
||||
}
|
||||
require.Nil(t, leader.Close())
|
||||
waitFor(t, follower.IsLeader)
|
||||
}
|
||||
|
||||
func TestMesh_CloseDeregisters(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
|
||||
require.Nil(t, err)
|
||||
var count int
|
||||
require.Nil(t, pool.QueryRow(`SELECT COUNT(*) FROM node_registry WHERE node_id = 'node-a'`).Scan(&count))
|
||||
require.Equal(t, 1, count)
|
||||
require.Nil(t, mesh.Close())
|
||||
require.Nil(t, pool.QueryRow(`SELECT COUNT(*) FROM node_registry WHERE node_id = 'node-a'`).Scan(&count))
|
||||
require.Equal(t, 0, count)
|
||||
}
|
||||
|
||||
// postState delivers a state envelope to a mesh's peer API, as a peer would.
|
||||
func postState(c *meshCluster, origin NodeID, state *apiState) *httptest.ResponseRecorder {
|
||||
body, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", StatePath, bytes.NewReader(body))
|
||||
req.Header.Set(secretHeader, testSecret)
|
||||
req.Header.Set(originHeader, string(origin))
|
||||
c.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
// topicFilter builds a marshaled Bloom filter over the given topics.
|
||||
func topicFilter(t *testing.T, topics ...string) []byte {
|
||||
t.Helper()
|
||||
filter := util.NewBloomFilter(len(topics), 0.01)
|
||||
for _, topic := range topics {
|
||||
filter.Add(topic)
|
||||
}
|
||||
data, err := filter.MarshalBinary()
|
||||
require.Nil(t, err)
|
||||
return data
|
||||
}
|
||||
|
||||
func TestMesh_RouteSkipsUnsubscribedPeer(t *testing.T) {
|
||||
// A peer whose fresh state provably excludes a topic is not contacted for it; a topic in its
|
||||
// state is delivered as usual.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
var mu sync.Mutex
|
||||
received := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
messages, err := unmarshalMessageBody(body, 1<<20)
|
||||
require.Nil(t, err)
|
||||
mu.Lock()
|
||||
received += len(messages)
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
registerFakePeer(t, pool, "node-b", srv.URL)
|
||||
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer mesh.Close()
|
||||
// node-b reports subscribers only for "subscribed-topic"
|
||||
rr := postState(mesh, "node-b", &apiState{Topics: &apiStateTopics{Filter: topicFilter(t, "subscribed-topic")}})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
// A topic outside the peer's state is skipped
|
||||
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("other-topic", "skipped")))
|
||||
time.Sleep(300 * time.Millisecond) // Give a wrong implementation time to deliver anyway
|
||||
mu.Lock()
|
||||
require.Equal(t, 0, received)
|
||||
mu.Unlock()
|
||||
// A topic inside the peer's state is delivered
|
||||
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("subscribed-topic", "delivered")))
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return received == 1
|
||||
})
|
||||
}
|
||||
|
||||
func TestMesh_RouteBroadcastsOnStaleState(t *testing.T) {
|
||||
// State too old to trust cannot justify skipping: the peer is broadcast to as if unknown.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
var mu sync.Mutex
|
||||
received := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == MessagePath { // The mesh also pushes state here; count only messages
|
||||
mu.Lock()
|
||||
received++
|
||||
mu.Unlock()
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
registerFakePeer(t, pool, "node-b", srv.URL)
|
||||
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer mesh.Close()
|
||||
rr := postState(mesh, "node-b", &apiState{Topics: &apiStateTopics{Filter: topicFilter(t, "subscribed-topic")}})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
// Age the state beyond the trust window
|
||||
mesh.statesMu.Lock()
|
||||
mesh.states["node-b"].updatedAt = time.Now().Add(-time.Hour)
|
||||
mesh.statesMu.Unlock()
|
||||
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("other-topic", "broadcast anyway")))
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return received == 1
|
||||
})
|
||||
}
|
||||
|
||||
func TestMesh_StatePushReplacesAndRemoves(t *testing.T) {
|
||||
// Node A periodically pushes a full snapshot of its live topics to node B; each snapshot
|
||||
// REPLACES B's knowledge, so topics that lost their subscribers disappear without any
|
||||
// explicit removal protocol.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
poolA, poolB := openTestPool(t, schemaDSN), openTestPool(t, schemaDSN)
|
||||
var topicsMu sync.Mutex
|
||||
topicsA := []string{"topic-1"}
|
||||
source := func() []string {
|
||||
topicsMu.Lock()
|
||||
defer topicsMu.Unlock()
|
||||
return append([]string{}, topicsA...)
|
||||
}
|
||||
var meshB *meshCluster
|
||||
srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
meshB.ServeHTTP(w, r)
|
||||
}))
|
||||
defer srvB.Close()
|
||||
meshB, err := newMeshCluster(newTestMeshConfig("node-b", srvB.URL), poolB, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer meshB.Close()
|
||||
confA := newTestMeshConfig("node-a", "http://127.0.0.1:1")
|
||||
confA.StateInterval = 200 * time.Millisecond
|
||||
meshA, err := newMeshCluster(confA, poolA, nil, source)
|
||||
require.Nil(t, err)
|
||||
defer meshA.Close()
|
||||
// B learns A's topics via the periodic push
|
||||
knows := func(topic string) func() bool {
|
||||
return func() bool {
|
||||
meshB.statesMu.Lock()
|
||||
defer meshB.statesMu.Unlock()
|
||||
state, ok := meshB.states["node-a"]
|
||||
return ok && state.topics.Contains(topic)
|
||||
}
|
||||
}
|
||||
waitFor(t, knows("topic-1"))
|
||||
// A's subscribers change; the next snapshot replaces the old knowledge entirely
|
||||
topicsMu.Lock()
|
||||
topicsA = []string{"topic-2"}
|
||||
topicsMu.Unlock()
|
||||
waitFor(t, knows("topic-2"))
|
||||
waitFor(t, func() bool { return !knows("topic-1")() })
|
||||
}
|
||||
|
||||
func TestMesh_AnnounceClosesWindow(t *testing.T) {
|
||||
// A topic gaining its first subscriber is announced immediately, so peers learn about it
|
||||
// without waiting for the next full state push.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
poolA, poolB := openTestPool(t, schemaDSN), openTestPool(t, schemaDSN)
|
||||
var meshB *meshCluster
|
||||
srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
meshB.ServeHTTP(w, r)
|
||||
}))
|
||||
defer srvB.Close()
|
||||
meshB, err := newMeshCluster(newTestMeshConfig("node-b", srvB.URL), poolB, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer meshB.Close()
|
||||
confA := newTestMeshConfig("node-a", "http://127.0.0.1:1")
|
||||
confA.StateInterval = 200 * time.Millisecond // One full push establishes the baseline
|
||||
meshA, err := newMeshCluster(confA, poolA, nil, func() []string { return []string{"existing"} })
|
||||
require.Nil(t, err)
|
||||
defer meshA.Close()
|
||||
waitFor(t, func() bool {
|
||||
meshB.statesMu.Lock()
|
||||
defer meshB.statesMu.Unlock()
|
||||
_, ok := meshB.states["node-a"]
|
||||
return ok
|
||||
})
|
||||
// Announcements merge into the baseline right away
|
||||
meshA.BroadcastState(&State{AddedTopics: []string{"fresh-topic"}})
|
||||
waitFor(t, func() bool {
|
||||
meshB.statesMu.Lock()
|
||||
defer meshB.statesMu.Unlock()
|
||||
state, ok := meshB.states["node-a"]
|
||||
return ok && state.topics.Contains("fresh-topic")
|
||||
})
|
||||
}
|
||||
|
||||
func TestMesh_StateOfDepartedPeerPruned(t *testing.T) {
|
||||
// peerState is push-driven and can arrive before the peer is visible in the registry, so it
|
||||
// must survive reconcile while fresh -- but a departed peer's state must not leak forever:
|
||||
// once it is both absent from the registry and stale past the trust window, it is pruned.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer mesh.Close()
|
||||
rr := postState(mesh, "node-gone", &apiState{Topics: &apiStateTopics{Filter: topicFilter(t, "some-topic")}})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
// Fresh state of an unknown peer survives reconcile (the new-node visibility window)
|
||||
mesh.reconcilePeers(nil)
|
||||
mesh.statesMu.Lock()
|
||||
_, ok := mesh.states["node-gone"]
|
||||
mesh.statesMu.Unlock()
|
||||
require.True(t, ok)
|
||||
// Stale state of an absent peer is pruned
|
||||
mesh.statesMu.Lock()
|
||||
mesh.states["node-gone"].updatedAt = time.Now().Add(-time.Hour)
|
||||
mesh.statesMu.Unlock()
|
||||
mesh.reconcilePeers(nil)
|
||||
mesh.statesMu.Lock()
|
||||
_, ok = mesh.states["node-gone"]
|
||||
mesh.statesMu.Unlock()
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
func TestMesh_HealthyReflectsRegistration(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer mesh.Close()
|
||||
require.True(t, mesh.Healthy()) // Registered synchronously at construction
|
||||
// Stale heartbeat: peers stop forwarding to this node, so it must report unhealthy
|
||||
mesh.mu.Lock()
|
||||
mesh.lastRegistered = time.Now().Add(-2 * mesh.conf.NodeTTL)
|
||||
mesh.mu.Unlock()
|
||||
require.False(t, mesh.Healthy())
|
||||
// A successful heartbeat restores health
|
||||
require.Nil(t, mesh.heartbeat())
|
||||
require.True(t, mesh.Healthy())
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
// nopCluster is the single-node default: it drops all relayed messages, rejects peer API requests, and
|
||||
// reports this node as leader (a single node is trivially the leader, so leader-gated jobs need
|
||||
// no special-casing in single-node mode).
|
||||
type nopCluster struct{}
|
||||
|
||||
func (c *nopCluster) ForwardMessage(_ *model.Message) error { return nil }
|
||||
|
||||
func (c *nopCluster) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
|
||||
func (c *nopCluster) BroadcastState(_ *State) {}
|
||||
|
||||
func (c *nopCluster) IsLeader() bool { return true }
|
||||
|
||||
func (c *nopCluster) Healthy() bool { return true }
|
||||
|
||||
func (c *nopCluster) Close() error { return nil }
|
||||
@@ -0,0 +1,84 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
func TestDeliver_RoundTrip(t *testing.T) {
|
||||
// The fan-out body is NDJSON: one apiDeliverMessage per line, joined from pre-marshaled
|
||||
// fragments; the origin travels in a header, not the body
|
||||
m1 := model.NewDefaultMessage("mytopic", "my message")
|
||||
m1.Sender = netip.MustParseAddr("1.2.3.4")
|
||||
m1.User = "u_abc"
|
||||
m2 := model.NewDefaultMessage("othertopic", "other message")
|
||||
frag1, err := marshalMessage(m1)
|
||||
require.Nil(t, err)
|
||||
frag2, err := marshalMessage(m2)
|
||||
require.Nil(t, err)
|
||||
messages, err := unmarshalMessageBody(assembleMessageBody([][]byte{frag1, frag2}), 1<<20)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, messages, 2)
|
||||
require.Equal(t, "mytopic", messages[0].Topic)
|
||||
require.Equal(t, "my message", messages[0].Message)
|
||||
// Sender and User are json:"-" on model.Message; the lines must carry and reattach them
|
||||
require.Equal(t, netip.MustParseAddr("1.2.3.4"), messages[0].Sender)
|
||||
require.Equal(t, "u_abc", messages[0].User)
|
||||
require.Equal(t, "othertopic", messages[1].Topic)
|
||||
require.False(t, messages[1].Sender.IsValid())
|
||||
}
|
||||
|
||||
func TestDeliver_SingleMessage(t *testing.T) {
|
||||
// A single message is just a one-line body; there is no separate single-message format
|
||||
frag, err := marshalMessage(model.NewDefaultMessage("mytopic", "hi"))
|
||||
require.Nil(t, err)
|
||||
messages, err := unmarshalMessageBody(assembleMessageBody([][]byte{frag}), 1<<20)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, messages, 1)
|
||||
}
|
||||
|
||||
func TestDeliver_MalformedLinesSkipped(t *testing.T) {
|
||||
// Fan-out is fire-and-forget: a malformed or message-less line is skipped (and logged), the
|
||||
// remaining lines are still delivered
|
||||
frag, err := marshalMessage(model.NewDefaultMessage("mytopic", "good"))
|
||||
require.Nil(t, err)
|
||||
body := []byte("this is not json\n{\"sender\":\"1.2.3.4\"}\n" + string(frag) + "\n\n")
|
||||
messages, err := unmarshalMessageBody(body, 1<<20)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, messages, 1)
|
||||
require.Equal(t, "good", messages[0].Message)
|
||||
}
|
||||
|
||||
// unmarshalMessageBody is a test helper collecting the messages of an NDJSON message body.
|
||||
func unmarshalMessageBody(body []byte, maxLineBytes int) ([]*model.Message, error) {
|
||||
var messages []*model.Message
|
||||
err := decodeMessageBody(bytes.NewReader(body), maxLineBytes, func(m *model.Message) {
|
||||
messages = append(messages, m)
|
||||
})
|
||||
return messages, err
|
||||
}
|
||||
|
||||
func TestNop(t *testing.T) {
|
||||
b, err := New(&Config{}, nil, nil, nil) // not enabled -> nop cluster, no database required
|
||||
require.Nil(t, err)
|
||||
require.IsType(t, &nopCluster{}, b)
|
||||
require.Nil(t, b.ForwardMessage(model.NewDefaultMessage("mytopic", "hi")))
|
||||
// A single node is trivially the leader, so leader-gated jobs run without special-casing
|
||||
require.True(t, b.IsLeader())
|
||||
require.True(t, b.Healthy())
|
||||
rr := httptest.NewRecorder()
|
||||
b.ServeHTTP(rr, httptest.NewRequest("POST", MessagePath, nil))
|
||||
require.Equal(t, 404, rr.Code)
|
||||
require.Nil(t, b.Close())
|
||||
}
|
||||
|
||||
func TestNew_EnabledRequiresDatabase(t *testing.T) {
|
||||
_, err := New(&Config{Enabled: true, Secret: "secret"}, nil, nil, nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "database")
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Package registry implements cluster membership: each node upserts its own row into the
|
||||
// node_registry table with a fresh heartbeat, and discovers its peers by reading the other
|
||||
// fresh rows. Node IDs are plain strings here; the cluster package layers its NodeID type on
|
||||
// top.
|
||||
package registry
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/schema"
|
||||
)
|
||||
|
||||
// Registry queries
|
||||
const (
|
||||
upsertNodeQuery = `
|
||||
INSERT INTO node_registry (node_id, advertise_url, last_heartbeat)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (node_id) DO UPDATE SET advertise_url = EXCLUDED.advertise_url, last_heartbeat = EXCLUDED.last_heartbeat
|
||||
`
|
||||
selectPeersQuery = `SELECT node_id, advertise_url FROM node_registry WHERE last_heartbeat >= $1 AND node_id != $2`
|
||||
pruneStaleNodesQuery = `DELETE FROM node_registry WHERE last_heartbeat < $1`
|
||||
deleteNodeQuery = `DELETE FROM node_registry WHERE node_id = $1`
|
||||
)
|
||||
|
||||
// Schema version and queries
|
||||
|
||||
const (
|
||||
schemaVersion = 1
|
||||
schemaStoreKey = "node_registry"
|
||||
)
|
||||
|
||||
var (
|
||||
createTable = schema.AsMigrateFunc(`
|
||||
CREATE TABLE IF NOT EXISTS node_registry (
|
||||
node_id TEXT PRIMARY KEY,
|
||||
advertise_url TEXT NOT NULL,
|
||||
last_heartbeat BIGINT NOT NULL
|
||||
)
|
||||
`)
|
||||
)
|
||||
|
||||
// Peer is a live remote node as read from the registry.
|
||||
type Peer struct {
|
||||
NodeID string
|
||||
AdvertiseURL string
|
||||
}
|
||||
|
||||
// Registry is the node membership table (control plane): each node upserts its own row with a
|
||||
// fresh heartbeat every few seconds, and peers are the other rows with a heartbeat newer than
|
||||
// the TTL. Stale rows are pruned by the leader. The TTL bounds membership staleness in BOTH
|
||||
// directions: how long a silent node still counts as live, and how long the cached peer list is
|
||||
// served before a re-read -- so a new node may take up to a TTL to become visible.
|
||||
type Registry struct {
|
||||
pool *db.DB
|
||||
nodeID string
|
||||
advertiseURL string
|
||||
ttl time.Duration
|
||||
peers []*Peer // cached peer list
|
||||
peersFetched time.Time
|
||||
mu sync.Mutex // Protects peers and peersFetched
|
||||
}
|
||||
|
||||
// New creates or migrates the registry schema and returns this node's membership handle. It
|
||||
// does NOT register the node: joining the cluster is an explicit Register call, owned by the
|
||||
// caller, so read-only uses of the registry stay side-effect free.
|
||||
func New(pool *db.DB, nodeID, advertiseURL string, ttl time.Duration) (*Registry, error) {
|
||||
if err := schema.Migrate(pool.Primary(), schema.Postgres, schemaStoreKey, schemaVersion, createTable, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Registry{
|
||||
pool: pool,
|
||||
nodeID: nodeID,
|
||||
advertiseURL: advertiseURL,
|
||||
ttl: ttl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Register upserts this node into the registry with a fresh heartbeat. It is a pure write: it
|
||||
// does not touch the peer cache, because our own row is excluded from Peers() anyway.
|
||||
func (r *Registry) Register() error {
|
||||
_, err := r.pool.Exec(upsertNodeQuery, r.nodeID, r.advertiseURL, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// Peers returns the current set of live peer nodes (all registry rows with a fresh heartbeat,
|
||||
// excluding this node), cached for the TTL.
|
||||
func (r *Registry) Peers() ([]*Peer, error) {
|
||||
r.mu.Lock()
|
||||
if r.peers != nil && time.Since(r.peersFetched) < r.ttl {
|
||||
peers := r.peers
|
||||
r.mu.Unlock()
|
||||
return peers, nil
|
||||
}
|
||||
r.mu.Unlock()
|
||||
peers, err := r.queryPeers()
|
||||
if err != nil {
|
||||
// Serve the last-known peer list during database hiccups: fan-out keeps flowing to
|
||||
// known peers instead of erroring (and logging) once per published message for the
|
||||
// duration of the outage. Dead peers in the stale list only cost failed sends.
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.peers != nil {
|
||||
return r.peers, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.peers = peers
|
||||
r.peersFetched = time.Now()
|
||||
r.mu.Unlock()
|
||||
return peers, nil
|
||||
}
|
||||
|
||||
// Prune deletes registry rows whose heartbeat is long expired. Only the leader calls this; the
|
||||
// grace period of 3x the TTL avoids deleting rows of nodes that are merely slow to heartbeat.
|
||||
func (r *Registry) Prune() error {
|
||||
_, err := r.pool.Exec(pruneStaleNodesQuery, time.Now().Add(-3*r.ttl).Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// Deregister deletes this node's registry row; called on shutdown.
|
||||
func (r *Registry) Deregister() error {
|
||||
_, err := r.pool.Exec(deleteNodeQuery, r.nodeID)
|
||||
return err
|
||||
}
|
||||
|
||||
// queryPeers reads the current live peer set from the registry table.
|
||||
func (r *Registry) queryPeers() ([]*Peer, error) {
|
||||
cutoff := time.Now().Add(-r.ttl).Unix()
|
||||
rows, err := r.pool.Query(selectPeersQuery, cutoff, r.nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
peers := make([]*Peer, 0)
|
||||
for rows.Next() {
|
||||
p := &Peer{}
|
||||
if err := rows.Scan(&p.NodeID, &p.AdvertiseURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
peers = append(peers, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return peers, nil
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
dbtest "heckel.io/ntfy/v2/db/test"
|
||||
)
|
||||
|
||||
func openTestPool(t *testing.T, dsn string) *db.DB {
|
||||
t.Helper()
|
||||
host, err := pg.Open(dsn)
|
||||
require.Nil(t, err)
|
||||
d := db.New(host, nil)
|
||||
t.Cleanup(func() { d.Close() })
|
||||
return d
|
||||
}
|
||||
|
||||
func TestRegistry_NewDoesNotRegister(t *testing.T) {
|
||||
// New only sets up the schema and the identity handle; joining the cluster is an explicit
|
||||
// Register call, owned by the caller (the mesh registers synchronously at construction).
|
||||
// This keeps read-only uses (ops tooling, future admin endpoints) side-effect free.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, countRows(t, pool, "node-1"))
|
||||
require.Nil(t, r1.Register())
|
||||
require.Equal(t, 1, countRows(t, pool, "node-1"))
|
||||
}
|
||||
|
||||
func TestRegistry_RegisterAndPeers(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, r1.Register())
|
||||
r2, err := New(pool, "node-2", "http://10.0.0.2:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, r2.Register())
|
||||
// Each node sees the other, never itself
|
||||
peers, err := r1.Peers()
|
||||
require.Nil(t, err)
|
||||
require.Len(t, peers, 1)
|
||||
require.Equal(t, "node-2", peers[0].NodeID)
|
||||
require.Equal(t, "http://10.0.0.2:2587", peers[0].AdvertiseURL)
|
||||
peers, err = r2.Peers()
|
||||
require.Nil(t, err)
|
||||
require.Len(t, peers, 1)
|
||||
require.Equal(t, "node-1", peers[0].NodeID)
|
||||
}
|
||||
|
||||
func TestRegistry_ReRegisterUpdatesAdvertiseURL(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
// The same node comes back under a new address; the upsert replaces the row
|
||||
old, err := New(pool, "node-2", "http://old:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, old.Register())
|
||||
renewed, err := New(pool, "node-2", "http://new:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, renewed.Register())
|
||||
expireCache(r1)
|
||||
peers, err := r1.Peers()
|
||||
require.Nil(t, err)
|
||||
require.Len(t, peers, 1)
|
||||
require.Equal(t, "http://new:2587", peers[0].AdvertiseURL)
|
||||
}
|
||||
|
||||
func TestRegistry_PeersCachedForTTL(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
peers, err := r1.Peers()
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, peers)
|
||||
// A node joining after the cache was populated is invisible until the cache expires
|
||||
r2, err := New(pool, "node-2", "http://10.0.0.2:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, r2.Register())
|
||||
peers, err = r1.Peers()
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, peers)
|
||||
expireCache(r1)
|
||||
peers, err = r1.Peers()
|
||||
require.Nil(t, err)
|
||||
require.Len(t, peers, 1)
|
||||
}
|
||||
|
||||
func TestRegistry_TTLExcludesSilentNodes(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
// A node whose heartbeat is older than the TTL does not count as live
|
||||
_, err = pool.Exec(upsertNodeQuery, "node-silent", "http://10.0.0.9:2587", time.Now().Add(-2*time.Minute).Unix())
|
||||
require.Nil(t, err)
|
||||
peers, err := r1.Peers()
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, peers)
|
||||
}
|
||||
|
||||
func TestRegistry_PruneDeletesLongDeadOnly(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
// One node beyond the 3x TTL grace period, one merely stale
|
||||
_, err = pool.Exec(upsertNodeQuery, "node-long-dead", "http://10.0.0.8:2587", time.Now().Add(-4*time.Minute).Unix())
|
||||
require.Nil(t, err)
|
||||
_, err = pool.Exec(upsertNodeQuery, "node-slow", "http://10.0.0.9:2587", time.Now().Add(-2*time.Minute).Unix())
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, r1.Prune())
|
||||
require.Equal(t, 0, countRows(t, pool, "node-long-dead"))
|
||||
require.Equal(t, 1, countRows(t, pool, "node-slow")) // Slow, not dead: kept
|
||||
}
|
||||
|
||||
func TestRegistry_Deregister(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, r1.Register())
|
||||
require.Equal(t, 1, countRows(t, pool, "node-1"))
|
||||
require.Nil(t, r1.Deregister())
|
||||
require.Equal(t, 0, countRows(t, pool, "node-1"))
|
||||
}
|
||||
|
||||
func TestRegistry_PeersStaleCacheOnError(t *testing.T) {
|
||||
// During a database hiccup, Peers serves the last-known peer list instead of erroring:
|
||||
// fan-out keeps flowing to known peers, and the publish path does not log a warning per
|
||||
// message for the duration of the outage.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
r2, err := New(pool, "node-2", "http://10.0.0.2:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, r2.Register())
|
||||
peers, err := r1.Peers()
|
||||
require.Nil(t, err)
|
||||
require.Len(t, peers, 1)
|
||||
// Expire the cache and break the database; the stale list must still be served
|
||||
expireCache(r1)
|
||||
require.Nil(t, pool.Close())
|
||||
peers, err = r1.Peers()
|
||||
require.Nil(t, err)
|
||||
require.Len(t, peers, 1)
|
||||
require.Equal(t, "node-2", peers[0].NodeID)
|
||||
}
|
||||
|
||||
func TestRegistry_ConcurrentCreate(t *testing.T) {
|
||||
// Multiple nodes cold-booting on a fresh database must not race on table creation: CREATE
|
||||
// TABLE IF NOT EXISTS is not atomic in PostgreSQL, so creation is serialized via an advisory
|
||||
// lock. Without it, this test fails sporadically with a duplicate-key error on pg_class.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
const n = 8
|
||||
errs := make(chan error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
go func(i int) {
|
||||
pool, err := pg.Open(schemaDSN)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
defer pool.DB.Close()
|
||||
_, err = New(db.New(pool, nil), fmt.Sprintf("node-%d", i), "http://127.0.0.1:1", time.Second)
|
||||
errs <- err
|
||||
}(i)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
require.Nil(t, <-errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_SchemaVersionWritten(t *testing.T) {
|
||||
// The registry participates in the shared schema_version framework like every other store,
|
||||
// so future table changes can be applied as migrations.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
_, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
var version int
|
||||
require.Nil(t, pool.QueryRow(`SELECT version FROM schema_version WHERE store = $1`, schemaStoreKey).Scan(&version))
|
||||
require.Equal(t, schemaVersion, version)
|
||||
// Setup is idempotent: a second node boots against the migrated schema
|
||||
_, err = New(pool, "node-2", "http://10.0.0.2:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestRegistry_SchemaVersionFromTheFuture(t *testing.T) {
|
||||
// A node running older code must refuse to touch a schema migrated by newer code
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
_, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
_, err = pool.Exec(`UPDATE schema_version SET version = 99 WHERE store = $1`, schemaStoreKey)
|
||||
require.Nil(t, err)
|
||||
_, err = New(pool, "node-2", "http://10.0.0.2:2587", time.Minute)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// expireCache forces the next Peers() call to re-read the registry table.
|
||||
func expireCache(r *Registry) {
|
||||
r.mu.Lock()
|
||||
r.peersFetched = time.Time{}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func countRows(t *testing.T, pool *db.DB, nodeID string) int {
|
||||
t.Helper()
|
||||
var count int
|
||||
require.Nil(t, pool.QueryRow(`SELECT COUNT(*) FROM node_registry WHERE node_id = $1`, nodeID).Scan(&count))
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
// Config configures the cluster. It is assembled by the server from its own config, which keeps
|
||||
// this package free of server types.
|
||||
type Config struct {
|
||||
Enabled bool // Master switch; when false, New returns the nop cluster
|
||||
NodeID NodeID // Stable per-node identifier; required
|
||||
AdvertiseURL string // Base URL peers use to reach this node's fan-out endpoint
|
||||
Secret string // Shared secret authenticating node-to-node fan-out requests
|
||||
HeartbeatInterval time.Duration // How often the node registry heartbeat is refreshed
|
||||
NodeTTL time.Duration // Registry rows older than this do not count as live peers
|
||||
BatchLinger time.Duration // How long messages wait in a peer queue to form a batch; 0 = send immediately
|
||||
StateInterval time.Duration // How often the full subscription state is pushed to peers
|
||||
MaxMessageBytes int64 // Upper bound for a single message on the wire (batch limits derive from this)
|
||||
LeaderRenewInterval time.Duration // Overrides the leader lease renewal cadence; tests only, 0 = default
|
||||
}
|
||||
|
||||
// DeliverFunc hands a message received from a peer node to this node's local subscribers. The
|
||||
// server supplies it, which inverts the dependency: this package never imports the server.
|
||||
type DeliverFunc func(m *model.Message)
|
||||
|
||||
// State is a subscription-state delta for Cluster.BroadcastState.
|
||||
type State struct {
|
||||
AddedTopics []string // Topics that just gained their first local subscriber on this node
|
||||
}
|
||||
|
||||
// TopicsFunc returns the topics that currently have at least one live subscriber, computed
|
||||
// fresh on every call: membership is never tracked as a list, so topics "leave" simply by not
|
||||
// appearing in the next snapshot. The server supplies it (same inversion as DeliverFunc).
|
||||
type TopicsFunc func() []string
|
||||
|
||||
// apiMessage is one line of a message request body (NDJSON: one message per line; a single
|
||||
// message is just a one-line body). It carries the two fields that model.Message does not
|
||||
// serialize to JSON (Sender and User), which are needed to reconstruct the visitor on the
|
||||
// receiving node. The origin node travels in a request header, not in the body.
|
||||
type apiMessage struct {
|
||||
Sender string `json:"sender,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
Message *model.Message `json:"message"`
|
||||
}
|
||||
|
||||
// apiState is the peer state-exchange envelope. Each concern is an optional section; future
|
||||
// concerns (rate limit counters, stats) become siblings of Topics.
|
||||
type apiState struct {
|
||||
Topics *apiStateTopics `json:"topics,omitempty"`
|
||||
}
|
||||
|
||||
// apiStateTopics carries a peer's subscription knowledge: either a full snapshot (Filter, a
|
||||
// marshaled Bloom filter over the topics with live subscribers) replacing all prior knowledge,
|
||||
// or an incremental update (Added) merged into it.
|
||||
type apiStateTopics struct {
|
||||
Filter []byte `json:"filter,omitempty"`
|
||||
Added []string `json:"added,omitempty"`
|
||||
}
|
||||
|
||||
// peerState is what a peer last told us about itself; ForwardMessage routes around peers whose
|
||||
// fresh state provably excludes a topic.
|
||||
type peerState struct {
|
||||
topics *util.BloomFilter
|
||||
updatedAt time.Time
|
||||
}
|
||||
|
||||
// peerQueue is the bounded, batching send queue for a single peer, pinned to the advertise URL
|
||||
// the peer was created with: a peer re-registering under a different advertise URL is treated
|
||||
// as a replacement (reconcile retires the old queue; ForwardMessage creates a fresh one on demand).
|
||||
type peerQueue struct {
|
||||
advertiseURL string
|
||||
queue *util.LingerQueue[[]byte] // pre-marshaled apiMessage fragments
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
// messageURL derives the peer's message endpoint URL from its advertise URL.
|
||||
func messageURL(advertiseURL string) string {
|
||||
return strings.TrimRight(advertiseURL, "/") + MessagePath
|
||||
}
|
||||
|
||||
// stateURL derives the peer's state endpoint URL from its advertise URL.
|
||||
func stateURL(advertiseURL string) string {
|
||||
return strings.TrimRight(advertiseURL, "/") + StatePath
|
||||
}
|
||||
|
||||
// marshalMessage serializes one message and its non-JSON fields (Sender, User) as an
|
||||
// apiMessage line. Lines are marshaled once per publish and shared across all per-peer
|
||||
// queues; assembleMessageBody joins them without re-marshaling.
|
||||
func marshalMessage(m *model.Message) ([]byte, error) {
|
||||
apiMsg := &apiMessage{User: m.User, Message: m}
|
||||
if m.Sender.IsValid() {
|
||||
apiMsg.Sender = m.Sender.String()
|
||||
}
|
||||
return json.Marshal(apiMsg)
|
||||
}
|
||||
|
||||
// assembleMessageBody builds an NDJSON fan-out request body from pre-marshaled apiMessage
|
||||
// lines, avoiding a second JSON marshal of the messages.
|
||||
func assembleMessageBody(frags [][]byte) []byte {
|
||||
return append(bytes.Join(frags, []byte("\n")), '\n')
|
||||
}
|
||||
|
||||
// decodeMessageBody reads NDJSON apiMessage lines from r, reattaches the non-JSON fields
|
||||
// (Sender, User) onto each message, and hands them to deliver. Malformed or message-less lines
|
||||
// are skipped and logged, not fatal: fan-out is fire-and-forget, so the valid remainder of a
|
||||
// request is still delivered. It returns an error only for stream-level failures (e.g. a line
|
||||
// exceeding maxLineBytes).
|
||||
func decodeMessageBody(r io.Reader, maxLineBytes int, deliver DeliverFunc) error {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 64*1024), maxLineBytes)
|
||||
for scanner.Scan() {
|
||||
line := bytes.TrimSpace(scanner.Bytes())
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var apiMsg apiMessage
|
||||
if err := json.Unmarshal(line, &apiMsg); err != nil || apiMsg.Message == nil {
|
||||
log.Tag(tag).Warn("Skipping malformed fan-out line")
|
||||
continue
|
||||
}
|
||||
apiMsg.Message.User = apiMsg.User
|
||||
if apiMsg.Sender != "" {
|
||||
if addr, err := netip.ParseAddr(apiMsg.Sender); err == nil {
|
||||
apiMsg.Message.Sender = addr
|
||||
}
|
||||
}
|
||||
deliver(apiMsg.Message)
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
+43
-2
@@ -18,6 +18,8 @@ import (
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/v2/ban"
|
||||
"heckel.io/ntfy/v2/cluster"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/payments"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
@@ -42,6 +44,11 @@ var flagsServe = append(
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores (e.g. postgres://user:pass@host:5432/ntfy)"}),
|
||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "database-replica-urls", Aliases: []string{"database_replica_urls"}, EnvVars: []string{"NTFY_DATABASE_REPLICA_URLS"}, Usage: "PostgreSQL read replica connection strings for offloading read queries"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cluster-node-id", Aliases: []string{"cluster_node_id"}, EnvVars: []string{"NTFY_CLUSTER_NODE_ID"}, Usage: "stable per-node identifier for the cluster node registry (required in cluster mode)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cluster-listen", Aliases: []string{"cluster_listen"}, EnvVars: []string{"NTFY_CLUSTER_LISTEN"}, Usage: "ip:port for the dedicated cluster fan-out listener; bind it to the private network (e.g. 10.0.0.5:2587)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cluster-advertise-url", Aliases: []string{"cluster_advertise_url"}, EnvVars: []string{"NTFY_CLUSTER_ADVERTISE_URL"}, Usage: "base URL peer nodes use to reach this node's fan-out listener (defaults to http://<cluster-listen>)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cluster-secret", Aliases: []string{"cluster_secret"}, EnvVars: []string{"NTFY_CLUSTER_SECRET"}, Usage: "shared secret authenticating node-to-node fan-out requests"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cluster-batch-linger", Aliases: []string{"cluster_batch_linger"}, EnvVars: []string{"NTFY_CLUSTER_BATCH_LINGER"}, Value: util.FormatDuration(cluster.DefaultBatchLinger), Usage: "how long fan-out messages wait to form a batch per peer node (0 = send immediately)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: util.FormatDuration(server.DefaultCacheDuration), Usage: "buffer messages for this time to allow `since` requests"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "cache-batch-size", Aliases: []string{"cache_batch_size"}, EnvVars: []string{"NTFY_BATCH_SIZE"}, Usage: "max size of messages to batch together when writing to message cache (if zero, writes are synchronous)"}),
|
||||
@@ -101,7 +108,7 @@ var flagsServe = append(
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv6", Aliases: []string{"visitor_prefix_bits_ipv6"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV6"}, Value: server.DefaultVisitorPrefixBitsIPv6, Usage: "number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "ban-file", Aliases: []string{"ban_file"}, EnvVars: []string{"NTFY_BAN_FILE"}, Value: "", Usage: "if set, append IPs of abusive visitors to this file for fail2ban to tail (empty disables)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "ban-window", Aliases: []string{"ban_window"}, EnvVars: []string{"NTFY_BAN_WINDOW"}, Value: util.FormatDuration(server.DefaultBanWindow), Usage: "rolling window over which weighted strikes are counted for the ban file"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "ban-threshold", Aliases: []string{"ban_threshold"}, EnvVars: []string{"NTFY_BAN_THRESHOLD"}, Value: server.DefaultBanThreshold, Usage: "weighted strikes per window before a visitor is banned"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "ban-threshold", Aliases: []string{"ban_threshold"}, EnvVars: []string{"NTFY_BAN_THRESHOLD"}, Value: server.DefaultBanThreshold, Usage: "weighted strikes per window before an offender is banned"}),
|
||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "ban-weights", Aliases: []string{"ban_weights"}, EnvVars: []string{"NTFY_BAN_WEIGHTS"}, Value: cli.NewStringSlice(server.DefaultBanWeights...), Usage: "per-code strike weights as KEY:WEIGHT, where KEY is an ntfy code, an HTTP status, a PREFIX*, or '*' (weight 0 exempts)"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}),
|
||||
@@ -156,6 +163,11 @@ func execServe(c *cli.Context) error {
|
||||
firebaseKeyFile := c.String("firebase-key-file")
|
||||
databaseURL := c.String("database-url")
|
||||
databaseReplicaURLs := c.StringSlice("database-replica-urls")
|
||||
clusterNodeID := c.String("cluster-node-id")
|
||||
clusterListen := c.String("cluster-listen")
|
||||
clusterAdvertiseURL := c.String("cluster-advertise-url")
|
||||
clusterSecret := c.String("cluster-secret")
|
||||
clusterBatchLingerStr := c.String("cluster-batch-linger")
|
||||
webPushPrivateKey := c.String("web-push-private-key")
|
||||
webPushPublicKey := c.String("web-push-public-key")
|
||||
webPushFile := c.String("web-push-file")
|
||||
@@ -251,6 +263,10 @@ func execServe(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid keepalive interval: %s", keepaliveIntervalStr)
|
||||
}
|
||||
clusterBatchLinger, err := util.ParseDuration(clusterBatchLingerStr)
|
||||
if err != nil || clusterBatchLinger < 0 {
|
||||
return fmt.Errorf("invalid cluster batch linger: %s", clusterBatchLingerStr)
|
||||
}
|
||||
managerInterval, err := util.ParseDuration(managerIntervalStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid manager interval: %s", managerIntervalStr)
|
||||
@@ -285,7 +301,7 @@ func execServe(c *cli.Context) error {
|
||||
}
|
||||
|
||||
// Parse abuse ban-feed weights ("KEY:WEIGHT" list, "*" fallback)
|
||||
banWeights, err := server.ParseBanWeights(banWeightsRaw)
|
||||
banWeights, err := ban.ParseWeights(banWeightsRaw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -321,6 +337,16 @@ func execServe(c *cli.Context) error {
|
||||
return errors.New("if database-url is set, auth-file, cache-file, and web-push-file must not be set")
|
||||
} else if len(databaseReplicaURLs) > 0 && databaseURL == "" {
|
||||
return errors.New("database-replica-urls can only be used if database-url is also set")
|
||||
} else if clusterListen != "" && databaseURL == "" {
|
||||
return errors.New("cluster-listen requires database-url to be set")
|
||||
} else if clusterListen != "" && clusterSecret == "" {
|
||||
return errors.New("cluster-listen requires cluster-secret to be set")
|
||||
} else if clusterListen != "" && clusterNodeID == "" {
|
||||
return errors.New("cluster-listen requires cluster-node-id to be set")
|
||||
} else if clusterListen == "" && clusterSecret != "" {
|
||||
return errors.New("cluster-secret can only be used if cluster-listen is set")
|
||||
} else if clusterListen != "" && clusterAdvertiseURL == "" && wildcardAddr(clusterListen) {
|
||||
return errors.New("cluster-advertise-url must be set if cluster-listen binds a wildcard address")
|
||||
} else if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
||||
return errors.New("if set, FCM key file must exist")
|
||||
} else if firebaseKeyFile != "" && !server.FirebaseAvailable {
|
||||
@@ -558,6 +584,11 @@ func execServe(c *cli.Context) error {
|
||||
conf.ProfileListenHTTP = profileListenHTTP
|
||||
conf.DatabaseURL = databaseURL
|
||||
conf.DatabaseReplicaURLs = databaseReplicaURLs
|
||||
conf.ClusterNodeID = clusterNodeID
|
||||
conf.ClusterListen = clusterListen
|
||||
conf.ClusterAdvertiseURL = clusterAdvertiseURL
|
||||
conf.ClusterSecret = clusterSecret
|
||||
conf.ClusterBatchLinger = clusterBatchLinger
|
||||
conf.WebPushPrivateKey = webPushPrivateKey
|
||||
conf.WebPushPublicKey = webPushPublicKey
|
||||
conf.WebPushFile = webPushFile
|
||||
@@ -736,3 +767,13 @@ func maybeFromMetadata(m map[string]any, key string) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// wildcardAddr reports whether the given listen address binds all interfaces (e.g. ":2587",
|
||||
// "0.0.0.0:2587", "[::]:2587"), in which case peers cannot derive a reachable URL from it.
|
||||
func wildcardAddr(addr string) bool {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return true // Unparseable -> cannot derive a URL either
|
||||
}
|
||||
return host == "" || host == "0.0.0.0" || host == "::"
|
||||
}
|
||||
|
||||
@@ -536,6 +536,41 @@ func TestIP_Host_Parsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLI_Serve_ClusterValidation(t *testing.T) {
|
||||
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
|
||||
// Setting cluster-listen implicitly enables clustering, which requires database-url; all
|
||||
// validation must fail before any database connection is attempted
|
||||
app, _, _, _ := newTestApp()
|
||||
err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--cluster-listen=127.0.0.1:2587"})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "database-url")
|
||||
// cluster-listen requires cluster-secret
|
||||
app, _, _, _ = newTestApp()
|
||||
err = app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--cluster-listen=127.0.0.1:2587", "--database-url=postgres://user:pass@localhost:1/na"})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cluster-secret")
|
||||
// cluster-listen requires an explicit stable node ID
|
||||
app, _, _, _ = newTestApp()
|
||||
err = app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--cluster-listen=127.0.0.1:2587", "--database-url=postgres://user:pass@localhost:1/na", "--cluster-secret=s3cret"})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cluster-node-id")
|
||||
// cluster-secret without cluster-listen is a config error (clustering would silently be off)
|
||||
app, _, _, _ = newTestApp()
|
||||
err = app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--cluster-secret=s3cret"})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cluster-listen")
|
||||
// A wildcard cluster-listen bind cannot derive an advertise URL
|
||||
app, _, _, _ = newTestApp()
|
||||
err = app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--cluster-listen=:2587", "--database-url=postgres://user:pass@localhost:1/na", "--cluster-secret=s3cret", "--cluster-node-id=node-a"})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cluster-advertise-url")
|
||||
// cluster-batch-linger must not be negative
|
||||
app, _, _, _ = newTestApp()
|
||||
err = app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--cluster-batch-linger=-1s"})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cluster batch linger")
|
||||
}
|
||||
|
||||
func newEmptyFile(t *testing.T) string {
|
||||
filename := filepath.Join(t.TempDir(), "empty")
|
||||
require.Nil(t, os.WriteFile(filename, []byte{}, 0600))
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package pg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
)
|
||||
|
||||
const (
|
||||
tagLeader = "leader"
|
||||
|
||||
tryAdvisoryLockQuery = `SELECT pg_try_advisory_lock($1)`
|
||||
advisoryUnlockQuery = `SELECT pg_advisory_unlock($1)`
|
||||
|
||||
defaultRenewInterval = 5 * time.Second
|
||||
leaderMissedRenewals = 3
|
||||
leaderHoldoffFactor = 2
|
||||
)
|
||||
|
||||
// Leader implements singleton-job leader election via a Postgres advisory lock held on a
|
||||
// pinned connection. The lock auto-releases when the holding connection dies, so a crashed
|
||||
// leader is replaced without manual fencing; distinct keys elect independently. The Leader
|
||||
// renews its lease on its own loop; callers only ask IsLeader and eventually Close.
|
||||
//
|
||||
// Holding the lock is not the same as believing to be the leader: IsLeader also requires a
|
||||
// recent renewal (lease duration) and a completed hold-off after winning the lock. The
|
||||
// hold-off outlasts the lease duration by construction, so on failover the old belief always
|
||||
// expires before the new one begins: a short no-leader gap, never two leaders. Defaults:
|
||||
// renew every 5s, lease duration 15s, hold-off 30s -> up to ~35s without a leader.
|
||||
type Leader struct {
|
||||
db *sql.DB
|
||||
key int64
|
||||
renewInterval time.Duration
|
||||
conn *sql.Conn // holds the advisory lock while this process is leader
|
||||
acquiredAt time.Time // When the lock was won (this tenure), for the hold-off
|
||||
renewedAt time.Time // Last successful renewal, for the lease duration; zero = lock not held
|
||||
cancel context.CancelFunc // Stops the renew loop and aborts its in-flight query on Close
|
||||
closeOnce sync.Once
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex // Protects conn, acquiredAt and renewedAt
|
||||
}
|
||||
|
||||
// NewLeader creates a Leader competing for the lock identified by key and starts its renew
|
||||
// loop. renewInterval is for tests; pass 0 for the default.
|
||||
func NewLeader(db *sql.DB, key int64, renewInterval time.Duration) *Leader {
|
||||
if renewInterval <= 0 {
|
||||
renewInterval = defaultRenewInterval
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
l := &Leader{
|
||||
db: db,
|
||||
key: key,
|
||||
renewInterval: renewInterval,
|
||||
cancel: cancel,
|
||||
}
|
||||
l.wg.Add(1)
|
||||
go l.runAcquireOrRenewLoop(ctx)
|
||||
return l
|
||||
}
|
||||
|
||||
// IsLeader reports whether this process should act as the leader: lock held, lease renewed
|
||||
// recently, hold-off elapsed (see the Leader doc comment).
|
||||
func (l *Leader) IsLeader() bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
leaseDuration := leaderMissedRenewals * l.renewInterval
|
||||
holdoff := leaderHoldoffFactor * leaseDuration
|
||||
return time.Since(l.renewedAt) < leaseDuration && time.Since(l.acquiredAt) >= holdoff
|
||||
}
|
||||
|
||||
// Close stops competing for leadership and releases the lock. Idempotent.
|
||||
func (l *Leader) Close() {
|
||||
l.closeOnce.Do(func() {
|
||||
l.cancel() // Also aborts an in-flight renewal query
|
||||
l.wg.Wait()
|
||||
if l.IsLeader() {
|
||||
log.Tag(tagLeader).Info("Lost leadership: closed (lock key %d)", l.key)
|
||||
}
|
||||
l.release()
|
||||
})
|
||||
}
|
||||
|
||||
// runAcquireOrRenewLoop acquires or renews the lock every renewInterval until ctx is canceled
|
||||
func (l *Leader) runAcquireOrRenewLoop(ctx context.Context) {
|
||||
defer l.wg.Done()
|
||||
ticker := time.NewTicker(l.renewInterval)
|
||||
defer ticker.Stop()
|
||||
wasLeader := false
|
||||
for {
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, l.renewInterval)
|
||||
l.tryAcquireOrRenew(attemptCtx)
|
||||
cancel()
|
||||
if isLeader := l.IsLeader(); isLeader != wasLeader {
|
||||
wasLeader = isLeader
|
||||
if isLeader {
|
||||
log.Tag(tagLeader).Info("Became leader (lock key %d)", l.key)
|
||||
} else {
|
||||
log.Tag(tagLeader).Info("Lost leadership (lock key %d)", l.key)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tryAcquireOrRenew renews the lock on a healthy leader (a cheap ping) or retries acquiring
|
||||
// it on a follower, on a pinned connection.
|
||||
func (l *Leader) tryAcquireOrRenew(ctx context.Context) {
|
||||
l.mu.Lock()
|
||||
conn := l.conn
|
||||
l.mu.Unlock()
|
||||
if conn != nil {
|
||||
if conn.PingContext(ctx) == nil {
|
||||
// Still holding the lock, connection healthy: renew the lease
|
||||
l.mu.Lock()
|
||||
l.renewedAt = time.Now()
|
||||
l.mu.Unlock()
|
||||
log.Tag(tagLeader).Trace("Renewed leader lease (lock key %d)", l.key)
|
||||
return
|
||||
}
|
||||
log.Tag(tagLeader).Debug("Leader lock connection died, lock lost (lock key %d)", l.key)
|
||||
l.release() // Connection died; the lock is already gone, re-acquire below
|
||||
}
|
||||
newConn, err := l.db.Conn(ctx)
|
||||
if err != nil {
|
||||
log.Tag(tagLeader).Debug("Cannot get connection to compete for leader lock (lock key %d): %s", l.key, err.Error())
|
||||
return
|
||||
}
|
||||
var acquired bool
|
||||
if err := newConn.QueryRowContext(ctx, tryAdvisoryLockQuery, l.key).Scan(&acquired); err != nil || !acquired {
|
||||
newConn.Close()
|
||||
log.Tag(tagLeader).Trace("Leader lock held elsewhere (lock key %d)", l.key)
|
||||
return
|
||||
}
|
||||
log.Tag(tagLeader).Debug("Acquired leader lock (lock key %d); leadership after the hold-off", l.key)
|
||||
l.mu.Lock()
|
||||
l.conn = newConn
|
||||
l.acquiredAt = time.Now()
|
||||
l.renewedAt = l.acquiredAt
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// release unlocks the advisory lock and returns the pinned connection to the pool
|
||||
func (l *Leader) release() {
|
||||
l.mu.Lock()
|
||||
conn := l.conn
|
||||
l.conn = nil
|
||||
l.renewedAt = time.Time{} // Zero revokes belief; without it, IsLeader would linger a lease duration
|
||||
l.mu.Unlock()
|
||||
if conn != nil {
|
||||
// Unlock explicitly: sql.Conn.Close() returns the connection to the pool, so the
|
||||
// session-scoped lock would otherwise stay held
|
||||
conn.ExecContext(context.Background(), advisoryUnlockQuery, l.key)
|
||||
conn.Close()
|
||||
log.Tag(tagLeader).Debug("Released leader lock (lock key %d)", l.key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package pg
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The lease logic is pure time arithmetic, so it is unit-tested here without a database; the
|
||||
// external leader tests cover the loop end to end.
|
||||
|
||||
func TestLeader_Lease_HoldoffMeansNoLeaderRatherThanTwo(t *testing.T) {
|
||||
// Freshly acquired lock: belief must wait out the hold-off
|
||||
l := &Leader{renewInterval: 20 * time.Second} // Lease duration 1m, hold-off 2m
|
||||
l.acquiredAt = time.Now()
|
||||
l.renewedAt = l.acquiredAt
|
||||
require.False(t, l.IsLeader())
|
||||
// Once the hold-off has passed (and verification is fresh), belief begins
|
||||
l.acquiredAt = time.Now().Add(-3 * time.Minute)
|
||||
l.renewedAt = time.Now()
|
||||
require.True(t, l.IsLeader())
|
||||
}
|
||||
|
||||
func TestLeader_Lease_ExpiredLeaseRevokesLeadership(t *testing.T) {
|
||||
// A leader that cannot renew its lease (wedged process, long GC pause) must stop
|
||||
// believing once the lease expires, even though the lock may still be held
|
||||
l := &Leader{renewInterval: 20 * time.Second} // Lease duration 1m, hold-off 2m
|
||||
l.acquiredAt = time.Now().Add(-time.Hour)
|
||||
l.renewedAt = time.Now().Add(-2 * time.Minute) // Lease expired
|
||||
require.False(t, l.IsLeader())
|
||||
l.renewedAt = time.Now() // Fresh renewal restores belief
|
||||
require.True(t, l.IsLeader())
|
||||
}
|
||||
|
||||
func TestLeader_Lease_ReleasedIsNeverLeader(t *testing.T) {
|
||||
// release() zeroes renewedAt, which fails the lease check no matter how old the tenure
|
||||
l := &Leader{renewInterval: 20 * time.Second} // Lease duration 1m, hold-off 2m
|
||||
l.acquiredAt = time.Now().Add(-time.Hour)
|
||||
require.False(t, l.IsLeader())
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package pg_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
dbtest "heckel.io/ntfy/v2/db/test"
|
||||
)
|
||||
|
||||
const testRenewInterval = 20 * time.Millisecond // Lease duration 60ms, hold-off 120ms
|
||||
|
||||
func TestLeader_AcquireAndFailover(t *testing.T) {
|
||||
testDB := dbtest.CreateTestPostgres(t) // skips if NTFY_TEST_DATABASE_URL is unset
|
||||
const key = int64(42)
|
||||
l1 := pg.NewLeader(testDB.Primary(), key, testRenewInterval)
|
||||
defer l1.Close()
|
||||
// Belief follows the hold-off, it is never instant
|
||||
require.False(t, l1.IsLeader())
|
||||
waitForLeader(t, l1)
|
||||
// A competitor never becomes leader while the leader lives
|
||||
l2 := pg.NewLeader(testDB.Primary(), key, testRenewInterval)
|
||||
defer l2.Close()
|
||||
time.Sleep(300 * time.Millisecond) // Several verification rounds
|
||||
require.False(t, l2.IsLeader())
|
||||
require.True(t, l1.IsLeader())
|
||||
// Close -> the follower takes over
|
||||
l1.Close()
|
||||
require.False(t, l1.IsLeader())
|
||||
waitForLeader(t, l2)
|
||||
require.False(t, l1.IsLeader())
|
||||
}
|
||||
|
||||
func TestLeader_ConnectionLossFailover(t *testing.T) {
|
||||
// A crashed leader must not wedge the cluster: Postgres releases the session-scoped lock
|
||||
// when the pinned connection dies (simulated by terminating the backend), and someone
|
||||
// re-acquires. Either node may win; the invariant is one leader eventually, never two.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
hostA, err := pg.Open(schemaDSN)
|
||||
require.Nil(t, err)
|
||||
defer hostA.DB.Close()
|
||||
hostB, err := pg.Open(schemaDSN)
|
||||
require.Nil(t, err)
|
||||
defer hostB.DB.Close()
|
||||
const key = int64(43)
|
||||
l1 := pg.NewLeader(hostA.DB, key, testRenewInterval)
|
||||
defer l1.Close()
|
||||
waitForLeader(t, l1)
|
||||
l2 := pg.NewLeader(hostB.DB, key, testRenewInterval)
|
||||
defer l2.Close()
|
||||
// Kill the backend holding the lock (advisory lock keys map to classid/objid)
|
||||
_, err = hostB.DB.Exec(`SELECT pg_terminate_backend(pid) FROM pg_locks WHERE locktype = 'advisory' AND objid = $1 AND granted`, key)
|
||||
require.Nil(t, err)
|
||||
// Eventually exactly one leader again, and never two along the way
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
leader1, leader2 := l1.IsLeader(), l2.IsLeader()
|
||||
require.False(t, leader1 && leader2, "two leaders at once")
|
||||
if leader1 != leader2 {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("no leader re-emerged after connection loss")
|
||||
}
|
||||
|
||||
func TestLeader_DistinctKeysAreIndependent(t *testing.T) {
|
||||
testDB := dbtest.CreateTestPostgres(t)
|
||||
l1 := pg.NewLeader(testDB.Primary(), 1, testRenewInterval)
|
||||
defer l1.Close()
|
||||
l2 := pg.NewLeader(testDB.Primary(), 2, testRenewInterval)
|
||||
defer l2.Close()
|
||||
// Different keys do not compete: both become effective leaders
|
||||
waitForLeader(t, l1)
|
||||
waitForLeader(t, l2)
|
||||
}
|
||||
|
||||
// waitForLeader waits until the node believes it is the leader, or fails the test
|
||||
func waitForLeader(t *testing.T, l *pg.Leader) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if l.IsLeader() {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("node never became effective leader")
|
||||
}
|
||||
@@ -13,6 +13,13 @@ import (
|
||||
"heckel.io/ntfy/v2/db"
|
||||
)
|
||||
|
||||
// Advisory lock keys. PostgreSQL advisory locks share one database-wide key space, so every
|
||||
// ntfy key is defined here, following the "ntfy"+2586+letter scheme
|
||||
const (
|
||||
SchemaLockKey = int64(0x6e7466792586a) // Schema setup serialization (transaction-scoped, see db/schema)
|
||||
LeaderLockKey = int64(0x6e7466792586b) // Cluster singleton-job leader (session-scoped, held for process lifetime)
|
||||
)
|
||||
|
||||
// Open opens a PostgreSQL connection pool for a primary database. It pings the database
|
||||
// to verify connectivity before returning.
|
||||
func Open(dsn string) (*db.Host, error) {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Package schema tracks and migrates database schemas, and Migrate creates or upgrades a
|
||||
// store's schema inside a single transaction. On PostgreSQL, all stores share one database, so
|
||||
// versions live in a shared schema_version table keyed by store name. On SQLite, every store is
|
||||
// its own database file, so the version lives in the schemaVersion table keyed by id = 1.
|
||||
package schema
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
)
|
||||
|
||||
const (
|
||||
tag = "schema"
|
||||
)
|
||||
|
||||
const (
|
||||
sqliteCreateVersionTableQuery = `CREATE TABLE IF NOT EXISTS schemaVersion (id INT PRIMARY KEY, version INT NOT NULL)`
|
||||
sqliteSelectVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
||||
sqliteUpsertVersionQuery = `INSERT INTO schemaVersion (id, version) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET version = excluded.version`
|
||||
|
||||
postgresCreateVersionTableQuery = `CREATE TABLE IF NOT EXISTS schema_version (store TEXT PRIMARY KEY, version INT NOT NULL)`
|
||||
postgresSelectVersionQuery = `SELECT version FROM schema_version WHERE store = $1`
|
||||
postgresUpsertVersionQuery = `INSERT INTO schema_version (store, version) VALUES ($1, $2) ON CONFLICT (store) DO UPDATE SET version = EXCLUDED.version`
|
||||
postgresAdvisoryLockQuery = `SELECT pg_advisory_xact_lock($1)` // Transaction-scoped lock to avoid migration races
|
||||
)
|
||||
|
||||
// Migrate creates or upgrades the named store's schema to targetVersion in one transaction, or
|
||||
// creates a new database using the "create" function.
|
||||
func Migrate(db *sql.DB, dialect Dialect, store string, targetVersion int, create MigrateFunc, migrations map[int]MigrateFunc) error {
|
||||
if dialect != Postgres && dialect != SQLite {
|
||||
return fmt.Errorf("unsupported schema dialect %d", dialect)
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot begin %s schema transaction: %w", store, err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if dialect == Postgres {
|
||||
// Serialize setup across nodes: CREATE TABLE IF NOT EXISTS is not atomic, and
|
||||
// concurrently cold-booting nodes would otherwise race on DDL and crash
|
||||
if _, err := tx.Exec(postgresAdvisoryLockQuery, pg.SchemaLockKey); err != nil {
|
||||
return fmt.Errorf("cannot acquire %s schema advisory lock: %w", store, err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(createVersionTableQuery(dialect)); err != nil {
|
||||
return fmt.Errorf("cannot create schema version table: %w", err)
|
||||
}
|
||||
version, err := readVersion(tx, dialect, store)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// Fresh database: create the store's tables at the target version
|
||||
if err := create(tx); err != nil {
|
||||
return fmt.Errorf("cannot create %s schema: %w", store, err)
|
||||
}
|
||||
if err := writeVersion(tx, dialect, store, targetVersion); err != nil {
|
||||
return fmt.Errorf("cannot write %s schema version: %w", store, err)
|
||||
}
|
||||
return tx.Commit()
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("cannot read %s schema version: %w", store, err)
|
||||
}
|
||||
if version == targetVersion {
|
||||
return tx.Commit()
|
||||
}
|
||||
if version > targetVersion {
|
||||
return fmt.Errorf("unexpected %s schema version %d, this version of ntfy supports up to %d", store, version, targetVersion)
|
||||
}
|
||||
for v := version; v < targetVersion; v++ {
|
||||
migrate, ok := migrations[v]
|
||||
if !ok {
|
||||
return fmt.Errorf("cannot find %s migration step from version %d to %d", store, v, v+1)
|
||||
}
|
||||
log.Tag(tag).Info("Migrating %s database schema: from %d to %d", store, v, v+1)
|
||||
if err := migrate(tx); err != nil {
|
||||
return fmt.Errorf("%s migration step from version %d to %d failed: %w", store, v, v+1, err)
|
||||
}
|
||||
}
|
||||
if err := writeVersion(tx, dialect, store, targetVersion); err != nil {
|
||||
return fmt.Errorf("cannot write %s schema version: %w", store, err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func createVersionTableQuery(dialect Dialect) string {
|
||||
if dialect == Postgres {
|
||||
return postgresCreateVersionTableQuery
|
||||
}
|
||||
return sqliteCreateVersionTableQuery
|
||||
}
|
||||
|
||||
func readVersion(tx *sql.Tx, dialect Dialect, store string) (version int, err error) {
|
||||
if dialect == Postgres {
|
||||
err = tx.QueryRow(postgresSelectVersionQuery, store).Scan(&version)
|
||||
} else {
|
||||
err = tx.QueryRow(sqliteSelectVersionQuery).Scan(&version)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func writeVersion(tx *sql.Tx, dialect Dialect, store string, version int) error {
|
||||
var err error
|
||||
if dialect == Postgres {
|
||||
_, err = tx.Exec(postgresUpsertVersionQuery, store, version)
|
||||
} else {
|
||||
_, err = tx.Exec(sqliteUpsertVersionQuery, version)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package schema_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
"heckel.io/ntfy/v2/db/schema"
|
||||
dbtest "heckel.io/ntfy/v2/db/test"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const (
|
||||
testCreateQuery = `CREATE TABLE IF NOT EXISTS things (id TEXT PRIMARY KEY, name TEXT NOT NULL)`
|
||||
)
|
||||
|
||||
func testCreate(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(testCreateQuery)
|
||||
return err
|
||||
}
|
||||
|
||||
func openTestPostgres(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
host, err := pg.Open(dbtest.CreateTestPostgresSchema(t))
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() { host.DB.Close() })
|
||||
return host.DB
|
||||
}
|
||||
|
||||
func openTestSQLite(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
d, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "test.db"))
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() { d.Close() })
|
||||
return d
|
||||
}
|
||||
|
||||
func forEachDialect(t *testing.T, f func(t *testing.T, d *sql.DB, dialect schema.Dialect)) {
|
||||
t.Run("postgres", func(t *testing.T) {
|
||||
f(t, openTestPostgres(t), schema.Postgres)
|
||||
})
|
||||
t.Run("sqlite", func(t *testing.T) {
|
||||
f(t, openTestSQLite(t), schema.SQLite)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrate_FreshCreate(t *testing.T) {
|
||||
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
|
||||
// A fresh database jumps straight to the target version; migration steps are not consulted
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 3, testCreate, nil))
|
||||
_, err := d.Exec(`INSERT INTO things (id, name) VALUES ('a', 'thing a')`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, storeVersion(t, d, dialect, "things"))
|
||||
// Idempotent: a second node boots against the migrated schema
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 3, testCreate, nil))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrate_AppliesMigrationSteps(t *testing.T) {
|
||||
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 1, testCreate, nil))
|
||||
// A newer version of the code migrates 1 -> 3 step by step, in order
|
||||
migrations := map[int]schema.MigrateFunc{
|
||||
1: func(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`ALTER TABLE things ADD COLUMN color TEXT NOT NULL DEFAULT ''`)
|
||||
return err
|
||||
},
|
||||
2: func(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`ALTER TABLE things ADD COLUMN size INT NOT NULL DEFAULT 0`)
|
||||
return err
|
||||
},
|
||||
}
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 3, testCreate, migrations))
|
||||
_, err := d.Exec(`INSERT INTO things (id, name, color, size) VALUES ('b', 'thing b', 'red', 2)`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, storeVersion(t, d, dialect, "things"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrate_ClosureCarriesConfig(t *testing.T) {
|
||||
// Migrations needing config take it via closure at map-construction time; there is no
|
||||
// params plumbing in the framework itself
|
||||
migrationsFor := func(defaultName string) map[int]schema.MigrateFunc {
|
||||
return map[int]schema.MigrateFunc{
|
||||
1: schema.AsMigrateFunc(fmt.Sprintf(`ALTER TABLE things ADD COLUMN nick TEXT NOT NULL DEFAULT '%s'`, defaultName)),
|
||||
}
|
||||
}
|
||||
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 1, testCreate, nil))
|
||||
_, err := d.Exec(`INSERT INTO things (id, name) VALUES ('a', 'thing a')`)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 2, testCreate, migrationsFor("configured-default")))
|
||||
var nick string
|
||||
require.Nil(t, d.QueryRow(`SELECT nick FROM things WHERE id = 'a'`).Scan(&nick))
|
||||
require.Equal(t, "configured-default", nick)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrate_InvalidDialect(t *testing.T) {
|
||||
d := openTestSQLite(t)
|
||||
err := schema.Migrate(d, schema.Dialect(99), "things", 1, testCreate, nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMigrate_RefusesFutureVersion(t *testing.T) {
|
||||
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 2, testCreate, map[int]schema.MigrateFunc{}))
|
||||
err := schema.Migrate(d, dialect, "things", 1, testCreate, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrate_MissingStepFails(t *testing.T) {
|
||||
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 1, testCreate, nil))
|
||||
err := schema.Migrate(d, dialect, "things", 3, testCreate, nil) // No step 1 -> 2 registered
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrate_StoresAreIndependent(t *testing.T) {
|
||||
// Postgres only: stores share one database, tracked as rows in schema_version. On SQLite
|
||||
// every store has its own database file, so independence is by file.
|
||||
d := openTestPostgres(t)
|
||||
require.Nil(t, schema.Migrate(d, schema.Postgres, "things", 1, testCreate, nil))
|
||||
require.Nil(t, schema.Migrate(d, schema.Postgres, "gadgets", 4, func(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`CREATE TABLE IF NOT EXISTS gadgets (id TEXT PRIMARY KEY)`)
|
||||
return err
|
||||
}, nil))
|
||||
require.Equal(t, 1, storeVersion(t, d, schema.Postgres, "things"))
|
||||
require.Equal(t, 4, storeVersion(t, d, schema.Postgres, "gadgets"))
|
||||
}
|
||||
|
||||
func TestMigrate_SQLiteReadsExistingSchemaVersionTable(t *testing.T) {
|
||||
// Existing ntfy SQLite databases (message, user, webpush) track their version in a
|
||||
// schemaVersion (id, version) table keyed by id = 1; the framework uses that table as-is
|
||||
// on SQLite, so existing databases migrate without any adoption step
|
||||
d := openTestSQLite(t)
|
||||
_, err := d.Exec(testCreateQuery)
|
||||
require.Nil(t, err)
|
||||
_, err = d.Exec(`CREATE TABLE schemaVersion (id INT PRIMARY KEY, version INT NOT NULL)`)
|
||||
require.Nil(t, err)
|
||||
_, err = d.Exec(`INSERT INTO schemaVersion VALUES (1, 1)`)
|
||||
require.Nil(t, err)
|
||||
migrations := map[int]schema.MigrateFunc{
|
||||
1: func(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`ALTER TABLE things ADD COLUMN color TEXT NOT NULL DEFAULT ''`)
|
||||
return err
|
||||
},
|
||||
}
|
||||
require.Nil(t, schema.Migrate(d, schema.SQLite, "things", 2, testCreate, migrations))
|
||||
_, err = d.Exec(`INSERT INTO things (id, name, color) VALUES ('a', 'thing a', 'red')`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, storeVersion(t, d, schema.SQLite, "things"))
|
||||
}
|
||||
|
||||
func TestMigrate_ConcurrentFreshCreate(t *testing.T) {
|
||||
// Postgres only: concurrent cold-boots must not race on DDL (CREATE TABLE IF NOT EXISTS is
|
||||
// not atomic); Migrate serializes via an advisory lock. SQLite has a single writer.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
const n = 8
|
||||
errs := make(chan error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
go func() {
|
||||
host, err := pg.Open(schemaDSN)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
defer host.DB.Close()
|
||||
errs <- schema.Migrate(host.DB, schema.Postgres, "things", 1, testCreate, nil)
|
||||
}()
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
require.Nil(t, <-errs)
|
||||
}
|
||||
}
|
||||
|
||||
func storeVersion(t *testing.T, d *sql.DB, dialect schema.Dialect, store string) int {
|
||||
t.Helper()
|
||||
var version int
|
||||
if dialect == schema.Postgres {
|
||||
require.Nil(t, d.QueryRow(`SELECT version FROM schema_version WHERE store = $1`, store).Scan(&version), fmt.Sprintf("store %s", store))
|
||||
} else {
|
||||
require.Nil(t, d.QueryRow(`SELECT version FROM schemaVersion WHERE id = 1`).Scan(&version), fmt.Sprintf("store %s", store))
|
||||
}
|
||||
return version
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package schema
|
||||
|
||||
import "database/sql"
|
||||
|
||||
// Dialect selects the SQL flavor Migrate speaks to the version table.
|
||||
type Dialect int
|
||||
|
||||
// Supported dialects; SQLite is the zero value
|
||||
const (
|
||||
SQLite Dialect = iota
|
||||
Postgres
|
||||
)
|
||||
|
||||
// MigrateFunc applies one schema change inside the setup transaction: the initial creation of
|
||||
// a store's tables, or one step upgrading a store from version N to N+1. Migrations needing
|
||||
// config capture it via closure, e.g. func migrations(cacheDuration time.Duration) map[int]MigrateFunc.
|
||||
type MigrateFunc func(tx *sql.Tx) error
|
||||
|
||||
// AsMigrateFunc converts a simple query to a migration function
|
||||
func AsMigrateFunc(query string) MigrateFunc {
|
||||
return func(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(query)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// NopMigrateFunc is a migration step that does nothing, for versions where a dialect has no
|
||||
// work to do (e.g. when only the other dialect's schema changed).
|
||||
func NopMigrateFunc(_ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package dbtest
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Querier is the subset of *sql.DB / *db.DB needed to introspect a schema.
|
||||
type Querier interface {
|
||||
Query(query string, args ...any) (*sql.Rows, error)
|
||||
}
|
||||
|
||||
// SQLiteSchema returns a normalized, comparable description of the database schema: tables
|
||||
// with their columns, named indexes, and foreign keys. Column order, declared type spelling
|
||||
// (INT vs INTEGER) and default values are not part of the description, so the schema produced
|
||||
// by a migration chain can be compared to a freshly created one.
|
||||
func SQLiteSchema(t testing.TB, d Querier) string {
|
||||
t.Helper()
|
||||
lines := make([]string, 0)
|
||||
for _, table := range sqliteTables(t, d) {
|
||||
lines = append(lines, "table "+table)
|
||||
lines = append(lines, sqliteColumns(t, d, table)...)
|
||||
lines = append(lines, sqliteForeignKeys(t, d, table)...)
|
||||
lines = append(lines, sqliteIndexes(t, d, table)...)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// PostgresSchema is SQLiteSchema's PostgreSQL counterpart, describing the current schema's
|
||||
// tables, columns, constraints and indexes in a normalized, comparable way.
|
||||
func PostgresSchema(t testing.TB, d Querier) string {
|
||||
t.Helper()
|
||||
lines := make([]string, 0)
|
||||
for _, table := range postgresTables(t, d) {
|
||||
lines = append(lines, "table "+table)
|
||||
lines = append(lines, postgresColumns(t, d, table)...)
|
||||
}
|
||||
lines = append(lines, postgresConstraints(t, d)...)
|
||||
lines = append(lines, postgresIndexes(t, d)...)
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func sqliteTables(t testing.TB, d Querier) []string {
|
||||
t.Helper()
|
||||
return queryStrings(t, d, `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`)
|
||||
}
|
||||
|
||||
func sqliteColumns(t testing.TB, d Querier, table string) []string {
|
||||
t.Helper()
|
||||
rows, err := d.Query(fmt.Sprintf(`PRAGMA table_info(%q)`, table))
|
||||
require.Nil(t, err)
|
||||
defer rows.Close()
|
||||
lines := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var cid, notNull, pk int
|
||||
var name, typ string
|
||||
var dflt sql.NullString
|
||||
require.Nil(t, rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk))
|
||||
typ = strings.ToUpper(typ)
|
||||
if typ == "INT" { // INT and INTEGER are the same affinity; migrations spell them inconsistently
|
||||
typ = "INTEGER"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" col %s %s notnull=%d pk=%d", name, typ, notNull, pk))
|
||||
}
|
||||
require.Nil(t, rows.Err())
|
||||
sort.Strings(lines)
|
||||
return lines
|
||||
}
|
||||
|
||||
func sqliteForeignKeys(t testing.TB, d Querier, table string) []string {
|
||||
t.Helper()
|
||||
rows, err := d.Query(fmt.Sprintf(`PRAGMA foreign_key_list(%q)`, table))
|
||||
require.Nil(t, err)
|
||||
defer rows.Close()
|
||||
lines := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var id, seq int
|
||||
var refTable, from, onUpdate, onDelete, match string
|
||||
var to sql.NullString // NULL when referencing the parent's primary key implicitly
|
||||
require.Nil(t, rows.Scan(&id, &seq, &refTable, &from, &to, &onUpdate, &onDelete, &match))
|
||||
lines = append(lines, fmt.Sprintf(" fk %s -> %s(%s) on_delete=%s", from, refTable, to.String, onDelete))
|
||||
}
|
||||
require.Nil(t, rows.Err())
|
||||
sort.Strings(lines)
|
||||
return lines
|
||||
}
|
||||
|
||||
func sqliteIndexes(t testing.TB, d Querier, table string) []string {
|
||||
t.Helper()
|
||||
rows, err := d.Query(fmt.Sprintf(`PRAGMA index_list(%q)`, table))
|
||||
require.Nil(t, err)
|
||||
type index struct {
|
||||
name string
|
||||
unique, partial int
|
||||
}
|
||||
indexes := make([]index, 0)
|
||||
for rows.Next() {
|
||||
var seq, unique, partial int
|
||||
var name, origin string
|
||||
require.Nil(t, rows.Scan(&seq, &name, &unique, &origin, &partial))
|
||||
// Skip auto-indexes backing PRIMARY KEY/UNIQUE table constraints; those are described
|
||||
// by the column and constraint listings already
|
||||
if strings.HasPrefix(name, "sqlite_autoindex_") {
|
||||
continue
|
||||
}
|
||||
indexes = append(indexes, index{name, unique, partial})
|
||||
}
|
||||
require.Nil(t, rows.Err())
|
||||
require.Nil(t, rows.Close())
|
||||
lines := make([]string, 0, len(indexes))
|
||||
for _, idx := range indexes {
|
||||
cols := queryStrings(t, d, fmt.Sprintf(`SELECT name FROM pragma_index_info(%q) ORDER BY seqno`, idx.name))
|
||||
lines = append(lines, fmt.Sprintf(" index %s unique=%d partial=%d cols=(%s)", idx.name, idx.unique, idx.partial, strings.Join(cols, ",")))
|
||||
}
|
||||
sort.Strings(lines)
|
||||
return lines
|
||||
}
|
||||
|
||||
func postgresTables(t testing.TB, d Querier) []string {
|
||||
t.Helper()
|
||||
return queryStrings(t, d, `SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' ORDER BY table_name`)
|
||||
}
|
||||
|
||||
func postgresColumns(t testing.TB, d Querier, table string) []string {
|
||||
t.Helper()
|
||||
rows, err := d.Query(`SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 ORDER BY column_name`, table)
|
||||
require.Nil(t, err)
|
||||
defer rows.Close()
|
||||
lines := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var name, typ, nullable string
|
||||
require.Nil(t, rows.Scan(&name, &typ, &nullable))
|
||||
lines = append(lines, fmt.Sprintf(" col %s %s nullable=%s", name, typ, nullable))
|
||||
}
|
||||
require.Nil(t, rows.Err())
|
||||
return lines
|
||||
}
|
||||
|
||||
func postgresConstraints(t testing.TB, d Querier) []string {
|
||||
t.Helper()
|
||||
return queryStrings(t, d, `
|
||||
SELECT 'constraint ' || conrelid::regclass::text || ': ' || pg_get_constraintdef(oid)
|
||||
FROM pg_constraint
|
||||
WHERE connamespace = current_schema()::regnamespace
|
||||
ORDER BY 1
|
||||
`)
|
||||
}
|
||||
|
||||
func postgresIndexes(t testing.TB, d Querier) []string {
|
||||
t.Helper()
|
||||
rows, err := d.Query(`SELECT indexname, indexdef, schemaname FROM pg_indexes WHERE schemaname = current_schema() ORDER BY indexname`)
|
||||
require.Nil(t, err)
|
||||
defer rows.Close()
|
||||
lines := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var name, def, schema string
|
||||
require.Nil(t, rows.Scan(&name, &def, &schema))
|
||||
// The index definition qualifies the table with the (test-specific) schema name; strip
|
||||
// it so snapshots from different test schemas compare equal
|
||||
def = strings.ReplaceAll(def, schema+".", "")
|
||||
lines = append(lines, "index "+def)
|
||||
}
|
||||
require.Nil(t, rows.Err())
|
||||
return lines
|
||||
}
|
||||
|
||||
func queryStrings(t testing.TB, d Querier, query string) []string {
|
||||
t.Helper()
|
||||
rows, err := d.Query(query)
|
||||
require.Nil(t, err)
|
||||
defer rows.Close()
|
||||
values := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var value string
|
||||
require.Nil(t, rows.Scan(&value))
|
||||
values = append(values, value)
|
||||
}
|
||||
require.Nil(t, rows.Err())
|
||||
return values
|
||||
}
|
||||
+2
-2
@@ -17,7 +17,7 @@ const testPoolMaxConns = "2"
|
||||
// CreateTestPostgresSchema creates a temporary PostgreSQL schema and returns the DSN pointing to it.
|
||||
// It registers a cleanup function to drop the schema when the test finishes.
|
||||
// If NTFY_TEST_DATABASE_URL is not set, the test is skipped.
|
||||
func CreateTestPostgresSchema(t *testing.T) string {
|
||||
func CreateTestPostgresSchema(t testing.TB) string {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("NTFY_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
@@ -51,7 +51,7 @@ func CreateTestPostgresSchema(t *testing.T) string {
|
||||
// CreateTestPostgres creates a temporary PostgreSQL schema and returns an open *db.DB connection to it.
|
||||
// It registers cleanup functions to close the DB and drop the schema when the test finishes.
|
||||
// If NTFY_TEST_DATABASE_URL is not set, the test is skipped.
|
||||
func CreateTestPostgres(t *testing.T) *db.DB {
|
||||
func CreateTestPostgres(t testing.TB) *db.DB {
|
||||
t.Helper()
|
||||
schemaDSN := CreateTestPostgresSchema(t)
|
||||
testHost, err := pg.Open(schemaDSN)
|
||||
|
||||
+77
-6
@@ -379,7 +379,7 @@ This generator helps you configure your self-hosted ntfy instance. It's not full
|
||||
</div>
|
||||
</div>
|
||||
<div class="cg-panel" id="cg-panel-database">
|
||||
<div class="cg-panel-desc">Configure the PostgreSQL connection. See <a href="/config/#postgresql-experimental" target="_blank">PostgreSQL</a> for details.</div>
|
||||
<div class="cg-panel-desc">Configure the PostgreSQL connection. See <a href="/config/#postgresql" target="_blank">PostgreSQL</a> for details.</div>
|
||||
<div class="cg-field">
|
||||
<label>Database URL</label>
|
||||
<input type="text" data-key="database-url" placeholder="postgres://user:pass@host:5432/ntfy">
|
||||
@@ -417,7 +417,7 @@ no external dependencies:
|
||||
* `auth-file`: Database file for authentication and [access control](#access-control). If set, enables auth.
|
||||
* `web-push-file`: Database file for [web push](#web-push) subscriptions.
|
||||
|
||||
### PostgreSQL (EXPERIMENTAL)
|
||||
### PostgreSQL
|
||||
As an alternative, you can configure ntfy to use PostgreSQL for **all** database-backed stores by setting the
|
||||
`database-url` option to a PostgreSQL connection string.
|
||||
|
||||
@@ -1656,7 +1656,7 @@ a database to keep track of the browser's subscriptions, and an admin email addr
|
||||
- `web-push-expiry-duration` defines the duration after which unused subscriptions will expire (default is `60d`)
|
||||
|
||||
Alternatively, you can use PostgreSQL instead of SQLite by setting `database-url`
|
||||
(see [PostgreSQL database](#postgresql-experimental)).
|
||||
(see [PostgreSQL database](#postgresql)).
|
||||
|
||||
Limitations:
|
||||
|
||||
@@ -2129,6 +2129,72 @@ 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.
|
||||
|
||||
### Ban-feed
|
||||
In addition to the fail2ban setup above, ntfy can detect abusive visitors itself and write their IP
|
||||
addresses to a file for fail2ban to ban from. ntfy keeps a per-prefix weighted "strike" budget, and
|
||||
each rejected request costs strikes based on its response code -- the ntfy error code, or its HTTP
|
||||
status (see `ban-weights`) -- so different kinds of rejection can be weighted differently or exempted
|
||||
entirely. When a prefix exceeds the budget, ntfy appends the offending IP address to `ban-file`. Since
|
||||
every line is already a confirmed offender, the fail2ban jail can ban on first sight (`maxretry = 1`).
|
||||
|
||||
- `ban-file` is the file offenders are appended to. If it is not set, the ban-feed is disabled. Its
|
||||
parent directory must exist and be writable by ntfy. Be sure to rotate it (e.g. with logrotate and
|
||||
`copytruncate`) so it does not grow unbounded.
|
||||
- `ban-window` is the rolling window over which weighted strikes are counted, per IP prefix.
|
||||
- `ban-threshold` is the number of weighted strikes per `ban-window` before a prefix is written to
|
||||
`ban-file`. Each prefix has one shared budget, so it cannot be gamed by mixing error codes.
|
||||
- `ban-weights` assigns a strike weight per matcher key, formatted as `KEY:WEIGHT`. A key is an exact
|
||||
ntfy error code (`42909`), a code family (`429*`, `403*`, `4*`), a bare HTTP status (`403`, short for
|
||||
`403*`), or `*`. The longest matching key wins. A weight of `0` exempts a code entirely (it never
|
||||
counts toward a ban), useful to spare a specific code from a `*` catch-all. Heavier weights ban faster. If you do not
|
||||
include a `*` rule, any code that matches nothing defaults to weight `1` (i.e. it can be banned);
|
||||
set `*:0` to exempt everything that is not explicitly weighted.
|
||||
|
||||
Only rejections (4xx/5xx) count towards a ban; successful requests never do. Because the budget
|
||||
refills over `ban-window`, the trigger is a sustained rate: a prefix is only written out once it
|
||||
exceeds `ban-threshold / ban-window` rejections per second (with the defaults, `100 / 10m` = ~0.17/s).
|
||||
|
||||
Each line in `ban-file` has the format `<RFC3339-timestamp> <ip> <prefix> <http-code> <ntfy-code>`, for example:
|
||||
|
||||
```
|
||||
2026-01-15T20:56:32Z 1.2.3.4 1.2.3.4/32 429 42901
|
||||
2026-01-15T20:56:32Z 2001:db8::abcd 2001:db8::/64 429 42909
|
||||
```
|
||||
|
||||
`<prefix>` is `<ip>` masked to the rate-limiting prefix (`visitor-prefix-bits-ipv4`/`-ipv6`) -- the
|
||||
same unit ntfy rate-limits by. Have the fail2ban filter capture the bare `<ip>` (the action then
|
||||
applies the prefix):
|
||||
|
||||
=== "server.yml"
|
||||
```yaml
|
||||
ban-file: "/var/log/ntfy/ban.log"
|
||||
ban-window: "10m"
|
||||
ban-threshold: 100
|
||||
ban-weights:
|
||||
- "42909:10" # too many auth failures -> brute force, ban fast
|
||||
# everything else 4xx/5xx defaults to weight 1
|
||||
```
|
||||
|
||||
=== "/etc/fail2ban/filter.d/ntfy-ban.conf"
|
||||
```
|
||||
[Definition]
|
||||
failregex = ^\S+ <HOST> \S+ \d+ \d+$
|
||||
datepattern = ^%%Y-%%m-%%dT%%H:%%M:%%S
|
||||
ignoreregex =
|
||||
```
|
||||
|
||||
=== "/etc/fail2ban/jail.d/ntfy-ban.local"
|
||||
```
|
||||
[ntfy-ban]
|
||||
enabled = true
|
||||
filter = ntfy-ban
|
||||
action = iptables-multiport[name=ntfy-ban, port="http,https", protocol=tcp]
|
||||
logpath = /var/log/ntfy/ban.log
|
||||
maxretry = 1
|
||||
findtime = 1m
|
||||
bantime = 1h
|
||||
```
|
||||
|
||||
## IPv6 support
|
||||
ntfy fully supports IPv6, though there are a few things to keep in mind.
|
||||
|
||||
@@ -2159,13 +2225,14 @@ See [Installation for Docker](install.md#docker) for an example of how this coul
|
||||
If configured, ntfy can expose a `/metrics` endpoint for [Prometheus](https://prometheus.io/), which can then be used to
|
||||
create dashboards and alerts (e.g. via [Grafana](https://grafana.com/)).
|
||||
|
||||
To configure the metrics endpoint, either set `enable-metrics` and/or set the `metrics-listen-http` option to a dedicated
|
||||
To configure the metrics endpoint, either set `enable-metrics`, or set the `metrics-listen-http` option to a dedicated
|
||||
listen address. Metrics may be considered sensitive information, so before you enable them, be sure you know what you are
|
||||
doing, and/or secure access to the endpoint in your reverse proxy.
|
||||
|
||||
- `enable-metrics` enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket)
|
||||
- `metrics-listen-http` exposes the metrics endpoint via a dedicated `[IP]:port`. If set, this option implicitly
|
||||
enables metrics as well, e.g. "10.0.1.1:9090" or ":9090"
|
||||
- `metrics-listen-http` moves the metrics endpoint to a dedicated `[IP]:port`, e.g. "10.0.1.1:9090" or ":9090". It
|
||||
implicitly enables metrics. If set, the metrics are served only on that dedicated port, and the default ntfy server
|
||||
does not serve /metrics, even if `enable-metrics` is also set.
|
||||
|
||||
=== "server.yml (Using default port)"
|
||||
```yaml
|
||||
@@ -2328,6 +2395,10 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||
| `visitor-topic-creation-limit-replenish` | `NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH` | *duration* | 1m | Rate limiting: Rate at which the per-visitor topic-creation bucket is refilled (one new topic per x). |
|
||||
| `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 |
|
||||
| `ban-file` | `NTFY_BAN_FILE` | *filename* | - | Abuse ban-feed: file confirmed abusive visitor IPs are appended to, for fail2ban to tail. Empty disables the feature. See [Banning bad actors](#banning-bad-actors-fail2ban) |
|
||||
| `ban-window` | `NTFY_BAN_WINDOW` | *duration* | 10m | Abuse ban-feed: rolling window over which weighted strikes are counted, per IP prefix |
|
||||
| `ban-threshold` | `NTFY_BAN_THRESHOLD` | *number* | 100 | Abuse ban-feed: weighted strikes per `ban-window` before a prefix is written to `ban-file` |
|
||||
| `ban-weights` | `NTFY_BAN_WEIGHTS` | *list of KEY:WEIGHT* | `42909:10`| Abuse ban-feed: per-code strike weights (exact code, family `429*`, or `*`; longest match wins; `0` exempts). See [Banning bad actors](#banning-bad-actors-fail2ban) |
|
||||
| `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 |
|
||||
|
||||
+6
-1
@@ -28,7 +28,7 @@ via the following channels:
|
||||
| Channel | Contact | Description |
|
||||
|-----------------------|-----------------------------------------------------|------------------------------------------|
|
||||
| **General Support** | [support@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Direct email support for Pro subscribers |
|
||||
| **Billing Inquiries** | [billing@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Inquire about billing issues |
|
||||
| **Billing Inquiries** | [billing@mail.ntfy.sh](mailto:billing@mail.ntfy.sh) | Inquire about billing issues |
|
||||
| **Discord/Matrix** | Mention your Pro status | Priority responses in community channels |
|
||||
|
||||
Please include your ntfy.sh username when contacting support so we can verify your subscription status.
|
||||
@@ -37,6 +37,11 @@ Please include your ntfy.sh username when contacting support so we can verify yo
|
||||
|
||||
If you discover a security vulnerability, please report it responsibly via [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh). See also: [SECURITY.md](https://github.com/binwiederhier/ntfy/blob/main/SECURITY.md).
|
||||
|
||||
## Abuse reports
|
||||
|
||||
To report spam, phishing, or other abuse of ntfy.sh, please email [abuse@mail.ntfy.sh](mailto:abuse@mail.ntfy.sh).
|
||||
Please include the topic name and any relevant message details so we can investigate.
|
||||
|
||||
## Other inquiries
|
||||
|
||||
For questions about our [privacy policy](privacy.md), data handling, or to exercise your data rights
|
||||
|
||||
+38
-38
@@ -34,37 +34,37 @@ as a service starting at boot time.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.26.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.26.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.27.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.27.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.27.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.26.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.26.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.27.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.27.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.27.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.26.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.26.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.27.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.27.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.27.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.26.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.26.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.27.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.27.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.27.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
@@ -84,25 +84,25 @@ Install the ntfy server unit file (which contains parameters to start the servic
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo mv ntfy_2.26.0_linux_amd64/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.27.0_linux_amd64/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo mv ntfy_2.26.0_linux_armv6/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.27.0_linux_armv6/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mv ntfy_2.26.0_linux_armv7/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.27.0_linux_armv7/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mv ntfy_2.26.0_linux_arm64/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.27.0_linux_arm64/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
@@ -118,25 +118,25 @@ Install the ntfy server service script:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo mv ntfy_2.26.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.27.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo mv ntfy_2.26.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.27.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mv ntfy_2.26.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.27.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mv ntfy_2.26.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.27.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
@@ -204,7 +204,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -212,7 +212,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv6.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -220,7 +220,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -228,7 +228,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -238,28 +238,28 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_amd64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv6.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv6.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv7.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
@@ -301,18 +301,18 @@ pkg install go-ntfy
|
||||
|
||||
## macOS
|
||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_darwin_all.tar.gz),
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_darwin_all.tar.gz),
|
||||
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
||||
|
||||
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
||||
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||
|
||||
```bash
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_darwin_all.tar.gz > ntfy_2.26.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.26.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.26.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_darwin_all.tar.gz > ntfy_2.27.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.27.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.27.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_2.26.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
cp ntfy_2.27.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
ntfy --help
|
||||
```
|
||||
|
||||
@@ -333,7 +333,7 @@ brew install ntfy
|
||||
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
|
||||
To install, you can either
|
||||
|
||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_windows_amd64.zip),
|
||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_windows_amd64.zip),
|
||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
||||
* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
|
||||
|
||||
|
||||
@@ -84,13 +84,13 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [symfony/ntfy-notifier](https://symfony.com/components/NtfyNotifier) ⭐ - Symfony Notifier integration for ntfy (PHP)
|
||||
- [ntfy-java](https://github.com/MaheshBabu11/ntfy-java/) - A Java package to interact with a ntfy server (Java)
|
||||
- [aiontfy](https://github.com/tr4nt0r/aiontfy) - Asynchronous client library for publishing and subscribing to ntfy (Python)
|
||||
- [ex_ntfy](https://github.com/houllette/ex_ntfy) - Elixir SDK covering publishing, polling, and streaming subscriptions for ntfy servers (Elixir)
|
||||
|
||||
## CLIs + GUIs
|
||||
|
||||
- [ntfy.sh.sh](https://github.com/mininmobile/ntfy.sh.sh) - Run scripts on ntfy.sh events
|
||||
- [ntfy-desktop](https://codeberg.org/zvava/ntfy-desktop) - Cross-platform desktop application for ntfy
|
||||
- [ntfy-desktop](https://github.com/Aetherinox/ntfy-desktop) - Desktop client for Windows, Linux, and MacOS with push notifications
|
||||
- [ntfy svelte front-end](https://github.com/novatorem/Ntfy) - Front-end built with svelte
|
||||
- [ntfy Desktop (Windows)](https://github.com/simoneferrari/ntfy-desktop) - Native Windows desktop client with multi-server support, toast notifications and message history, built with WPF and .NET (C#)
|
||||
- [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic
|
||||
- [ntfysh-windows](https://github.com/mshafer1/ntfysh-windows) - A ntfy client for Windows Desktop
|
||||
|
||||
+4
-1
@@ -3227,7 +3227,10 @@ your templates there first ([example for Grafana alert](https://repeatit.io/#/sh
|
||||
!!! info
|
||||
A few Go template features are disabled for user-supplied templates: `{{define}}`, `{{template}}`,
|
||||
`{{block}}`, and `{{call}}` are not allowed. Templates also run with a short execution time limit --
|
||||
a template that loops too long is stopped and rejected with an HTTP 400 error.
|
||||
a template that loops too long is stopped and rejected with an HTTP 400 error. Templates are
|
||||
limited to 32 KB in size, `printf` widths and precisions must be below 1000 (`%999d` is
|
||||
allowed, `%1000d` is not), including the `%*d` form that takes the width from an argument, and
|
||||
`indent`/`nindent` are limited to 100 spaces.
|
||||
|
||||
### Template functions
|
||||
ntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig),
|
||||
|
||||
+77
-42
@@ -6,12 +6,83 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
| Component | Version | Release date |
|
||||
|------------------|---------|---------------|
|
||||
| ntfy server | v2.26.0 | July 9, 2026 |
|
||||
| ntfy Android app | v1.24.0 | Mar 5, 2026 |
|
||||
| ntfy server | v2.27.0 | Aug 4, 2026 |
|
||||
| ntfy Android app | v1.25.2 | July 23, 2026 |
|
||||
| ntfy iOS app | v1.7.0 | May 30, 2026 |
|
||||
|
||||
Please check out the release notes for [upcoming releases](#not-released-yet) below.
|
||||
|
||||
### ntfy server v2.27.0
|
||||
Released August 4, 2026
|
||||
|
||||
This release lets you sign in with your verified email address instead of your username, which should help if you ever
|
||||
signed up with an email and then forgot which username you picked. It also hardens the message templating engine against
|
||||
a few ways a small template could eat a lot of memory, and it drops the "experimental" label from
|
||||
[PostgreSQL support](config.md#postgresql), which has been running ntfy.sh for a while now.
|
||||
|
||||
I also did a bunch of refactoring in, mostly in preparation for being able to cluster ntfy nodes and scale the service
|
||||
horizontally. It'll be a while until then, ... baby steps.
|
||||
|
||||
**Security:**
|
||||
|
||||
* Limit message templates (`Template: yes`) to 32 KB, limit `printf` widths and precisions to below 1000, and limit `indent`/`nindent` to 100 spaces, preventing excessive memory use from a single small template
|
||||
* Exclude secrets from the config hash served to the web app, preventing a rather theoretical information leak
|
||||
|
||||
**Features:**
|
||||
|
||||
* Allow logging in with your verified primary email address (in addition to your username), so a password reset no longer leaves you unable to sign in when you only remember the email you signed up with
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix Twilio phone calls and phone number verifications failing silently when Twilio rejected the request, and move the Twilio integration into its own `twilio` package
|
||||
* Move the Prometheus metrics into a dedicated `metrics` package
|
||||
* Message cache databases from ntfy older than v1.10.0 (November 2021) can no longer be migrated; upgrade via an older ntfy version first, or delete the cache database
|
||||
* Fix `user_phone` table in the SQLite user database referencing a dropped table after the v2.14 schema migration; repaired automatically by a new migration
|
||||
|
||||
## ntfy Android v1.25.2
|
||||
Released July 23, 2026
|
||||
|
||||
This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out
|
||||
or even gave ntfy bad reviews. I heard you! You can re-enable the alert in the advanced settings.
|
||||
|
||||
The release also tries to be smarter about not retrying the connection at all if the app is in flight mode, or has no network. If there
|
||||
is no network, ntfy now keeps the foreground service alive and shows a "Waiting for network" notification, then resumes automatically
|
||||
once connectivity returns.
|
||||
|
||||
Another change related to the networking is that we now force-reconnect when the connection is changed, e.g. during transitions
|
||||
from Wi-Fi to cellular network, or vice versa. That should allow for faster transitions during hand-overs.
|
||||
|
||||
We also increase the client-side WebSocket ping interval from 1 minute to 3 minutes, which should slightly improve battery life,
|
||||
especially when paired with increaseing the server-side `keepalive-interval` in your self-hosted server.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add configurable "Alert when connection is lost" setting, turned off by default ([#1665](https://github.com/binwiederhier/ntfy/issues/1665), [#1662](https://github.com/binwiederhier/ntfy/issues/1662), [#1652](https://github.com/binwiederhier/ntfy/issues/1652), [#1655](https://github.com/binwiederhier/ntfy/issues/1655), thanks to [@tintamarre](https://github.com/tintamarre), [@sjozs](https://github.com/sjozs), [@TheRealOne78](https://github.com/TheRealOne78), and [@DAE51D](https://github.com/DAE51D) for reporting)
|
||||
* Handle "no network" gracefully: when the device is offline or in airplane mode, ntfy now stops retrying, suppresses the connection-lost alert, and keeps the foreground service alive with a "Waiting for network" notification, resuming instant delivery automatically when connectivity returns ([ntfy-android#165](https://github.com/binwiederhier/ntfy-android/pull/165), thanks to [@tintamarre](https://github.com/tintamarre) for the contribution, and [#1709](https://github.com/binwiederhier/ntfy/issues/1709), thanks to [@isaitgirl](https://github.com/isaitgirl) for reporting)
|
||||
* Improve battery life by increasing WebSocket client ping interval from 1 min to 3 min, and reconnect instantly on Wi-Fi/cellular/VPN transitions ([ntfy-android#113](https://github.com/binwiederhier/ntfy-android/pull/113), thanks to [@ftilde](https://github.com/ftilde) for the investigation)
|
||||
* Disable UnifiedPush components when UnifiedPush is disabled in settings ([ntfy-android#168](https://github.com/binwiederhier/ntfy-android/pull/168), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix the "connection lost" alert briefly disappearing and re-firing when roaming between networks (e.g. Wi-Fi to cellular), by no longer cancelling it during the transient no-network gap of a handover
|
||||
* Fix the "connection lost" alert repeatedly waking the screen while a server stayed unreachable, by no longer re-posting the alert once it is already showing
|
||||
* Fix the "connection lost" alert firing late, erratically, or not at all when a connection kept dropping (e.g. a flaky server) rather than being refused outright, by tracking how long the connection has been down independently of whether the drop warrants a UI error
|
||||
* Undo automatic phone number linking for numbers in message body ([ntfy-android#170](https://github.com/binwiederhier/ntfy-android/pull/170), thanks to [@acortelyou](https://github.com/acortelyou) for the contribution)
|
||||
* Fix subscription icons disappearing after a few days due to Android clearing cache ([#1322](https://github.com/binwiederhier/ntfy/issues/1322), thanks to [@mcanning](https://github.com/mcanning) for reporting)
|
||||
* Fix UnifiedPush `failed_reason` being sent as an enum instead of a string, which caused an exception in receiving apps that read it as a string extra ([ntfy-android#182](https://github.com/binwiederhier/ntfy-android/pull/182), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
|
||||
|
||||
### ntfy server v2.26.3
|
||||
Released July 20, 2026
|
||||
|
||||
This is a hotfix release, useful pretty much only for ntfy.sh. It was adds the ability to track abusive IPs more
|
||||
efficiently, reducing the load on the IP banning services and preventing them from falling behind and leaving abusers
|
||||
unbanned for too long. It works by tracking HTTP errors, and writing out a ban file that fail2ban can read and ban
|
||||
offenders instantly. See [ban-feed](config.md#ban-feed) for details.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add an abuse ban-feed: when enabled via `ban-file`, ntfy tracks a weighted strike budget per visitor and appends abusive IPs to a file that fail2ban can tail and ban on sight (`ban-file`, `ban-window`, `ban-threshold`, `ban-weights`; see [ban-feed docs](config.md#ban-feed))
|
||||
|
||||
### ntfy server v2.26.0
|
||||
Released July 9, 2026
|
||||
|
||||
@@ -23,7 +94,7 @@ and a fix that strips unsafe URL protocols from rendered Markdown.
|
||||
|
||||
**Security:**
|
||||
|
||||
* Prevent a CPU denial of service via message templates (`Template: yes`) ([GHSA-rhwf-xgc9-m9fp](https://github.com/binwiederhier/ntfy/security/advisories/GHSA-rhwf-xgc9-m9fp), [#1826](https://github.com/binwiederhier/ntfy/pull/1826), thanks to [@alanturing881](https://github.com/alanturing881) for reporting)
|
||||
* Prevent a CPU denial of service via message templates (`Template: yes`) ([#1826](https://github.com/binwiederhier/ntfy/pull/1826), thanks to [@alanturing881](https://github.com/alanturing881), [@5ud0er](https://github.com/5ud0er) and [@jvoisin](https://github.com/jvoisin) for reporting)
|
||||
|
||||
**Features:**
|
||||
|
||||
@@ -256,7 +327,7 @@ to the primary until the replica recovers.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Support [PostgreSQL read replicas](config.md#postgresql-experimental) for offloading non-critical read queries via `database-replica-urls` config option ([#1648](https://github.com/binwiederhier/ntfy/pull/1648))
|
||||
* Support [PostgreSQL read replicas](config.md#postgresql) for offloading non-critical read queries via `database-replica-urls` config option ([#1648](https://github.com/binwiederhier/ntfy/pull/1648))
|
||||
* Add interactive [config generator](config.md#config-generator) to the documentation to help create server configuration files ([#1654](https://github.com/binwiederhier/ntfy/pull/1654))
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
@@ -268,7 +339,7 @@ to the primary until the replica recovers.
|
||||
Released March 7, 2026
|
||||
|
||||
This is the biggest release I've ever done on the server. It's 14,997 added lines of code, and 10,202 lines removed, all from
|
||||
one [pull request](https://github.com/binwiederhier/ntfy/pull/1619) that adds [PostgreSQL support](config.md#postgresql-experimental).
|
||||
one [pull request](https://github.com/binwiederhier/ntfy/pull/1619) that adds [PostgreSQL support](config.md#postgresql).
|
||||
|
||||
The code was written by Cursor and Claude, but reviewed and heavily tested over 2-3 weeks by me. I created comparison documents,
|
||||
went through all queries multiple times and reviewed the logic over and over again. I also did load tests and manual regression tests,
|
||||
@@ -279,7 +350,7 @@ if things are working (or not working). There is a [one-off migration tool](http
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add experimental [PostgreSQL support](config.md#postgresql-experimental) as an alternative database backend (message cache, user manager, web push subscriptions) via `database-url` config option ([#1114](https://github.com/binwiederhier/ntfy/issues/1114)/[#1619](https://github.com/binwiederhier/ntfy/pull/1619), thanks to [@brettinternet](https://github.com/brettinternet) for reporting)
|
||||
* Add experimental [PostgreSQL support](config.md#postgresql) as an alternative database backend (message cache, user manager, web push subscriptions) via `database-url` config option ([#1114](https://github.com/binwiederhier/ntfy/issues/1114)/[#1619](https://github.com/binwiederhier/ntfy/pull/1619), thanks to [@brettinternet](https://github.com/brettinternet) for reporting)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
@@ -2007,42 +2078,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Not released yet
|
||||
|
||||
### ntfy server v2.26.1 (UNRELEASED)
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add an abuse ban-feed: when enabled via `ban-file`, ntfy tracks a weighted strike budget per visitor and appends abusive IPs to a file that fail2ban can tail and ban on sight (see `ban-file`, `ban-window`, `ban-threshold`, `ban-weights`)
|
||||
|
||||
### ntfy Android v1.25.1 (UNRELEASED)
|
||||
|
||||
This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out
|
||||
or even gave ntfy bad reviews. I heard you! You can re-enable the alert in the advanced settings.
|
||||
|
||||
The release also tries to be smarter about not retrying the connection at all if the app is in flight mode, or has no network. If there
|
||||
is no network, ntfy now keeps the foreground service alive and shows a "Waiting for network" notification, then resumes automatically
|
||||
once connectivity returns.
|
||||
|
||||
Another change related to the networking is that we now force-reconnect when the connection is changed, e.g. during transitions
|
||||
from Wi-Fi to cellular network, or vice versa. That should allow for faster transitions during hand-overs.
|
||||
|
||||
We also increase the client-side WebSocket ping interval from 1 minute to 3 minutes, which should slightly improve battery life,
|
||||
especially when paired with increaseing the server-side `keepalive-interval` in your self-hosted server.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add configurable "Alert when connection is lost" setting, turned off by default ([#1665](https://github.com/binwiederhier/ntfy/issues/1665), [#1662](https://github.com/binwiederhier/ntfy/issues/1662), [#1652](https://github.com/binwiederhier/ntfy/issues/1652), [#1655](https://github.com/binwiederhier/ntfy/issues/1655), thanks to [@tintamarre](https://github.com/tintamarre), [@sjozs](https://github.com/sjozs), [@TheRealOne78](https://github.com/TheRealOne78), and [@DAE51D](https://github.com/DAE51D) for reporting)
|
||||
* Handle "no network" gracefully: when the device is offline or in airplane mode, ntfy now stops retrying, suppresses the connection-lost alert, and keeps the foreground service alive with a "Waiting for network" notification, resuming instant delivery automatically when connectivity returns ([ntfy-android#165](https://github.com/binwiederhier/ntfy-android/pull/165), thanks to [@tintamarre](https://github.com/tintamarre) for the contribution, and [#1709](https://github.com/binwiederhier/ntfy/issues/1709), thanks to [@isaitgirl](https://github.com/isaitgirl) for reporting)
|
||||
* Improve battery life by increasing WebSocket client ping interval from 1 min to 3 min, and reconnect instantly on Wi-Fi/cellular/VPN transitions ([ntfy-android#113](https://github.com/binwiederhier/ntfy-android/pull/113), thanks to [@ftilde](https://github.com/ftilde) for the investigation)
|
||||
* Disable UnifiedPush components when UnifiedPush is disabled in settings ([ntfy-android#168](https://github.com/binwiederhier/ntfy-android/pull/168), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix the "connection lost" alert repeatedly waking the screen while a server stayed unreachable, by no longer re-posting the alert once it is already showing
|
||||
* Fix the "connection lost" alert firing late, erratically, or not at all when a connection kept dropping (e.g. a flaky server) rather than being refused outright, by tracking how long the connection has been down independently of whether the drop warrants a UI error
|
||||
* Undo automatic phone number linking for numbers in message body ([ntfy-android#170](https://github.com/binwiederhier/ntfy-android/pull/170), thanks to [@acortelyou](https://github.com/acortelyou) for the contribution)
|
||||
* Fix subscription icons disappearing after a few days due to Android clearing cache ([#1322](https://github.com/binwiederhier/ntfy/issues/1322), thanks to [@mcanning](https://github.com/mcanning) for reporting)
|
||||
* Fix UnifiedPush `failed_reason` being sent as an enum instead of a string, which caused an exception in receiving apps that read it as a string extra ([ntfy-android#182](https://github.com/binwiederhier/ntfy-android/pull/182), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
|
||||
|
||||
### ntfy iOS app v1.8.0 (UNRELEASED)
|
||||
|
||||
**Features:**
|
||||
|
||||
@@ -3,23 +3,23 @@ module heckel.io/ntfy/v2
|
||||
go 1.25.8
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.22.0 // indirect
|
||||
cloud.google.com/go/storage v1.63.0 // indirect
|
||||
cloud.google.com/go/firestore v1.24.0 // indirect
|
||||
cloud.google.com/go/storage v1.64.0 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.13
|
||||
github.com/gabriel-vasile/mimetype v1.4.15
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mattn/go-sqlite3 v1.14.47
|
||||
github.com/mattn/go-sqlite3 v1.14.49
|
||||
github.com/olebedev/when v1.1.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/term v0.44.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/term v0.45.0
|
||||
golang.org/x/time v0.15.0
|
||||
google.golang.org/api v0.287.0
|
||||
google.golang.org/api v0.291.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
@@ -28,29 +28,29 @@ 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.20.0
|
||||
firebase.google.com/go/v4 v4.21.0
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
github.com/stripe/stripe-go/v74 v74.30.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/text v0.39.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/text v0.40.0
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.25.2 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.20.0 // indirect
|
||||
cloud.google.com/go/auth v0.22.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.11.0 // indirect
|
||||
cloud.google.com/go/longrunning v1.1.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.29.0 // indirect
|
||||
cloud.google.com/go/iam v1.12.0 // indirect
|
||||
cloud.google.com/go/longrunning v1.2.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.30.0 // indirect
|
||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.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
|
||||
@@ -62,15 +62,15 @@ require (
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.19 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.23.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@@ -79,7 +79,7 @@ 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.69.0 // indirect
|
||||
github.com/prometheus/common v0.70.1 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect
|
||||
@@ -94,12 +94,12 @@ require (
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect
|
||||
google.golang.org/grpc v1.82.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260803160001-6ac0973c030d // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
|
||||
google.golang.org/grpc v1.83.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -2,40 +2,40 @@ cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
|
||||
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
|
||||
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
|
||||
cloud.google.com/go/auth v0.22.0 h1:Xp9wAKkLoeaYb5pYZZoQGz4E9sdPxIbzS3gywZE3ciQ=
|
||||
cloud.google.com/go/auth v0.22.0/go.mod h1:M9o2Oz+YI2jAfxewJgb1vyI3vceHF+eohmxyzmrl+9s=
|
||||
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.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/firestore v1.22.0 h1:avooeboIq37vKXobrbPUFhFBxS/c3FqmWoX0xs8dO6E=
|
||||
cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU=
|
||||
cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM=
|
||||
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
|
||||
cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k=
|
||||
cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI=
|
||||
cloud.google.com/go/longrunning v1.1.0 h1:qJ0R0IA8ONaRCNWTRPAS0iAmt1bj3TVgJ40z7ZGRslE=
|
||||
cloud.google.com/go/longrunning v1.1.0/go.mod h1:tH+A/6UvNypiPJWAQaKCsh+xiGbB23wUO8egwUXlD2E=
|
||||
cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY=
|
||||
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
|
||||
cloud.google.com/go/storage v1.63.0 h1:hvXF2xfg9I32bjujggxgkEZn/Ej6sJ9pieFgeueBLrQ=
|
||||
cloud.google.com/go/storage v1.63.0/go.mod h1:tirWVptrFNo5GEX2DQ47JooF7yaweJdAJ1hYAVMvKzE=
|
||||
cloud.google.com/go/firestore v1.24.0 h1:x0Z3hrgjYgo2wI9whuBRQcNc2hYwzZDQy/7pkUXbXcs=
|
||||
cloud.google.com/go/firestore v1.24.0/go.mod h1:5aojyjN4olKUnBZDCRWwM+NsdrrCX3t1qfyERZGOonM=
|
||||
cloud.google.com/go/iam v1.12.0 h1:Aki3bX9aHUDKPHfnRJfDcTdVedvy6quGBQcTqx3DRXk=
|
||||
cloud.google.com/go/iam v1.12.0/go.mod h1:FEZ4lXpADAC2AIpQY7LANNjjwyQ2jK439CI2VaD+sLY=
|
||||
cloud.google.com/go/logging v1.19.0 h1:NCqhdVUg3wQ8Cobdf16FDSuTGi3+6+hdSBHrY5TsR6Q=
|
||||
cloud.google.com/go/logging v1.19.0/go.mod h1:i40NZCHC9Gqvod4yE+yQfDWwlgwW/SrshkkGibCHxcA=
|
||||
cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM=
|
||||
cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0=
|
||||
cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY=
|
||||
cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM=
|
||||
cloud.google.com/go/storage v1.64.0 h1:KLpxI/oX9LxeRsNqn877d2WyeT3ryiEwnGt8pwcSPZg=
|
||||
cloud.google.com/go/storage v1.64.0/go.mod h1:lWyAtwvDZHdL3k68WVKbESP6bmWaV23ZJJ/JEVw/ZaQ=
|
||||
cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E=
|
||||
cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
|
||||
firebase.google.com/go/v4 v4.20.0 h1:ighpjeAC45rY/95cUQ+ojIKlKcTnz2YC0ldam56z2YU=
|
||||
firebase.google.com/go/v4 v4.20.0/go.mod h1:hqhkQtZkThGH42TnaYi7A8EFR1E0FEuB5oHvJ1Q57t8=
|
||||
firebase.google.com/go/v4 v4.21.0 h1:HBZV4jrLtFYj8EwWyqEZOuRLfkfkV2bpnfyyXHOhPxY=
|
||||
firebase.google.com/go/v4 v4.21.0/go.mod h1:CDumIdA5oTiyDpLNVcQoW8ZrB5CTgyE2D45DuENIABg=
|
||||
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.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 h1:bN1gA3of5bXtbnLsRPrwfmbbe7A5UWFlcTHseujLnpc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0/go.mod h1:Yj5vHEz/aAepZGliRJsA6uvHAVAQyEwajq9ORCHPxzM=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0 h1:c/Ivw7FuawPLfrr+zB0LZKeCchO2cAHQpF2qZ6OV7rQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0/go.mod h1:Zba7lknY/d78oxbKqFTmCsaGwfpzeJ3ktrrLXtnTV6g=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.59.0 h1:xTXsqDOj5k9mK3VVWHYUryryJCIdYfXxdjKFwpzINUw=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.59.0/go.mod h1:V9g30lTKzfUsEW+gpWssck6u9IhARajmipodImLLcwI=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.0 h1:18FRm6ZcN/x9+ZmhMr96hLcTtlLn2/gHPuDLVeg7XcY=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk=
|
||||
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=
|
||||
@@ -68,13 +68,13 @@ github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMD
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
|
||||
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
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=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
@@ -96,10 +96,10 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k=
|
||||
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
|
||||
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.19 h1:mMOE7DN2+p76/EdIrmAy9B9bH+yC4563vmnJ34QR8i4=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.19/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k=
|
||||
github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE=
|
||||
github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -112,16 +112,16 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
|
||||
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
|
||||
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
@@ -135,12 +135,12 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||
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.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk=
|
||||
github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
|
||||
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
||||
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -195,8 +195,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
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.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
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=
|
||||
@@ -211,8 +211,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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -222,8 +222,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.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
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=
|
||||
@@ -236,8 +236,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -247,8 +247,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -260,8 +260,8 @@ 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.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -274,18 +274,18 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/api v0.287.0 h1:CQDMqUiqZZ0U/Yge3zyjAhNQ0OSYEH0PaA7l4xtEen4=
|
||||
google.golang.org/api v0.287.0/go.mod h1:pPW85yt3Iuc3unkpaMhFtMmOqnTdCwCqEOaUlnuxRlQ=
|
||||
google.golang.org/api v0.291.0 h1:wfPbbY+mr9c7wZLqqzrHJLft/q8iFKREd6IgTBUene0=
|
||||
google.golang.org/api v0.291.0/go.mod h1:at7kwWbuonglBFEBoeMDAV1bguHqL3qf0BHFsv3coa0=
|
||||
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-20260630182238-925bb5da69e7 h1:lQG76ePMKmtujel4VIVMiFoHVWVNtJdawbCZJtWlVXU=
|
||||
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7/go.mod h1:LwlOWYBU335L+sR55UuR5fbbU8KmEX+3tUHf3SwMmhM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 h1:eM/YSd5bBFagF51o1E745Ta7RwzpW0h+z+QDNZOgmQ8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
|
||||
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/genproto v0.0.0-20260803160001-6ac0973c030d h1:33JLrUF0lFT31667SAtJzZAjLewV0ew5Mizks4caz0A=
|
||||
google.golang.org/genproto v0.0.0-20260803160001-6ac0973c030d/go.mod h1:I7vGRdTamb7ukERkgP9I+0e4p21O4ak3cM7ICA3krg8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
|
||||
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
||||
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.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
const (
|
||||
tagMessageCache = "message_cache"
|
||||
schemaStore = "message" // Store name in the schema_version table (see db/schema)
|
||||
)
|
||||
|
||||
var errNoRows = errors.New("no rows found")
|
||||
@@ -34,6 +35,7 @@ type queries struct {
|
||||
selectMessagesSinceIDScheduled string
|
||||
selectMessagesLatest string
|
||||
selectMessagesDue string
|
||||
selectMessagesDueForUpdate string // Postgres-only: claims due rows via FOR UPDATE SKIP LOCKED; empty for SQLite/mem
|
||||
deleteExpiredMessages string
|
||||
updateMessagePublished string
|
||||
selectMessagesCount string
|
||||
@@ -237,6 +239,15 @@ func (c *Cache) messagesLatest(topic string) ([]*model.Message, error) {
|
||||
|
||||
// MessagesDue returns all messages that are due for publishing
|
||||
func (c *Cache) MessagesDue() ([]*model.Message, error) {
|
||||
// On Postgres (cluster mode), claim due rows atomically so that concurrent delayed senders
|
||||
// on other nodes cannot pick up the same message. We SELECT ... FOR UPDATE SKIP LOCKED and
|
||||
// mark the claimed rows published in the same transaction; each row is thus handed to exactly
|
||||
// one node. We deliberately mark published at claim time (not after delivery) to keep the row
|
||||
// lock short: holding a transaction open across Firebase/WebPush/email delivery would be far
|
||||
// worse than the small at-most-once window if a node crashes between claim and delivery.
|
||||
if c.queries.selectMessagesDueForUpdate != "" {
|
||||
return c.claimMessagesDue()
|
||||
}
|
||||
rows, err := c.db.Query(c.queries.selectMessagesDue, time.Now().Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -244,6 +255,32 @@ func (c *Cache) MessagesDue() ([]*model.Message, error) {
|
||||
return readMessages(rows)
|
||||
}
|
||||
|
||||
// claimMessagesDue is the Postgres claiming path for MessagesDue (see its comment).
|
||||
func (c *Cache) claimMessagesDue() ([]*model.Message, error) {
|
||||
tx, err := c.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
rows, err := tx.Query(c.queries.selectMessagesDueForUpdate, time.Now().Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messages, err := readMessages(rows) // reads all rows and closes them
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range messages {
|
||||
if _, err := tx.Exec(c.queries.updateMessagePublished, m.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// DeleteExpiredMessages deletes up to `limit` expired messages in a single query
|
||||
// and returns the number of deleted rows.
|
||||
func (c *Cache) DeleteExpiredMessages(limit int) (int64, error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/schema"
|
||||
)
|
||||
|
||||
// PostgreSQL runtime query constants
|
||||
@@ -60,6 +61,13 @@ const (
|
||||
WHERE time <= $1 AND published = FALSE
|
||||
ORDER BY time, id
|
||||
`
|
||||
postgresSelectMessagesDueForUpdateQuery = `
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
|
||||
FROM message
|
||||
WHERE time <= $1 AND published = FALSE
|
||||
ORDER BY time, id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`
|
||||
postgresUpdateMessagePublishedQuery = `UPDATE message SET published = TRUE WHERE mid = $1`
|
||||
postgresSelectMessagesCountQuery = `SELECT COUNT(*) FROM message`
|
||||
postgresSelectTopicsQuery = `SELECT topic FROM message GROUP BY topic`
|
||||
@@ -87,6 +95,7 @@ var postgresQueries = queries{
|
||||
selectMessagesSinceIDScheduled: postgresSelectMessagesSinceIDIncludeScheduledQuery,
|
||||
selectMessagesLatest: postgresSelectMessagesLatestQuery,
|
||||
selectMessagesDue: postgresSelectMessagesDueQuery,
|
||||
selectMessagesDueForUpdate: postgresSelectMessagesDueForUpdateQuery,
|
||||
deleteExpiredMessages: postgresDeleteExpiredMessagesQuery,
|
||||
updateMessagePublished: postgresUpdateMessagePublishedQuery,
|
||||
selectMessagesCount: postgresSelectMessagesCountQuery,
|
||||
@@ -102,7 +111,7 @@ var postgresQueries = queries{
|
||||
|
||||
// NewPostgresStore creates a new PostgreSQL-backed message cache store using an existing database connection pool.
|
||||
func NewPostgresStore(d *db.DB, batchSize int, batchTimeout time.Duration) (*Cache, error) {
|
||||
if err := setupPostgres(d.Primary()); err != nil {
|
||||
if err := schema.Migrate(d.Primary(), schema.Postgres, schemaStore, postgresCurrentSchemaVersion, postgresCreateTables, postgresMigrations); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newCache(d, postgresQueries, nil, batchSize, batchTimeout, false), nil
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/db/schema"
|
||||
)
|
||||
|
||||
// Initial PostgreSQL schema
|
||||
const (
|
||||
postgresCreateTablesQuery = `
|
||||
postgresCurrentSchemaVersion = 15
|
||||
postgresCreateTablesQuery = `
|
||||
CREATE TABLE IF NOT EXISTS message (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
mid TEXT NOT NULL,
|
||||
@@ -50,21 +47,9 @@ const (
|
||||
value BIGINT
|
||||
);
|
||||
INSERT INTO message_stats (key, value) VALUES ('messages', 0);
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
store TEXT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
);
|
||||
`
|
||||
)
|
||||
|
||||
// PostgreSQL schema management queries
|
||||
const (
|
||||
postgresCurrentSchemaVersion = 15
|
||||
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('message', $1)`
|
||||
postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'message'`
|
||||
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'message'`
|
||||
)
|
||||
|
||||
// PostgreSQL schema migrations
|
||||
const (
|
||||
// 14 -> 15
|
||||
@@ -73,51 +58,12 @@ const (
|
||||
`
|
||||
)
|
||||
|
||||
var postgresMigrations = map[int]func(d *sql.DB) error{
|
||||
14: postgresMigrateFrom14,
|
||||
}
|
||||
var (
|
||||
postgresCreateTables = schema.AsMigrateFunc(postgresCreateTablesQuery)
|
||||
|
||||
func setupPostgres(d *sql.DB) error {
|
||||
var schemaVersion int
|
||||
if err := d.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil {
|
||||
return setupNewPostgresDB(d)
|
||||
} else if schemaVersion == postgresCurrentSchemaVersion {
|
||||
return nil
|
||||
} else if schemaVersion > postgresCurrentSchemaVersion {
|
||||
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, postgresCurrentSchemaVersion)
|
||||
// postgresMigrations maps a schema version to the migration upgrading it to the next
|
||||
// version. Always append migrations at the end, never insert in the middle.
|
||||
postgresMigrations = map[int]schema.MigrateFunc{
|
||||
14: schema.AsMigrateFunc(postgresMigrate14To15CreateIndexQuery),
|
||||
}
|
||||
for i := schemaVersion; i < postgresCurrentSchemaVersion; i++ {
|
||||
fn, ok := postgresMigrations[i]
|
||||
if !ok {
|
||||
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
|
||||
} else if err := fn(d); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func postgresMigrateFrom14(d *sql.DB) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating message cache database schema: from 14 to 15")
|
||||
return db.ExecTx(d, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(postgresMigrate14To15CreateIndexQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(postgresUpdateSchemaVersionQuery, 15); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func setupNewPostgresDB(sqlDB *sql.DB) error {
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(postgresCreateTablesQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(postgresInsertSchemaVersionQuery, postgresCurrentSchemaVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package message_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
dbtest "heckel.io/ntfy/v2/db/test"
|
||||
"heckel.io/ntfy/v2/message"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
func TestPostgresStore_Migration_From14(t *testing.T) {
|
||||
// A pre-framework database at version 14: full v14 schema, version tracked in the
|
||||
// hand-rolled schema_version table, and no idx_message_attachment_expires yet
|
||||
testDB := dbtest.CreateTestPostgres(t)
|
||||
_, err := testDB.Exec(`
|
||||
CREATE TABLE message (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
mid TEXT NOT NULL,
|
||||
sequence_id TEXT NOT NULL,
|
||||
time BIGINT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
expires BIGINT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
priority INT NOT NULL,
|
||||
tags TEXT NOT NULL,
|
||||
click TEXT NOT NULL,
|
||||
icon TEXT NOT NULL,
|
||||
actions TEXT NOT NULL,
|
||||
attachment_name TEXT NOT NULL,
|
||||
attachment_type TEXT NOT NULL,
|
||||
attachment_size BIGINT NOT NULL,
|
||||
attachment_expires BIGINT NOT NULL,
|
||||
attachment_url TEXT NOT NULL,
|
||||
attachment_deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sender TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
encoding TEXT NOT NULL,
|
||||
published BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
CREATE INDEX idx_message_mid ON message (mid);
|
||||
CREATE INDEX idx_message_sequence_id ON message (sequence_id);
|
||||
CREATE INDEX idx_message_topic_published_time ON message (topic, published, time, id);
|
||||
CREATE INDEX idx_message_published_expires ON message (published, expires);
|
||||
CREATE INDEX idx_message_sender_attachment_expires ON message (sender, attachment_expires) WHERE user_id = '';
|
||||
CREATE INDEX idx_message_user_id_attachment_expires ON message (user_id, attachment_expires);
|
||||
CREATE TABLE message_stats (key TEXT PRIMARY KEY, value BIGINT);
|
||||
INSERT INTO message_stats (key, value) VALUES ('messages', 0);
|
||||
CREATE TABLE schema_version (store TEXT PRIMARY KEY, version INT NOT NULL);
|
||||
INSERT INTO schema_version (store, version) VALUES ('message', 14);
|
||||
`)
|
||||
require.Nil(t, err)
|
||||
store, err := message.NewPostgresStore(testDB, 0, 0)
|
||||
require.Nil(t, err)
|
||||
// The 14 -> 15 step ran: version bumped, partial index created
|
||||
var version int
|
||||
require.Nil(t, testDB.QueryRow(`SELECT version FROM schema_version WHERE store = 'message'`).Scan(&version))
|
||||
require.Equal(t, 15, version)
|
||||
var indexCount int
|
||||
require.Nil(t, testDB.QueryRow(`SELECT COUNT(*) FROM pg_indexes WHERE indexname = 'idx_message_attachment_expires' AND schemaname = current_schema()`).Scan(&indexCount))
|
||||
require.Equal(t, 1, indexCount)
|
||||
// And the store works
|
||||
require.Nil(t, store.AddMessage(model.NewDefaultMessage("mytopic", "hi there")))
|
||||
messages, err := store.Messages("mytopic", model.SinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, messages, 1)
|
||||
|
||||
// The migrated database must be structurally identical to a freshly created one
|
||||
freshDB := dbtest.CreateTestPostgres(t)
|
||||
_, err = message.NewPostgresStore(freshDB, 0, 0)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, dbtest.PostgresSchema(t, freshDB), dbtest.PostgresSchema(t, testDB))
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/schema"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
@@ -113,7 +114,10 @@ func NewSQLiteStore(filename, startupQueries string, cacheDuration time.Duration
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := setupSQLite(d, startupQueries, cacheDuration); err != nil {
|
||||
if err := runSQLiteStartupQueries(d, startupQueries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := schema.Migrate(d, schema.SQLite, schemaStore, sqliteCurrentSchemaVersion, sqliteCreateTables, sqliteMigrations(cacheDuration)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newCache(db.New(&db.Host{DB: d}, nil), sqliteQueries, &sync.Mutex{}, batchSize, batchTimeout, nop), nil
|
||||
|
||||
+30
-283
@@ -2,16 +2,15 @@ package message
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/db/schema"
|
||||
)
|
||||
|
||||
// Initial SQLite schema
|
||||
const (
|
||||
sqliteCreateTablesQuery = `
|
||||
sqliteCurrentSchemaVersion = 15
|
||||
sqliteCreateTablesQuery = `
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mid TEXT NOT NULL,
|
||||
@@ -55,29 +54,9 @@ const (
|
||||
`
|
||||
)
|
||||
|
||||
// Schema version management for SQLite
|
||||
// Schema migrations for SQLite. Databases older than schema version 1 (ntfy < v1.10.0,
|
||||
// November 2021) can no longer be migrated.
|
||||
const (
|
||||
sqliteCurrentSchemaVersion = 15
|
||||
sqliteCreateSchemaVersionTableQuery = `
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
);
|
||||
`
|
||||
sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`
|
||||
sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1`
|
||||
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
||||
)
|
||||
|
||||
// Schema migrations for SQLite
|
||||
const (
|
||||
// 0 -> 1
|
||||
sqliteMigrate0To1AlterMessagesTableQuery = `
|
||||
ALTER TABLE messages ADD COLUMN title TEXT NOT NULL DEFAULT('');
|
||||
ALTER TABLE messages ADD COLUMN priority INT NOT NULL DEFAULT(0);
|
||||
ALTER TABLE messages ADD COLUMN tags TEXT NOT NULL DEFAULT('');
|
||||
`
|
||||
|
||||
// 1 -> 2
|
||||
sqliteMigrate1To2AlterMessagesTableQuery = `
|
||||
ALTER TABLE messages ADD COLUMN published INT NOT NULL DEFAULT(1);
|
||||
@@ -193,67 +172,35 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
sqliteMigrations = map[int]func(db *sql.DB, cacheDuration time.Duration) error{
|
||||
0: sqliteMigrateFrom0,
|
||||
1: sqliteMigrateFrom1,
|
||||
2: sqliteMigrateFrom2,
|
||||
3: sqliteMigrateFrom3,
|
||||
4: sqliteMigrateFrom4,
|
||||
5: sqliteMigrateFrom5,
|
||||
6: sqliteMigrateFrom6,
|
||||
7: sqliteMigrateFrom7,
|
||||
8: sqliteMigrateFrom8,
|
||||
9: sqliteMigrateFrom9,
|
||||
10: sqliteMigrateFrom10,
|
||||
11: sqliteMigrateFrom11,
|
||||
12: sqliteMigrateFrom12,
|
||||
13: sqliteMigrateFrom13,
|
||||
14: sqliteMigrateFrom14,
|
||||
}
|
||||
sqliteCreateTables = schema.AsMigrateFunc(sqliteCreateTablesQuery)
|
||||
)
|
||||
|
||||
func setupSQLite(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
|
||||
if err := runSQLiteStartupQueries(db, startupQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
// If 'messages' table does not exist, this must be a new database
|
||||
var messagesCount int
|
||||
if err := db.QueryRow(sqliteSelectMessagesCountQuery).Scan(&messagesCount); err != nil {
|
||||
return setupNewSQLite(db)
|
||||
}
|
||||
// If 'messages' table exists (schema >= 0), check 'schemaVersion' table
|
||||
var schemaVersion int
|
||||
db.QueryRow(sqliteSelectSchemaVersionQuery).Scan(&schemaVersion) // Error means schema version is zero!
|
||||
// Do migrations
|
||||
if schemaVersion == sqliteCurrentSchemaVersion {
|
||||
return nil
|
||||
} else if schemaVersion > sqliteCurrentSchemaVersion {
|
||||
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, sqliteCurrentSchemaVersion)
|
||||
}
|
||||
for i := schemaVersion; i < sqliteCurrentSchemaVersion; i++ {
|
||||
fn, ok := sqliteMigrations[i]
|
||||
if !ok {
|
||||
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
|
||||
} else if err := fn(db, cacheDuration); err != nil {
|
||||
// sqliteMigrations returns the migration steps, keyed by the version they upgrade FROM. The
|
||||
// cache duration is carried into the 9 -> 10 step via closure (it backfills "expires" from it).
|
||||
// Always append migrations at the end, never insert in the middle.
|
||||
func sqliteMigrations(cacheDuration time.Duration) map[int]schema.MigrateFunc {
|
||||
return map[int]schema.MigrateFunc{
|
||||
1: schema.AsMigrateFunc(sqliteMigrate1To2AlterMessagesTableQuery),
|
||||
2: schema.AsMigrateFunc(sqliteMigrate2To3AlterMessagesTableQuery),
|
||||
3: schema.AsMigrateFunc(sqliteMigrate3To4AlterMessagesTableQuery),
|
||||
4: schema.AsMigrateFunc(sqliteMigrate4To5AlterMessagesTableQuery),
|
||||
5: schema.AsMigrateFunc(sqliteMigrate5To6AlterMessagesTableQuery),
|
||||
6: schema.AsMigrateFunc(sqliteMigrate6To7AlterMessagesTableQuery),
|
||||
7: schema.AsMigrateFunc(sqliteMigrate7To8AlterMessagesTableQuery),
|
||||
8: schema.AsMigrateFunc(sqliteMigrate8To9AlterMessagesTableQuery),
|
||||
9: func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate9To10AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(sqliteMigrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds()))
|
||||
return err
|
||||
}
|
||||
},
|
||||
10: schema.AsMigrateFunc(sqliteMigrate10To11AlterMessagesTableQuery),
|
||||
11: schema.AsMigrateFunc(sqliteMigrate11To12AlterMessagesTableQuery),
|
||||
12: schema.AsMigrateFunc(sqliteMigrate12To13AlterMessagesTableQuery),
|
||||
13: schema.AsMigrateFunc(sqliteMigrate13To14AlterMessagesTableQuery),
|
||||
14: schema.NopMigrateFunc, // Corresponds to Postgres migration
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupNewSQLite(sqlDB *sql.DB) error {
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteCreateTablesQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteCreateSchemaVersionTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
|
||||
@@ -264,203 +211,3 @@ func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom0(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 0 to 1")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate0To1AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteCreateSchemaVersionTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteInsertSchemaVersionQuery, 1); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom1(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 1 to 2")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate1To2AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 2); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom2(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 2 to 3")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate2To3AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 3); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom3(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 3 to 4")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate3To4AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 4); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom4(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 4 to 5")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate4To5AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 5); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom5(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 5 to 6")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate5To6AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 6); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom6(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 6 to 7")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate6To7AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 7); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom7(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 7 to 8")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate7To8AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 8); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom8(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 8 to 9")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate8To9AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 9); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom9(sqlDB *sql.DB, cacheDuration time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 9 to 10")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate9To10AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteMigrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds())); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 10); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom10(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 10 to 11")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate10To11AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 11); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom11(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 11 to 12")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate11To12AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 12); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom12(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 12 to 13")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate12To13AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 13); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom13(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 13 to 14")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate13To14AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 14); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// sqliteMigrateFrom14 is a no-op; the corresponding Postgres migration adds
|
||||
// idx_message_attachment_expires, which SQLite already has from the initial schema.
|
||||
func sqliteMigrateFrom14(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 14 to 15")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 15); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,50 +9,11 @@ import (
|
||||
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
"github.com/stretchr/testify/require"
|
||||
dbtest "heckel.io/ntfy/v2/db/test"
|
||||
"heckel.io/ntfy/v2/message"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
func TestSqliteStore_Migration_From0(t *testing.T) {
|
||||
filename := newSqliteTestStoreFile(t)
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Create "version 0" schema
|
||||
_, err = db.Exec(`
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id VARCHAR(20) PRIMARY KEY,
|
||||
time INT NOT NULL,
|
||||
topic VARCHAR(64) NOT NULL,
|
||||
message VARCHAR(1024) NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||
COMMIT;
|
||||
`)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Insert a bunch of messages
|
||||
for i := 0; i < 10; i++ {
|
||||
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message) VALUES (?, ?, ?, ?)`,
|
||||
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i))
|
||||
require.Nil(t, err)
|
||||
}
|
||||
require.Nil(t, db.Close())
|
||||
|
||||
// Create store to trigger migration
|
||||
s := newSqliteTestStoreFromFile(t, filename, "")
|
||||
checkSqliteSchemaVersion(t, filename)
|
||||
|
||||
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 10, len(messages))
|
||||
require.Equal(t, "some message 5", messages[5].Message)
|
||||
require.Equal(t, "", messages[5].Title)
|
||||
require.Nil(t, messages[5].Tags)
|
||||
require.Equal(t, 0, messages[5].Priority)
|
||||
}
|
||||
|
||||
func TestSqliteStore_Migration_From1(t *testing.T) {
|
||||
filename := newSqliteTestStoreFile(t)
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
@@ -90,6 +51,19 @@ func TestSqliteStore_Migration_From1(t *testing.T) {
|
||||
s := newSqliteTestStoreFromFile(t, filename, "")
|
||||
checkSqliteSchemaVersion(t, filename)
|
||||
|
||||
// The migrated database must be structurally identical to a freshly created one
|
||||
freshFile := newSqliteTestStoreFile(t)
|
||||
fresh, err := message.NewSQLiteStore(freshFile, "", time.Hour, 0, 0, false)
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() { fresh.Close() })
|
||||
freshDB, err := sql.Open("sqlite3", freshFile)
|
||||
require.Nil(t, err)
|
||||
defer freshDB.Close()
|
||||
migratedDB, err := sql.Open("sqlite3", filename)
|
||||
require.Nil(t, err)
|
||||
defer migratedDB.Close()
|
||||
require.Equal(t, dbtest.SQLiteSchema(t, freshDB), dbtest.SQLiteSchema(t, migratedDB))
|
||||
|
||||
// Add delayed message
|
||||
delayedMessage := model.NewDefaultMessage("mytopic", "some delayed message")
|
||||
delayedMessage.Time = time.Now().Add(time.Minute).Unix()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package message_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
@@ -556,6 +557,43 @@ func TestStore_MarkPublished(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestStore_MessagesDue_ClaimExactlyOnce(t *testing.T) {
|
||||
// Postgres-only: exercises "FOR UPDATE SKIP LOCKED" claiming so that concurrent delayed
|
||||
// senders running on different cluster nodes never pick up (and deliver) the same due
|
||||
// message twice. With the non-claiming implementation, every concurrent caller sees every
|
||||
// due row, so this test fails; with claiming, each row is returned to exactly one caller.
|
||||
s := newTestPostgresStore(t) // skips if NTFY_TEST_DATABASE_URL is unset
|
||||
const n = 40
|
||||
for i := 0; i < n; i++ {
|
||||
m := model.NewDefaultMessage("mytopic", fmt.Sprintf("scheduled %d", i))
|
||||
m.Time = time.Now().Add(time.Hour).Unix() // future -> stored as published=FALSE
|
||||
require.Nil(t, s.AddMessage(m))
|
||||
// Move the time into the past so the message is due now (but still unpublished)
|
||||
require.Nil(t, s.UpdateMessageTime(m.ID, time.Now().Add(-time.Minute).Unix()))
|
||||
}
|
||||
var mu sync.Mutex
|
||||
seen := make(map[string]int)
|
||||
var wg sync.WaitGroup
|
||||
for c := 0; c < 6; c++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
due, err := s.MessagesDue()
|
||||
require.Nil(t, err)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, m := range due {
|
||||
seen[m.ID]++
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
require.Len(t, seen, n) // every due message was claimed
|
||||
for id, count := range seen {
|
||||
require.Equalf(t, 1, count, "message %s was claimed %d times, want exactly 1", id, count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_ExpireMessages(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, s *message.Cache) {
|
||||
// Add messages to two topics
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Package metrics defines the Prometheus metrics exposed by the ntfy server, and registers them
|
||||
// with the default Prometheus registry on import. It is decoupled from the ntfy server, so that
|
||||
// call sites can update metrics without depending on the server package.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
// Collectors for all metrics exposed by the server.
|
||||
//
|
||||
// These are never nil, so that call sites can update them unconditionally. If metrics are
|
||||
// disabled, the server never mounts the /metrics handler, and the values are simply never read.
|
||||
var (
|
||||
MessagesPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_messages_published_success",
|
||||
})
|
||||
MessagesPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_messages_published_failure",
|
||||
})
|
||||
MessagesCached = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_messages_cached_total",
|
||||
})
|
||||
MessagePublishDurationMillis = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_message_publish_duration_ms",
|
||||
})
|
||||
FirebasePublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_firebase_published_success",
|
||||
})
|
||||
FirebasePublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_firebase_published_failure",
|
||||
})
|
||||
EmailsPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_sent_success",
|
||||
})
|
||||
EmailsPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_sent_failure",
|
||||
})
|
||||
EmailsReceivedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_received_success",
|
||||
})
|
||||
EmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_received_failure",
|
||||
})
|
||||
CallsMadeSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_calls_made_success",
|
||||
})
|
||||
CallsMadeFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_calls_made_failure",
|
||||
})
|
||||
UnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_unifiedpush_published_success",
|
||||
})
|
||||
MatrixPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_matrix_published_success",
|
||||
})
|
||||
MatrixPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_matrix_published_failure",
|
||||
})
|
||||
AttachmentsTotalSize = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_attachments_total_size",
|
||||
})
|
||||
Visitors = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_visitors_total",
|
||||
})
|
||||
Users = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_users_total",
|
||||
})
|
||||
Subscribers = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_subscribers_total",
|
||||
})
|
||||
Topics = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_topics_total",
|
||||
})
|
||||
HTTPRequests = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "ntfy_http_requests_total",
|
||||
}, []string{"http_code", "ntfy_code", "http_method"})
|
||||
ClusterPeers = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_cluster_peers",
|
||||
})
|
||||
ClusterMessagesForwarded = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_cluster_messages_forwarded_total",
|
||||
})
|
||||
ClusterSendErrors = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_cluster_send_errors_total",
|
||||
})
|
||||
ClusterQueueDropped = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_cluster_queue_dropped_total",
|
||||
})
|
||||
ClusterBatchesSent = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_cluster_batches_sent_total",
|
||||
})
|
||||
ClusterMessagesWasted = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_cluster_messages_wasted_total",
|
||||
})
|
||||
ClusterRouteSkipped = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_cluster_route_skipped_total",
|
||||
})
|
||||
ClusterStatePushes = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_cluster_state_pushes_total",
|
||||
})
|
||||
ClusterLeader = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_cluster_leader",
|
||||
})
|
||||
)
|
||||
|
||||
// init registers all collectors with the default Prometheus registry. Registration is
|
||||
// unconditional: the collectors are only ever exposed if the server mounts the /metrics handler,
|
||||
// so there is nothing to be gained by tying registration to the config.
|
||||
func init() {
|
||||
prometheus.MustRegister(
|
||||
MessagesPublishedSuccess,
|
||||
MessagesPublishedFailure,
|
||||
MessagesCached,
|
||||
MessagePublishDurationMillis,
|
||||
FirebasePublishedSuccess,
|
||||
FirebasePublishedFailure,
|
||||
EmailsPublishedSuccess,
|
||||
EmailsPublishedFailure,
|
||||
EmailsReceivedSuccess,
|
||||
EmailsReceivedFailure,
|
||||
CallsMadeSuccess,
|
||||
CallsMadeFailure,
|
||||
UnifiedPushPublishedSuccess,
|
||||
MatrixPublishedSuccess,
|
||||
MatrixPublishedFailure,
|
||||
AttachmentsTotalSize,
|
||||
Visitors,
|
||||
Users,
|
||||
Subscribers,
|
||||
Topics,
|
||||
HTTPRequests,
|
||||
ClusterPeers,
|
||||
ClusterMessagesForwarded,
|
||||
ClusterSendErrors,
|
||||
ClusterQueueDropped,
|
||||
ClusterBatchesSent,
|
||||
ClusterMessagesWasted,
|
||||
ClusterRouteSkipped,
|
||||
ClusterStatePushes,
|
||||
ClusterLeader,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// expectedMetricNames is the exact set of metrics the server exposes. These names are a public
|
||||
// contract: renaming or dropping one silently breaks existing dashboards and alerts.
|
||||
var expectedMetricNames = []string{
|
||||
"ntfy_attachments_total_size",
|
||||
"ntfy_calls_made_failure",
|
||||
"ntfy_calls_made_success",
|
||||
"ntfy_cluster_batches_sent_total",
|
||||
"ntfy_cluster_leader",
|
||||
"ntfy_cluster_messages_forwarded_total",
|
||||
"ntfy_cluster_messages_wasted_total",
|
||||
"ntfy_cluster_peers",
|
||||
"ntfy_cluster_queue_dropped_total",
|
||||
"ntfy_cluster_route_skipped_total",
|
||||
"ntfy_cluster_send_errors_total",
|
||||
"ntfy_cluster_state_pushes_total",
|
||||
"ntfy_emails_received_failure",
|
||||
"ntfy_emails_received_success",
|
||||
"ntfy_emails_sent_failure",
|
||||
"ntfy_emails_sent_success",
|
||||
"ntfy_firebase_published_failure",
|
||||
"ntfy_firebase_published_success",
|
||||
"ntfy_http_requests_total",
|
||||
"ntfy_matrix_published_failure",
|
||||
"ntfy_matrix_published_success",
|
||||
"ntfy_message_publish_duration_ms",
|
||||
"ntfy_messages_cached_total",
|
||||
"ntfy_messages_published_failure",
|
||||
"ntfy_messages_published_success",
|
||||
"ntfy_subscribers_total",
|
||||
"ntfy_topics_total",
|
||||
"ntfy_unifiedpush_published_success",
|
||||
"ntfy_users_total",
|
||||
"ntfy_visitors_total",
|
||||
}
|
||||
|
||||
func TestRegisteredMetricNames(t *testing.T) {
|
||||
HTTPRequests.WithLabelValues("200", "20000", "GET").Inc()
|
||||
families, err := prometheus.DefaultGatherer.Gather()
|
||||
require.Nil(t, err)
|
||||
names := make([]string, 0)
|
||||
for _, family := range families {
|
||||
if strings.HasPrefix(family.GetName(), "ntfy_") {
|
||||
names = append(names, family.GetName())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
require.Equal(t, expectedMetricNames, names)
|
||||
}
|
||||
|
||||
func TestCollectors_NeverNil(t *testing.T) {
|
||||
// Call sites update metrics unconditionally, even when metrics are disabled, so no collector
|
||||
// may ever be nil
|
||||
MessagesPublishedSuccess.Inc()
|
||||
MessagesCached.Set(1)
|
||||
HTTPRequests.WithLabelValues("200", "20000", "PUT").Inc()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/ban"
|
||||
)
|
||||
|
||||
// TestServer_BanFeed_WritesOffenderToFile is the end-to-end wiring test: a rejected request flows
|
||||
// through s.handle -> the error responder -> s.ban.Record, and once the offender's prefix
|
||||
// breaches, its ban line lands in the ban file (flushed on Close).
|
||||
func TestServer_BanFeed_WritesOffenderToFile(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
conf := newTestConfig(t, "")
|
||||
conf.BanFile = banFile
|
||||
conf.BanWindow = time.Minute
|
||||
conf.BanThreshold = 1 // capacity 1: the 2nd rejection breaches
|
||||
conf.BanWeights = ban.Weights{"*": 1} // any 4xx counts one strike
|
||||
s := newTestServer(t, conf)
|
||||
require.NotNil(t, s.ban)
|
||||
|
||||
// A delayed message with caching disabled is a deterministic 400 (errHTTPBadRequestDelayNoCache).
|
||||
// request() sends from RemoteAddr 9.9.9.9.
|
||||
reject := map[string]string{"Cache": "no", "In": "30 min"}
|
||||
for i := 0; i < 3; i++ {
|
||||
response := request(t, s, "PUT", "/mytopic", "", reject)
|
||||
require.Equal(t, 400, response.Code)
|
||||
}
|
||||
|
||||
// Writes are async; Close flushes the buffer. The offender's prefix must be in the feed exactly
|
||||
// once (throttled to one line per window).
|
||||
s.ban.Close()
|
||||
data, err := os.ReadFile(banFile)
|
||||
require.NoError(t, err)
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
require.Len(t, lines, 1)
|
||||
require.Contains(t, lines[0], " 9.9.9.9 9.9.9.9/32 400 ") // <ip> <prefix> <http-code> <ntfy-code>
|
||||
}
|
||||
|
||||
// TestServer_BanFeed_DisabledByDefault verifies the feature is off with no ban file: s.ban is
|
||||
// nil and the error path skips it (guarded), so a rejected request must not panic.
|
||||
func TestServer_BanFeed_DisabledByDefault(t *testing.T) {
|
||||
conf := newTestConfig(t, "") // no BanFile
|
||||
s := newTestServer(t, conf)
|
||||
require.Nil(t, s.ban)
|
||||
|
||||
reject := map[string]string{"Cache": "no", "In": "30 min"}
|
||||
response := request(t, s, "PUT", "/mytopic", "", reject) // guarded callsite, no Record
|
||||
require.Equal(t, 400, response.Code)
|
||||
}
|
||||
+47
-107
@@ -7,11 +7,12 @@ import (
|
||||
"io/fs"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/cluster"
|
||||
|
||||
"heckel.io/ntfy/v2/ban"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
)
|
||||
|
||||
@@ -46,15 +47,21 @@ const (
|
||||
|
||||
// Defines default abuse ban-feed settings (see BanFile, BanWindow, BanThreshold, BanWeights)
|
||||
const (
|
||||
DefaultBanWindow = time.Minute
|
||||
DefaultBanThreshold = 100 // Weighted strikes per BanWindow before a visitor is banned
|
||||
DefaultBanWindow = 10 * time.Minute
|
||||
DefaultBanThreshold = 100 // Weighted strikes per BanWindow before a prefix is banned
|
||||
)
|
||||
|
||||
// DefaultBanWeights defines the per-code strike weights used when the ban-feed is enabled but no
|
||||
// explicit ban-weights are configured. Format is "KEY:WEIGHT" (see ParseBanWeights). The auth-failure
|
||||
// flood is weighted heavily so it bans fast, the legit quota 429s are exempt (weight 0), and every
|
||||
// other rejection costs one strike via the "*" fallback.
|
||||
var DefaultBanWeights = []string{"42909:10", "42908:0", "42903:0", "42905:0", "42910:0", "*:1"}
|
||||
// DefaultBanWeights is the ban-feed's default per-code strike weights: the auth-failure flood bans fast,
|
||||
// and everything else defaults to weight 1 (no "*" rule needed; see BanWeights.WeightFor).
|
||||
var DefaultBanWeights = []string{
|
||||
banWeight(errHTTPTooManyRequestsLimitAuthFailure, 10), // brute-force auth flood -> ban fast
|
||||
}
|
||||
|
||||
// banWeight formats a "CODE:WEIGHT" ban-feed default from an ntfy error, so the codes stay in sync with
|
||||
// the errHTTP definitions instead of being duplicated as string literals.
|
||||
func banWeight(err *errHTTP, weight int) string {
|
||||
return fmt.Sprintf("%d:%d", err.Code, weight)
|
||||
}
|
||||
|
||||
// Defines all global and per-visitor limits
|
||||
// - message size limit: the max number of bytes for a message
|
||||
@@ -114,8 +121,13 @@ type Config struct {
|
||||
ListenUnixMode fs.FileMode
|
||||
KeyFile string
|
||||
CertFile string
|
||||
DatabaseURL string // PostgreSQL connection string (e.g. "postgres://user:pass@host:5432/ntfy")
|
||||
DatabaseReplicaURLs []string // PostgreSQL read replica connection strings
|
||||
DatabaseURL string // PostgreSQL connection string (e.g. "postgres://user:pass@host:5432/ntfy")
|
||||
DatabaseReplicaURLs []string // PostgreSQL read replica connection strings
|
||||
ClusterNodeID string // Stable per-node identifier used to skip a node's own fan-out; required in cluster mode
|
||||
ClusterListen string // ip:port the dedicated cluster fan-out listener binds to (private network, e.g. "10.0.0.5:2587")
|
||||
ClusterAdvertiseURL string // Base URL peers use to reach this node's fan-out listener (defaults to "http://<cluster-listen>")
|
||||
ClusterSecret string `hash:"-"` // Shared secret authenticating node-to-node fan-out requests
|
||||
ClusterBatchLinger time.Duration // How long fan-out messages wait to form a batch per peer; 0 sends immediately
|
||||
FirebaseKeyFile string
|
||||
CacheFile string
|
||||
CacheDuration time.Duration
|
||||
@@ -125,9 +137,9 @@ type Config struct {
|
||||
AuthFile string
|
||||
AuthStartupQueries string
|
||||
AuthDefault user.Permission
|
||||
AuthUsers []*user.User
|
||||
AuthUsers []*user.User `hash:"-"`
|
||||
AuthAccess map[string][]*user.Grant
|
||||
AuthTokens map[string][]*user.Token
|
||||
AuthTokens map[string][]*user.Token `hash:"-"`
|
||||
AuthBcryptCost int
|
||||
AuthStatsQueueWriterInterval time.Duration
|
||||
AuthAccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only)
|
||||
@@ -148,23 +160,22 @@ type Config struct {
|
||||
FirebasePollInterval time.Duration
|
||||
FirebaseQuotaExceededPenaltyDuration time.Duration
|
||||
UpstreamBaseURL string
|
||||
UpstreamAccessToken string
|
||||
UpstreamAccessToken string `hash:"-"`
|
||||
SMTPSenderAddr string
|
||||
SMTPSenderUser string
|
||||
SMTPSenderPass string
|
||||
SMTPSenderPass string `hash:"-"`
|
||||
SMTPSenderFrom string
|
||||
SMTPSenderVerify bool
|
||||
SMTPServerListen string
|
||||
SMTPServerDomain string
|
||||
SMTPServerAddrPrefix string
|
||||
TwilioAccount string
|
||||
TwilioAuthToken string
|
||||
TwilioAuthToken string `hash:"-"`
|
||||
TwilioPhoneNumber string
|
||||
TwilioCallsBaseURL string
|
||||
TwilioVerifyBaseURL string
|
||||
TwilioVerifyService string
|
||||
TwilioCallFormat *template.Template
|
||||
MetricsEnable bool
|
||||
MetricsListenHTTP string
|
||||
ProfileListenHTTP string
|
||||
MessageDelayMin time.Duration
|
||||
@@ -194,8 +205,8 @@ type Config struct {
|
||||
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
|
||||
StripeSecretKey string `hash:"-"`
|
||||
StripeWebhookKey string `hash:"-"`
|
||||
StripePriceCacheDuration time.Duration
|
||||
BillingContact string
|
||||
EnableSignup bool // Enable creation of accounts via API and UI
|
||||
@@ -204,20 +215,20 @@ 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
|
||||
WebPushPrivateKey string
|
||||
WebPushPrivateKey string `hash:"-"`
|
||||
WebPushPublicKey string
|
||||
WebPushFile string
|
||||
WebPushEmailAddress string
|
||||
WebPushStartupQueries string
|
||||
WebPushExpiryDuration time.Duration
|
||||
WebPushExpiryWarningDuration time.Duration
|
||||
BanFile string // Abuse ban-feed: file that fail2ban tails; empty string disables the feature
|
||||
BanWindow time.Duration // Abuse ban-feed: rolling window over which weighted strikes are counted
|
||||
BanThreshold int // Abuse ban-feed: weighted strikes per window before a visitor is banned
|
||||
BanWeights map[string]int // Abuse ban-feed: normalized code matcher -> strike weight (see ParseBanWeights, weightFor)
|
||||
BuildVersion string // Injected by App
|
||||
BuildDate string // Injected by App
|
||||
BuildCommit string // Injected by App
|
||||
BanFile string // Abuse ban-feed: file that fail2ban tails; empty string disables the feature
|
||||
BanWindow time.Duration // Abuse ban-feed: rolling window over which weighted strikes are counted
|
||||
BanThreshold int // Abuse ban-feed: weighted strikes per window before a prefix is banned
|
||||
BanWeights ban.Weights // Abuse ban-feed: code matcher -> strike weight (see ban.ParseWeights, ban.Weights.WeightFor)
|
||||
BuildVersion string // Injected by App
|
||||
BuildDate string // Injected by App
|
||||
BuildCommit string // Injected by App
|
||||
}
|
||||
|
||||
// NewConfig instantiates a default new server config
|
||||
@@ -232,6 +243,11 @@ func NewConfig() *Config {
|
||||
KeyFile: "",
|
||||
CertFile: "",
|
||||
DatabaseURL: "",
|
||||
ClusterNodeID: "",
|
||||
ClusterListen: "",
|
||||
ClusterAdvertiseURL: "",
|
||||
ClusterSecret: "",
|
||||
ClusterBatchLinger: cluster.DefaultBatchLinger,
|
||||
FirebaseKeyFile: "",
|
||||
CacheFile: "",
|
||||
CacheDuration: DefaultCacheDuration,
|
||||
@@ -338,6 +354,10 @@ func (c *Config) Hash() string {
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := v.Field(i)
|
||||
fieldName := t.Field(i).Name
|
||||
// Secrets must not feed the hash
|
||||
if t.Field(i).Tag.Get("hash") == "-" {
|
||||
continue
|
||||
}
|
||||
// Try to marshal the field and skip if it fails (e.g. *template.Template, netip.Prefix)
|
||||
if b, err := json.Marshal(field.Interface()); err == nil {
|
||||
result += fmt.Sprintf("%s:%s|", fieldName, string(b))
|
||||
@@ -345,83 +365,3 @@ func (c *Config) Hash() string {
|
||||
}
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(result)))
|
||||
}
|
||||
|
||||
// ParseBanWeights turns a list like ["42909:10","403:2","42908:0","*:1"] into a map of normalized
|
||||
// matcher key -> strike weight for the abuse ban-feed's single weighted bucket. A key may be an exact
|
||||
// ntfy code ("42909"), a prefix family ("429*"), or "*". A bare 3-digit HTTP status ("403") is a
|
||||
// shorthand normalized to its family ("403*"), so operators can weight a whole status at once. Weights
|
||||
// must be integers >= 0; a weight of 0 is valid and means the code is exempt (never contributes to a
|
||||
// ban), which lets a "*" catch-all coexist with carved-out legit-quota codes. A malformed entry is
|
||||
// rejected so misconfiguration surfaces at startup rather than silently disabling bans.
|
||||
func ParseBanWeights(entries []string) (map[string]int, error) {
|
||||
out := make(map[string]int, len(entries))
|
||||
for _, entry := range entries {
|
||||
key, weightStr, ok := strings.Cut(entry, ":")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid ban-weight %q, want KEY:WEIGHT", entry)
|
||||
}
|
||||
weight, err := strconv.Atoi(strings.TrimSpace(weightStr))
|
||||
if err != nil || weight < 0 {
|
||||
return nil, fmt.Errorf("invalid ban-weight value in %q, want a non-negative integer", entry)
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if !validBanWeightKey(key) {
|
||||
return nil, fmt.Errorf("invalid ban-weight key in %q, want %q, an ntfy code, an HTTP status, or a PREFIX*", entry, "*")
|
||||
}
|
||||
// A bare 3-digit HTTP status is shorthand for the whole family (e.g. "403" -> "403*").
|
||||
if len(key) == 3 && isAllDigits(key) {
|
||||
key += "*"
|
||||
}
|
||||
out[key] = weight
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// validBanWeightKey reports whether key is a legal ban-weight matcher: "*", an all-digits code,
|
||||
// or an all-digits prefix followed by "*".
|
||||
func validBanWeightKey(key string) bool {
|
||||
if key == "*" {
|
||||
return true
|
||||
}
|
||||
digits := strings.TrimSuffix(key, "*")
|
||||
return digits != "" && isAllDigits(digits)
|
||||
}
|
||||
|
||||
func isAllDigits(s string) bool {
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return s != ""
|
||||
}
|
||||
|
||||
// weightFor returns the strike weight for a rejection's 5-digit ntfy code using the ban-weights
|
||||
// matcher, longest-match-wins. An exact key ("42909") matches with length len(code); a family key
|
||||
// ("429*") matches a code with that prefix, with length equal to the prefix; "*" matches everything
|
||||
// with length 0. The weight of the longest match is returned, or 0 if no key matches (which the
|
||||
// caller treats as "no strike").
|
||||
func (c *Config) weightFor(ntfyCode int) int {
|
||||
code := strconv.Itoa(ntfyCode)
|
||||
weight, bestLen, matched := 0, -1, false
|
||||
for key, w := range c.BanWeights {
|
||||
matchLen := -1
|
||||
switch {
|
||||
case key == "*":
|
||||
matchLen = 0
|
||||
case strings.HasSuffix(key, "*"):
|
||||
if prefix := strings.TrimSuffix(key, "*"); strings.HasPrefix(code, prefix) {
|
||||
matchLen = len(prefix)
|
||||
}
|
||||
case key == code:
|
||||
matchLen = len(code)
|
||||
}
|
||||
if matchLen > bestLen {
|
||||
weight, bestLen, matched = w, matchLen, true
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return 0
|
||||
}
|
||||
return weight
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package server_test
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -11,3 +12,25 @@ func TestConfig_New(t *testing.T) {
|
||||
assert.Equal(t, ":80", c.ListenHTTP)
|
||||
assert.Equal(t, server.DefaultKeepaliveInterval, c.KeepaliveInterval)
|
||||
}
|
||||
|
||||
func TestConfig_HashExcludesSecrets(t *testing.T) {
|
||||
// The config hash is served to browsers (ConfigHash, for webapp change detection), so
|
||||
// secret material must not feed it: a weak secret would otherwise be offline-brute-forceable
|
||||
// against a publicly visible hash.
|
||||
conf1 := server.NewConfig()
|
||||
conf2 := server.NewConfig()
|
||||
conf2.StripeSecretKey = "sk_live_topsecret"
|
||||
conf2.StripeWebhookKey = "whsec_topsecret"
|
||||
conf2.TwilioAuthToken = "twilio-auth-token"
|
||||
conf2.UpstreamAccessToken = "tk_upstream"
|
||||
conf2.WebPushPrivateKey = "web-push-private-key"
|
||||
conf2.SMTPSenderPass = "hunter2"
|
||||
conf2.ClusterSecret = "cluster-secret"
|
||||
conf2.AuthUsers = []*user.User{{Name: "phil", Hash: "$2a$10$somebcrypthash"}}
|
||||
conf2.AuthTokens = map[string][]*user.Token{"phil": {{Value: "tk_secrettoken"}}}
|
||||
assert.Equal(t, conf1.Hash(), conf2.Hash())
|
||||
// Non-secret fields must still change the hash
|
||||
conf3 := server.NewConfig()
|
||||
conf3.BaseURL = "https://ntfy.example.com"
|
||||
assert.NotEqual(t, conf1.Hash(), conf3.Hash())
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ var (
|
||||
errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
||||
errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
||||
errHTTPBadRequestResetLinkInvalid = &errHTTP{40054, http.StatusBadRequest, "invalid request: password reset link invalid or expired", "", nil}
|
||||
errHTTPBadRequestTemplateTooLarge = &errHTTP{40056, http.StatusBadRequest, "invalid request: template too large", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
|
||||
+15
-16
@@ -16,22 +16,21 @@ import (
|
||||
|
||||
// Log tags
|
||||
const (
|
||||
tagStartup = "startup"
|
||||
tagHTTP = "http"
|
||||
tagPublish = "publish"
|
||||
tagSubscribe = "subscribe"
|
||||
tagFirebase = "firebase"
|
||||
tagSMTP = "smtp" // Receive email
|
||||
tagEmail = "email" // Send email
|
||||
tagTwilio = "twilio"
|
||||
tagMessageCache = "message_cache"
|
||||
tagStripe = "stripe"
|
||||
tagAccount = "account"
|
||||
tagManager = "manager"
|
||||
tagResetter = "resetter"
|
||||
tagWebsocket = "websocket"
|
||||
tagMatrix = "matrix"
|
||||
tagWebPush = "webpush"
|
||||
tagStartup = "startup"
|
||||
tagHTTP = "http"
|
||||
tagPublish = "publish"
|
||||
tagSubscribe = "subscribe"
|
||||
tagFirebase = "firebase"
|
||||
tagSMTP = "smtp" // Receive email
|
||||
tagEmail = "email" // Send email
|
||||
tagTwilio = "twilio"
|
||||
tagStripe = "stripe"
|
||||
tagAccount = "account"
|
||||
tagManager = "manager"
|
||||
tagResetter = "resetter"
|
||||
tagWebsocket = "websocket"
|
||||
tagMatrix = "matrix"
|
||||
tagWebPush = "webpush"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
+234
-88
@@ -31,13 +31,17 @@ import (
|
||||
"golang.org/x/sync/errgroup"
|
||||
"heckel.io/ntfy/v2/action"
|
||||
"heckel.io/ntfy/v2/attachment"
|
||||
"heckel.io/ntfy/v2/ban"
|
||||
"heckel.io/ntfy/v2/cluster"
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/mail"
|
||||
"heckel.io/ntfy/v2/message"
|
||||
"heckel.io/ntfy/v2/metrics"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/payments"
|
||||
"heckel.io/ntfy/v2/twilio"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"heckel.io/ntfy/v2/webpush"
|
||||
@@ -57,8 +61,9 @@ type Server struct {
|
||||
mailer mail.Sender
|
||||
topics map[string]*topic
|
||||
visitors map[string]*visitor // ip:<ip> or user:<user>
|
||||
ban *ban.Service // Abuse ban-feed; nil when the feature is disabled (no ban file)
|
||||
firebaseClient *firebaseClient
|
||||
twilio *twilioClient
|
||||
twilio *twilio.Client
|
||||
messages int64 // Total number of messages (persisted if messageCache enabled)
|
||||
messagesHistory []int64 // Last n values of the messages counter, used to determine rate
|
||||
userManager *user.Manager // Might be nil!
|
||||
@@ -68,6 +73,8 @@ type Server struct {
|
||||
stripe stripeAPI // Stripe API, can be replaced with a mock
|
||||
priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!)
|
||||
metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set
|
||||
cluster cluster.Cluster // Fans messages out to peer cluster nodes (nop when not clustered)
|
||||
httpClusterServer *http.Server // Dedicated private listener for node-to-node fan-out (cluster-listen)
|
||||
closeChan chan bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
@@ -110,6 +117,7 @@ var (
|
||||
apiUsersPath = "/v1/users"
|
||||
apiUsersAccessPath = "/v1/users/access"
|
||||
apiAccountPath = "/v1/account"
|
||||
apiAccountLoginPath = "/v1/account/login"
|
||||
apiAccountTokenPath = "/v1/account/token"
|
||||
apiAccountPasswordPath = "/v1/account/password"
|
||||
apiAccountSettingsPath = "/v1/account/settings"
|
||||
@@ -145,12 +153,6 @@ var (
|
||||
//go:embed docs
|
||||
docsStaticFs embed.FS
|
||||
docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
|
||||
|
||||
//go:embed templates
|
||||
templatesFs embed.FS // Contains template config files (e.g. grafana.yml, github.yml, ...)
|
||||
templatesDir = "templates"
|
||||
|
||||
templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -164,9 +166,6 @@ const (
|
||||
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
|
||||
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
|
||||
messagesHistoryMax = 10 // Number of message count values to keep in memory
|
||||
templateMaxExecutionTime = 100 * time.Millisecond // Maximum time a template can take to execute, used to prevent DoS attacks
|
||||
templateMaxOutputBytes = 1024 * 1024 // Maximum number of bytes a template can output, used to prevent DoS attacks
|
||||
templateFileExtension = ".yml" // Template files must end with this extension
|
||||
)
|
||||
|
||||
// WebSocket constants
|
||||
@@ -247,6 +246,16 @@ func New(conf *Config) (*Server, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
twilioClient := twilio.NewClient(&twilio.Config{
|
||||
Account: conf.TwilioAccount,
|
||||
AuthToken: conf.TwilioAuthToken,
|
||||
PhoneNumber: conf.TwilioPhoneNumber,
|
||||
CallsBaseURL: conf.TwilioCallsBaseURL,
|
||||
VerifyBaseURL: conf.TwilioVerifyBaseURL,
|
||||
VerifyService: conf.TwilioVerifyService,
|
||||
CallFormat: conf.TwilioCallFormat,
|
||||
BuildVersion: conf.BuildVersion,
|
||||
})
|
||||
var userManager *user.Manager
|
||||
if conf.AuthFile != "" || pool != nil {
|
||||
authConfig := &user.Config{
|
||||
@@ -286,6 +295,17 @@ func New(conf *Config) (*Server, error) {
|
||||
}
|
||||
firebaseClient = newFirebaseClient(sender, auther)
|
||||
}
|
||||
var banner *ban.Service
|
||||
if conf.BanFile != "" {
|
||||
banner = ban.NewService(&ban.Config{
|
||||
File: conf.BanFile,
|
||||
Window: conf.BanWindow,
|
||||
Threshold: conf.BanThreshold,
|
||||
Weights: conf.BanWeights,
|
||||
PrefixBitsIPv4: conf.VisitorPrefixBitsIPv4,
|
||||
PrefixBitsIPv6: conf.VisitorPrefixBitsIPv6,
|
||||
})
|
||||
}
|
||||
s := &Server{
|
||||
config: conf,
|
||||
db: pool,
|
||||
@@ -293,8 +313,9 @@ func New(conf *Config) (*Server, error) {
|
||||
webPush: wp,
|
||||
attachment: attachmentStore,
|
||||
firebaseClient: firebaseClient,
|
||||
twilio: newTwilioClient(conf, userManager),
|
||||
twilio: twilioClient,
|
||||
mailer: sender,
|
||||
ban: banner,
|
||||
topics: topics,
|
||||
userManager: userManager,
|
||||
messages: messages,
|
||||
@@ -303,9 +324,93 @@ func New(conf *Config) (*Server, error) {
|
||||
stripe: stripe,
|
||||
}
|
||||
s.priceCache = util.NewLookupCache(s.fetchStripePrices, conf.StripePriceCacheDuration)
|
||||
// Cross-node cluster; delivery of peer messages to local subscribers is injected
|
||||
// as a callback, so the cluster package never depends on the server. Peers talk to each
|
||||
// other only via the dedicated cluster listener, never via the public listeners.
|
||||
advertiseURL := conf.ClusterAdvertiseURL
|
||||
if advertiseURL == "" && conf.ClusterListen != "" {
|
||||
advertiseURL = "http://" + conf.ClusterListen
|
||||
}
|
||||
s.cluster, err = cluster.New(&cluster.Config{
|
||||
Enabled: conf.ClusterListen != "", // Setting cluster-listen implicitly enables clustering
|
||||
NodeID: cluster.NodeID(conf.ClusterNodeID),
|
||||
AdvertiseURL: advertiseURL,
|
||||
Secret: conf.ClusterSecret,
|
||||
BatchLinger: conf.ClusterBatchLinger,
|
||||
MaxMessageBytes: int64(conf.MessageSizeLimit)*4 + 1024, // Envelope overhead over the raw message
|
||||
}, pool, s.deliverFromBus, s.liveTopics)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The cluster routes messages by subscription knowledge: peers learn this node's live topics
|
||||
// via periodic state pushes (liveTopics) and immediate announcements on a topic's first
|
||||
// subscriber (the hook below; also set in topicsFromIDs for topics created later)
|
||||
for _, t := range s.topics {
|
||||
t.onFirstSubscriber = s.topicAnnouncer(t.ID)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// clusterHandler returns the handler served on the dedicated cluster listener (cluster-listen).
|
||||
// It serves the internal peer API (owned and routed by the cluster itself, including auth) plus
|
||||
// a health endpoint; the public listeners never expose these paths, so internal traffic cannot
|
||||
// be reached from the outside even before any firewalling.
|
||||
func (s *Server) clusterHandler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(apiHealthPath, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if s.cluster.Healthy() {
|
||||
io.WriteString(w, `{"healthy":true}`+"\n")
|
||||
} else {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
io.WriteString(w, `{"healthy":false}`+"\n")
|
||||
}
|
||||
})
|
||||
mux.Handle("/", s.cluster)
|
||||
return mux
|
||||
}
|
||||
|
||||
// liveTopics returns the topics that currently have at least one subscriber, computed fresh on
|
||||
// every call. Deliberately NOT the whole topics map: it also holds subscriber-less topics
|
||||
// rebuilt from the message cache, which would gut the routing filter's selectivity.
|
||||
func (s *Server) liveTopics() []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
topics := make([]string, 0, len(s.topics))
|
||||
for _, t := range s.topics {
|
||||
if subscribers, _ := t.Stats(); subscribers > 0 {
|
||||
topics = append(topics, t.ID)
|
||||
}
|
||||
}
|
||||
return topics
|
||||
}
|
||||
|
||||
// topicAnnouncer returns the first-subscriber hook for a topic: it tells peer nodes right away
|
||||
// that this node now wants messages for it (see Cluster.BroadcastState).
|
||||
func (s *Server) topicAnnouncer(id string) func() {
|
||||
return func() {
|
||||
s.cluster.BroadcastState(&cluster.State{AddedTopics: []string{id}})
|
||||
}
|
||||
}
|
||||
|
||||
// deliverFromBus delivers a message received from a peer node (via the cluster) to this
|
||||
// node's local subscribers. It is the receive-side counterpart to Cluster.ForwardMessage: local
|
||||
// delivery and all global side effects (Firebase, email, web push, upstream) already ran on the
|
||||
// origin node, so this only publishes to the local topic, and never re-relays.
|
||||
func (s *Server) deliverFromBus(m *model.Message) {
|
||||
s.mu.RLock()
|
||||
t, ok := s.topics[m.Topic]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
metrics.ClusterMessagesWasted.Inc() // Relayed here needlessly: this node had no use for the message
|
||||
return
|
||||
}
|
||||
v := s.visitor(m.Sender, nil)
|
||||
if err := t.Publish(v, m); err != nil {
|
||||
logvm(v, m).Err(err).Warn("Cluster: unable to deliver fan-out message to local subscribers")
|
||||
}
|
||||
}
|
||||
|
||||
func createMessageCache(conf *Config, pool *db.DB) (*message.Cache, error) {
|
||||
if conf.CacheDuration == 0 {
|
||||
return message.NewNopStore()
|
||||
@@ -348,6 +453,9 @@ func (s *Server) Run() error {
|
||||
if s.config.ProfileListenHTTP != "" {
|
||||
listenStr += fmt.Sprintf(" %s[http/profile]", s.config.ProfileListenHTTP)
|
||||
}
|
||||
if s.config.ClusterListen != "" {
|
||||
listenStr += fmt.Sprintf(" %s[http/cluster]", s.config.ClusterListen)
|
||||
}
|
||||
log.Tag(tagStartup).Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.BuildVersion, log.CurrentLevel().String())
|
||||
if log.IsFile() {
|
||||
fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.BuildVersion)
|
||||
@@ -395,15 +503,19 @@ func (s *Server) Run() error {
|
||||
}()
|
||||
}
|
||||
if s.config.MetricsListenHTTP != "" {
|
||||
initMetrics()
|
||||
s.httpMetricsServer = &http.Server{Addr: s.config.MetricsListenHTTP, Handler: promhttp.Handler()}
|
||||
go func() {
|
||||
errChan <- s.httpMetricsServer.ListenAndServe()
|
||||
}()
|
||||
} else if s.config.EnableMetrics {
|
||||
initMetrics()
|
||||
s.metricsHandler = promhttp.Handler()
|
||||
}
|
||||
if s.config.ClusterListen != "" {
|
||||
s.httpClusterServer = &http.Server{Addr: s.config.ClusterListen, Handler: s.clusterHandler()}
|
||||
go func() {
|
||||
errChan <- s.httpClusterServer.ListenAndServe()
|
||||
}()
|
||||
}
|
||||
if s.config.ProfileListenHTTP != "" {
|
||||
profileMux := http.NewServeMux()
|
||||
profileMux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||
@@ -449,7 +561,16 @@ func (s *Server) Stop() {
|
||||
if s.attachment != nil {
|
||||
s.attachment.Close()
|
||||
}
|
||||
if s.httpClusterServer != nil {
|
||||
s.httpClusterServer.Close()
|
||||
}
|
||||
if s.cluster != nil {
|
||||
s.cluster.Close()
|
||||
}
|
||||
s.closeDatabases()
|
||||
if s.ban != nil {
|
||||
s.ban.Close()
|
||||
}
|
||||
if s.closeChan != nil {
|
||||
close(s.closeChan)
|
||||
}
|
||||
@@ -489,9 +610,7 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleError(w, r, v, err)
|
||||
return
|
||||
}
|
||||
if metricHTTPRequests != nil {
|
||||
metricHTTPRequests.WithLabelValues("200", "20000", r.Method).Inc()
|
||||
}
|
||||
metrics.HTTPRequests.WithLabelValues("200", "20000", r.Method).Inc()
|
||||
}).
|
||||
Debug("HTTP request finished")
|
||||
}
|
||||
@@ -501,9 +620,7 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
|
||||
if !ok {
|
||||
httpErr = errHTTPInternalError
|
||||
}
|
||||
if metricHTTPRequests != nil {
|
||||
metricHTTPRequests.WithLabelValues(fmt.Sprintf("%d", httpErr.HTTPCode), fmt.Sprintf("%d", httpErr.Code), r.Method).Inc()
|
||||
}
|
||||
metrics.HTTPRequests.WithLabelValues(strconv.Itoa(httpErr.HTTPCode), strconv.Itoa(httpErr.Code), r.Method).Inc()
|
||||
isRateLimiting := util.Contains(rateLimitingErrorCodes, httpErr.HTTPCode)
|
||||
isNormalError := strings.Contains(err.Error(), "i/o timeout") || util.Contains(normalErrorCodes, httpErr.HTTPCode)
|
||||
ev := logvr(v, r).Err(err)
|
||||
@@ -538,13 +655,9 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
|
||||
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
|
||||
w.WriteHeader(httpErr.HTTPCode)
|
||||
io.WriteString(w, httpErr.JSON()+"\n")
|
||||
if s.config.BanFile != "" {
|
||||
// Abuse ban-feed: record against the OFFENDING request's IP, not v.ip. An account-keyed
|
||||
// (tier'd) visitor is one object shared across all its source IPs, so v.ip is stale. The IP was
|
||||
// already extracted in maybeAuthenticate and stashed in the request context (contextVisitorIP),
|
||||
// so reuse it here rather than re-parsing headers.
|
||||
if s.ban != nil {
|
||||
if ip, err := fromContext[netip.Addr](r, contextVisitorIP); err == nil {
|
||||
v.recordStatus(ip, httpErr.HTTPCode, httpErr.Code)
|
||||
s.ban.Record(ip, httpErr.HTTPCode, httpErr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -584,6 +697,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.ensureUser(s.withAccountSync(s.handleAccountDelete))(w, r, v)
|
||||
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordPath {
|
||||
return s.ensureUser(s.handleAccountPasswordChange)(w, r, v)
|
||||
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountLoginPath {
|
||||
return s.ensureUser(s.withAccountSync(s.handleAccountLogin))(w, r, v)
|
||||
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountTokenPath {
|
||||
return s.ensureUser(s.withAccountSync(s.handleAccountTokenCreate))(w, r, v)
|
||||
} else if r.Method == http.MethodPatch && r.URL.Path == apiAccountTokenPath {
|
||||
@@ -704,10 +819,13 @@ func (s *Server) handleTopicAuth(w http.ResponseWriter, _ *http.Request, _ *visi
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
response := &apiHealthResponse{
|
||||
Healthy: true,
|
||||
// Unhealthy = the registry heartbeat went stale and peers stopped forwarding to this
|
||||
// node; 503 lets status-code LB checks pull it (checkers must fail open, see cluster.Cluster)
|
||||
healthy := s.cluster.Healthy()
|
||||
if !healthy {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}
|
||||
return s.writeJSON(w, response)
|
||||
return s.writeJSON(w, &apiHealthResponse{Healthy: healthy})
|
||||
}
|
||||
|
||||
// handleMetrics returns Prometheus metrics. This endpoint is only called if enable-metrics is set,
|
||||
@@ -807,6 +925,49 @@ func (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error {
|
||||
return writeMatrixDiscoveryResponse(w)
|
||||
}
|
||||
|
||||
// dispatch delivers m to local subscribers, forwards it to peer cluster nodes, and fires the
|
||||
// requested side-effect targets. It is the single choke point through which every published
|
||||
// message must pass; t may be nil when the topic has no local subscribers (delayed sender).
|
||||
//
|
||||
// The side-effect targets (Firebase, email, calls, upstream, web push) are global, not per-node:
|
||||
// they fire only here on the origin node, and never again when a peer node receives the message
|
||||
// via the cluster (see deliverFromBus).
|
||||
func (s *Server) dispatch(v *visitor, t *topic, m *model.Message, opts dispatchOpts) error {
|
||||
// Deliver to local subscribers
|
||||
if t != nil {
|
||||
if opts.async {
|
||||
go func() {
|
||||
if err := t.Publish(v, m); err != nil {
|
||||
logvm(v, m).Err(err).Warn("Unable to publish message")
|
||||
}
|
||||
}()
|
||||
} else if err := t.Publish(v, m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Forward to peer cluster nodes, whose subscribers do not show up in this node's topics map
|
||||
if err := s.cluster.ForwardMessage(m); err != nil {
|
||||
logvm(v, m).Err(err).Warn("Cluster: unable to forward message to peer nodes")
|
||||
}
|
||||
// Fire the requested side-effect targets
|
||||
if s.firebaseClient != nil && opts.firebase {
|
||||
go s.sendToFirebase(v, m)
|
||||
}
|
||||
if s.mailer != nil && opts.email != "" {
|
||||
go s.sendEmail(v, m, opts.email)
|
||||
}
|
||||
if s.config.TwilioAccount != "" && opts.call != "" {
|
||||
go s.callPhone(v, m, opts.call)
|
||||
}
|
||||
if s.config.UpstreamBaseURL != "" && opts.upstream {
|
||||
go s.forwardPollRequest(v, m)
|
||||
}
|
||||
if s.config.WebPushPublicKey != "" && opts.webPush {
|
||||
go s.publishToWebPushEndpoints(v, m)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Message, error) {
|
||||
start := time.Now()
|
||||
t, err := fromContext[*topic](r, contextTopic)
|
||||
@@ -846,7 +1007,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
|
||||
}
|
||||
if call != "" {
|
||||
var httpErr *errHTTP
|
||||
call, httpErr = s.twilio.convertPhoneNumber(v.User(), call)
|
||||
call, httpErr = s.convertPhoneNumber(v.User(), call)
|
||||
if httpErr != nil {
|
||||
return nil, httpErr.With(t)
|
||||
} else if !vrate.CallAllowed() {
|
||||
@@ -885,24 +1046,16 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
|
||||
ev.Debug("Received message")
|
||||
}
|
||||
if !delayed {
|
||||
if err := t.Publish(v, m); err != nil {
|
||||
err := s.dispatch(v, t, m, dispatchOpts{
|
||||
firebase: firebase,
|
||||
email: email,
|
||||
call: call,
|
||||
upstream: !unifiedpush, // UP messages are not sent to upstream
|
||||
webPush: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.firebaseClient != nil && firebase {
|
||||
go s.sendToFirebase(v, m)
|
||||
}
|
||||
if s.mailer != nil && email != "" {
|
||||
go s.sendEmail(v, m, email)
|
||||
}
|
||||
if s.config.TwilioAccount != "" && call != "" {
|
||||
go s.twilio.callPhone(v, r, m, call)
|
||||
}
|
||||
if s.config.UpstreamBaseURL != "" && !unifiedpush { // UP messages are not sent to upstream
|
||||
go s.forwardPollRequest(v, m)
|
||||
}
|
||||
if s.config.WebPushPublicKey != "" {
|
||||
go s.publishToWebPushEndpoints(v, m)
|
||||
}
|
||||
} else {
|
||||
logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later")
|
||||
}
|
||||
@@ -931,27 +1084,27 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
|
||||
s.messages++
|
||||
s.mu.Unlock()
|
||||
if unifiedpush {
|
||||
minc(metricUnifiedPushPublishedSuccess)
|
||||
metrics.UnifiedPushPublishedSuccess.Inc()
|
||||
}
|
||||
mset(metricMessagePublishDurationMillis, time.Since(start).Milliseconds())
|
||||
metrics.MessagePublishDurationMillis.Set(float64(time.Since(start).Milliseconds()))
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
m, err := s.handlePublishInternal(r, v)
|
||||
if err != nil {
|
||||
minc(metricMessagesPublishedFailure)
|
||||
metrics.MessagesPublishedFailure.Inc()
|
||||
return err
|
||||
}
|
||||
minc(metricMessagesPublishedSuccess)
|
||||
metrics.MessagesPublishedSuccess.Inc()
|
||||
return s.writeJSON(w, m.ForJSON())
|
||||
}
|
||||
|
||||
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
_, err := s.handlePublishInternal(r, v)
|
||||
if err != nil {
|
||||
minc(metricMessagesPublishedFailure)
|
||||
minc(metricMatrixPublishedFailure)
|
||||
metrics.MessagesPublishedFailure.Inc()
|
||||
metrics.MatrixPublishedFailure.Inc()
|
||||
if e, ok := err.(*errHTTP); ok && e.HTTPCode == errHTTPInsufficientStorageUnifiedPush.HTTPCode {
|
||||
topic, err := fromContext[*topic](r, contextTopic)
|
||||
if err != nil {
|
||||
@@ -967,8 +1120,8 @@ func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *
|
||||
}
|
||||
return err
|
||||
}
|
||||
minc(metricMessagesPublishedSuccess)
|
||||
minc(metricMatrixPublishedSuccess)
|
||||
metrics.MessagesPublishedSuccess.Inc()
|
||||
metrics.MatrixPublishedSuccess.Inc()
|
||||
return writeMatrixSuccess(w)
|
||||
}
|
||||
|
||||
@@ -1001,18 +1154,10 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *
|
||||
m.Sender = v.IP()
|
||||
m.User = v.MaybeUserID()
|
||||
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
|
||||
// Publish to subscribers
|
||||
if err := t.Publish(v, m); err != nil {
|
||||
// Publish to subscribers, peer nodes, Firebase (for Android clients), and web push endpoints
|
||||
if err := s.dispatch(v, t, m, dispatchOpts{firebase: true, webPush: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
// Send to Firebase for Android clients
|
||||
if s.firebaseClient != nil {
|
||||
go s.sendToFirebase(v, m)
|
||||
}
|
||||
// Send to web push endpoints
|
||||
if s.config.WebPushPublicKey != "" {
|
||||
go s.publishToWebPushEndpoints(v, m)
|
||||
}
|
||||
if event == model.MessageDeleteEvent {
|
||||
// Delete any existing scheduled message with the same sequence ID
|
||||
deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, sequenceID)
|
||||
@@ -1040,7 +1185,7 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *
|
||||
func (s *Server) sendToFirebase(v *visitor, m *model.Message) {
|
||||
logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase")
|
||||
if err := s.firebaseClient.Send(v, m); err != nil {
|
||||
minc(metricFirebasePublishedFailure)
|
||||
metrics.FirebasePublishedFailure.Inc()
|
||||
if errors.Is(err, errFirebaseTemporarilyBanned) {
|
||||
logvm(v, m).Tag(tagFirebase).Err(err).Debug("Unable to publish to Firebase: %v", err.Error())
|
||||
} else {
|
||||
@@ -1048,17 +1193,17 @@ func (s *Server) sendToFirebase(v *visitor, m *model.Message) {
|
||||
}
|
||||
return
|
||||
}
|
||||
minc(metricFirebasePublishedSuccess)
|
||||
metrics.FirebasePublishedSuccess.Inc()
|
||||
}
|
||||
|
||||
func (s *Server) sendEmail(v *visitor, m *model.Message, email string) {
|
||||
logvm(v, m).Tag(tagEmail).Field("email", email).Info("Sending email to %s", email)
|
||||
if err := s.mailer.SendNotification(email, m, v.ip.String()); err != nil {
|
||||
logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error())
|
||||
minc(metricEmailsPublishedFailure)
|
||||
metrics.EmailsPublishedFailure.Inc()
|
||||
return
|
||||
}
|
||||
minc(metricEmailsPublishedSuccess)
|
||||
metrics.EmailsPublishedSuccess.Inc()
|
||||
}
|
||||
|
||||
func (s *Server) forwardPollRequest(v *visitor, m *model.Message) {
|
||||
@@ -1254,7 +1399,7 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *model.Message
|
||||
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
|
||||
} else if template.Enabled() {
|
||||
return s.handleBodyAsTemplatedTextMessage(m, template, body, priorityStr) // Case 5
|
||||
return s.handleBodyAsTemplatedTextMessage(r.Context(), m, template, body, priorityStr) // Case 5
|
||||
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
|
||||
return s.handleBodyAsTextMessage(m, body) // Case 6
|
||||
}
|
||||
@@ -1806,7 +1951,9 @@ func (s *Server) topicsFromIDs(v *visitor, ids ...string) ([]*topic, error) {
|
||||
if v != nil && !v.TopicCreationAllowed() {
|
||||
return nil, errHTTPTooManyRequestsLimitTopicCreation
|
||||
}
|
||||
s.topics[id] = newTopic(id)
|
||||
t := newTopic(id)
|
||||
t.onFirstSubscriber = s.topicAnnouncer(id)
|
||||
s.topics[id] = t
|
||||
}
|
||||
topics = append(topics, s.topics[id])
|
||||
}
|
||||
@@ -1893,7 +2040,8 @@ func (s *Server) resetStats() {
|
||||
for _, v := range s.visitors {
|
||||
v.ResetStats()
|
||||
}
|
||||
if s.userManager != nil {
|
||||
// The user database is shared; only the cluster leader resets it (always true single-node)
|
||||
if s.userManager != nil && s.cluster.IsLeader() {
|
||||
if err := s.userManager.ResetStats(); err != nil {
|
||||
log.Tag(tagResetter).Warn("Failed to write to database: %s", err.Error())
|
||||
}
|
||||
@@ -1908,7 +2056,11 @@ func (s *Server) runFirebaseKeepaliver() {
|
||||
for {
|
||||
select {
|
||||
case <-time.After(s.config.FirebaseKeepaliveInterval):
|
||||
s.sendToFirebase(v, model.NewKeepaliveMessage(firebaseControlTopic))
|
||||
// Leader only: every FCM keepalive wakes all subscribed phones, so a cluster must
|
||||
// send it exactly once, not once per node (checked per tick to survive failover)
|
||||
if s.cluster.IsLeader() {
|
||||
s.sendToFirebase(v, model.NewKeepaliveMessage(firebaseControlTopic))
|
||||
}
|
||||
/*
|
||||
FIXME: Disable iOS polling entirely for now due to thundering herd problem (see #677)
|
||||
To solve this, we'd have to shard the iOS poll topics to spread out the polling evenly.
|
||||
@@ -1961,24 +2113,18 @@ func (s *Server) sendDelayedMessages() error {
|
||||
func (s *Server) sendDelayedMessage(v *visitor, m *model.Message) error {
|
||||
logvm(v, m).Debug("Sending delayed message")
|
||||
s.mu.RLock()
|
||||
t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published
|
||||
t := s.topics[m.Topic] // May be nil if there are no local subscribers; dispatch handles that
|
||||
s.mu.RUnlock()
|
||||
if ok {
|
||||
go func() {
|
||||
// We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler
|
||||
if err := t.Publish(v, m); err != nil {
|
||||
logvm(v, m).Err(err).Warn("Unable to publish message")
|
||||
}
|
||||
}()
|
||||
}
|
||||
if s.firebaseClient != nil { // Firebase subscribers may not show up in topics map
|
||||
go s.sendToFirebase(v, m)
|
||||
}
|
||||
if s.config.UpstreamBaseURL != "" {
|
||||
go s.forwardPollRequest(v, m)
|
||||
}
|
||||
if s.config.WebPushPublicKey != "" {
|
||||
go s.publishToWebPushEndpoints(v, m)
|
||||
// We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler.
|
||||
// Firebase subscribers may not show up in the topics map, so side effects fire regardless.
|
||||
err := s.dispatch(v, t, m, dispatchOpts{
|
||||
firebase: true,
|
||||
upstream: true,
|
||||
webPush: true,
|
||||
async: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.messageCache.MarkPublished(m); err != nil {
|
||||
return err
|
||||
|
||||
+31
-2
@@ -61,6 +61,34 @@
|
||||
#
|
||||
# database-url: <connection-string>
|
||||
|
||||
# If "cluster-listen" is set, clustering is implicitly enabled: this node registers itself in
|
||||
# the PostgreSQL node registry and fans published messages out to the other cluster nodes over
|
||||
# HTTP, so subscribers connected to any node receive messages published to any other node.
|
||||
# Requires "database-url" and "cluster-secret".
|
||||
#
|
||||
# - cluster-listen is the ip:port of the dedicated fan-out listener that peer nodes talk to.
|
||||
# Bind it to a private network interface (e.g. "10.0.0.5:2587"); the public listeners never
|
||||
# serve the fan-out endpoint.
|
||||
# - cluster-node-id is a stable per-node identifier (e.g. the hostname); required.
|
||||
# - cluster-advertise-url is the base URL peer nodes use to reach this node's fan-out listener;
|
||||
# it defaults to "http://<cluster-listen>". It must be set explicitly if cluster-listen binds
|
||||
# a wildcard address (e.g. ":2587").
|
||||
# - cluster-secret authenticates node-to-node fan-out requests; it must be identical on all
|
||||
# nodes.
|
||||
# - cluster-batch-linger is how long fan-out messages may wait to form a batch per peer node,
|
||||
# trading up to that much cross-node delivery latency for a bounded request rate between
|
||||
# nodes. Set to 0 to send each message immediately.
|
||||
#
|
||||
# SECURITY: The fan-out endpoint injects messages into arbitrary topics. The shared secret
|
||||
# protects it, and it is only served on the dedicated cluster listener -- but you should still
|
||||
# make sure that listener is reachable only from the private network (firewall/VPC rules).
|
||||
#
|
||||
# cluster-listen: <ip:port>
|
||||
# cluster-node-id: <hostname>
|
||||
# cluster-advertise-url: "http://<cluster-listen>"
|
||||
# cluster-secret: <secret>
|
||||
# cluster-batch-linger: 500ms
|
||||
|
||||
# If "cache-file" is set, messages are cached in a local SQLite database instead of only in-memory.
|
||||
# This allows for service restarts without losing messages in support of the since= parameter.
|
||||
# Not required if "database-url" is set (messages are stored in PostgreSQL instead).
|
||||
@@ -456,8 +484,9 @@
|
||||
# doing, and/or secure access to the endpoint in your reverse proxy.
|
||||
#
|
||||
# - enable-metrics enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket)
|
||||
# - metrics-listen-http exposes the metrics endpoint via a dedicated [IP]:port. If set, this option implicitly
|
||||
# enables metrics as well, e.g. "10.0.1.1:9090" or ":9090"
|
||||
# - metrics-listen-http moves the metrics endpoint to a dedicated [IP]:port, e.g. "10.0.1.1:9090" or ":9090".
|
||||
# It implicitly enables metrics. If set, the metrics are served only on that dedicated port, and the default
|
||||
# ntfy server does not serve /metrics, even if enable-metrics is also set.
|
||||
#
|
||||
# enable-metrics: false
|
||||
# metrics-listen-http:
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/twilio"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
@@ -268,6 +269,24 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// handleAccountLogin authenticates a username-or-email + password (via the ensureUser wrapper's
|
||||
// Basic Auth), mints a session token, and returns it together with the canonical username. Unlike
|
||||
// the token endpoint (which exists to mint arbitrary API tokens), this endpoint's job is to log a
|
||||
// user in, so it also reports who they are (the identifier they typed may be a primary email).
|
||||
func (s *Server) handleAccountLogin(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
u := v.User()
|
||||
logvr(v, r).Tag(tagAccount).Info("Logging in user %s", u.Name)
|
||||
token, err := s.userManager.CreateToken(u.ID, "", time.Now().Add(tokenExpiryDuration), v.IP(), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response := &apiAccountLoginResponse{
|
||||
Token: token.Value,
|
||||
Username: u.Name,
|
||||
}
|
||||
return s.writeJSON(w, response)
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!
|
||||
if err != nil {
|
||||
@@ -613,7 +632,7 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
// Actually add the unverified number, and send verification
|
||||
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Sending phone number verification")
|
||||
if err := s.twilio.verifyPhoneNumber(v, r, req.Number, req.Channel); err != nil {
|
||||
if err := s.twilio.Verify(req.Number, req.Channel); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
@@ -628,7 +647,10 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ
|
||||
if !phoneNumberRegex.MatchString(req.Number) {
|
||||
return errHTTPBadRequestPhoneNumberInvalid
|
||||
}
|
||||
if err := s.twilio.verifyPhoneNumberCheck(v, r, req.Number, req.Code); err != nil {
|
||||
if err := s.twilio.CheckVerify(req.Number, req.Code); err != nil {
|
||||
if errors.Is(err, twilio.ErrVerificationExpired) {
|
||||
return errHTTPGonePhoneVerificationExpired
|
||||
}
|
||||
return err
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Adding phone number as verified")
|
||||
@@ -963,7 +985,8 @@ func (s *Server) publishSyncEventForUser(v *visitor, u *user.User) error {
|
||||
return err
|
||||
}
|
||||
m := model.NewDefaultMessage(syncTopic.ID, string(messageBytes))
|
||||
if err := syncTopic.Publish(v, m); err != nil {
|
||||
// Dispatch so the sync event also reaches the user's devices connected to peer cluster nodes
|
||||
if err := s.dispatch(v, syncTopic, m, dispatchOpts{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -224,6 +224,22 @@ func canLogin(t *testing.T, s *Server, username, password string) bool {
|
||||
return rr.Code == 200
|
||||
}
|
||||
|
||||
func TestAccount_LoginByPrimaryEmail(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
verifyEmailFor(t, s, mailer, auth, "ben@example.com")
|
||||
|
||||
// Basic Auth works with either the username or the verified primary email
|
||||
require.True(t, canLogin(t, s, "ben", "ben"))
|
||||
require.True(t, canLogin(t, s, "ben@example.com", "ben"))
|
||||
|
||||
// ...but not with the wrong password or an unknown email
|
||||
require.False(t, canLogin(t, s, "ben@example.com", "wrong"))
|
||||
require.False(t, canLogin(t, s, "nobody@example.com", "ben"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_ByUsername(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
|
||||
@@ -55,6 +55,58 @@ func TestAccount_Signup_Success(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Login_Success(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
s := newTestServer(t, conf)
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser, false))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddEmail(u.ID, "phil@example.com"))
|
||||
require.Nil(t, s.userManager.SetPrimaryEmail(u.ID, "phil@example.com"))
|
||||
|
||||
// Login by username returns a token and the canonical username
|
||||
rr := request(t, s, "POST", "/v1/account/login", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
resp, _ := util.UnmarshalJSON[apiAccountLoginResponse](io.NopCloser(rr.Body))
|
||||
require.True(t, strings.HasPrefix(resp.Token, "tk_"))
|
||||
require.Equal(t, "phil", resp.Username)
|
||||
|
||||
// The returned token actually authenticates
|
||||
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||
"Authorization": util.BearerAuth(resp.Token),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Login by primary email returns the canonical username, not the email that was typed
|
||||
rr = request(t, s, "POST", "/v1/account/login", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil@example.com", "mypass"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
resp, _ = util.UnmarshalJSON[apiAccountLoginResponse](io.NopCloser(rr.Body))
|
||||
require.True(t, strings.HasPrefix(resp.Token, "tk_"))
|
||||
require.Equal(t, "phil", resp.Username)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Login_InvalidCredentials(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
s := newTestServer(t, conf)
|
||||
defer s.closeDatabases()
|
||||
require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser, false))
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account/login", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "wrongpass"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Signup_UserExists(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/cluster"
|
||||
dbtest "heckel.io/ntfy/v2/db/test"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
)
|
||||
|
||||
// fakeCluster records relayed messages and topic announcements so tests can assert that every
|
||||
// publish path passes through the cluster exactly once, and that subscription hooks fire.
|
||||
type fakeCluster struct {
|
||||
mu sync.Mutex
|
||||
messages []*model.Message
|
||||
announced []string
|
||||
notLeader bool
|
||||
notHealthy bool
|
||||
}
|
||||
|
||||
func (b *fakeCluster) ForwardMessage(m *model.Message) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.messages = append(b.messages, m)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *fakeCluster) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {}
|
||||
|
||||
func (b *fakeCluster) BroadcastState(state *cluster.State) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.announced = append(b.announced, state.AddedTopics...)
|
||||
}
|
||||
|
||||
func (b *fakeCluster) Healthy() bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return !b.notHealthy
|
||||
}
|
||||
|
||||
func (b *fakeCluster) setHealthy(healthy bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.notHealthy = !healthy
|
||||
}
|
||||
|
||||
func (b *fakeCluster) IsLeader() bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return !b.notLeader
|
||||
}
|
||||
|
||||
func (b *fakeCluster) setLeader(leader bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.notLeader = !leader
|
||||
}
|
||||
|
||||
func (b *fakeCluster) Close() error { return nil }
|
||||
|
||||
func (b *fakeCluster) Messages() []*model.Message {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return append([]*model.Message{}, b.messages...)
|
||||
}
|
||||
|
||||
func (b *fakeCluster) Announced() []string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return append([]string{}, b.announced...)
|
||||
}
|
||||
|
||||
func TestServer_Cluster_PublishForwardsOnce(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t, ""))
|
||||
b := &fakeCluster{}
|
||||
s.cluster = b
|
||||
response := request(t, s, "PUT", "/mytopic", "hi there", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
messages := b.Messages()
|
||||
require.Len(t, messages, 1)
|
||||
require.Equal(t, "mytopic", messages[0].Topic)
|
||||
require.Equal(t, "hi there", messages[0].Message)
|
||||
}
|
||||
|
||||
func TestServer_Cluster_SyncEventForwards(t *testing.T) {
|
||||
// Account sync events are delivered via the user's st_... sync topic; without relaying
|
||||
// them, cross-device account sync silently breaks when a user's devices land on different
|
||||
// cluster nodes.
|
||||
s := newTestServer(t, newTestConfig(t, ""))
|
||||
b := &fakeCluster{}
|
||||
s.cluster = b
|
||||
u := &user.User{ID: "u_abc", Name: "phil", SyncTopic: "st_1234"}
|
||||
v := s.visitor(netip.MustParseAddr("1.2.3.4"), nil)
|
||||
require.Nil(t, s.publishSyncEventForUser(v, u))
|
||||
messages := b.Messages()
|
||||
require.Len(t, messages, 1)
|
||||
require.Equal(t, "st_1234", messages[0].Topic)
|
||||
}
|
||||
|
||||
func TestServer_Cluster_DeliverNotOnPublicHandler(t *testing.T) {
|
||||
// The fan-out endpoint lives only on the dedicated cluster listener; the public handler must
|
||||
// not serve it, even with cluster mode on and a valid secret.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
conf := newTestConfig(t, schemaDSN)
|
||||
conf.ClusterNodeID = "node-a"
|
||||
conf.ClusterListen = "127.0.0.1:1" // Enables clustering; not bound since Run() is not called
|
||||
conf.ClusterSecret = "s3cret"
|
||||
conf.ClusterAdvertiseURL = "http://127.0.0.1:1"
|
||||
s := newTestServer(t, conf)
|
||||
topics, err := s.topicsFromIDs(nil, "mytopic")
|
||||
require.Nil(t, err)
|
||||
var mu sync.Mutex
|
||||
var received []*model.Message
|
||||
topics[0].Subscribe(func(_ *visitor, m *model.Message) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
received = append(received, m)
|
||||
return nil
|
||||
}, "", func() {})
|
||||
// A valid fan-out request against the PUBLIC handler must not deliver
|
||||
response := request(t, s, "POST", "/v1/internal/message",
|
||||
`{"message":{"id":"x1","time":1,"event":"message","topic":"mytopic","message":"sneaky"}}`,
|
||||
map[string]string{"X-Cluster-Secret": "s3cret", "X-Cluster-Origin": "node-b"})
|
||||
require.Equal(t, 404, response.Code)
|
||||
time.Sleep(250 * time.Millisecond) // Delivery is async; give a wrong implementation time to fail
|
||||
mu.Lock()
|
||||
require.Empty(t, received)
|
||||
mu.Unlock()
|
||||
// The same request against the cluster listener handler DOES deliver
|
||||
rr := httptest.NewRecorder()
|
||||
req, err := http.NewRequest("POST", "/v1/internal/message",
|
||||
strings.NewReader(`{"message":{"id":"x2","time":1,"event":"message","topic":"mytopic","message":"legit"}}`))
|
||||
require.Nil(t, err)
|
||||
req.Header.Set("X-Cluster-Secret", "s3cret")
|
||||
req.Header.Set("X-Cluster-Origin", "node-b")
|
||||
s.clusterHandler().ServeHTTP(rr, req)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(received) == 1
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_Cluster_EndToEnd(t *testing.T) {
|
||||
// Two full servers sharing one Postgres schema: a message published to node A over HTTP must
|
||||
// reach a subscriber connected to node B, via the node registry and the fan-out endpoint.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
// Node B: create the listener first so its advertise URL is known before the server exists
|
||||
listenerB, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.Nil(t, err)
|
||||
confB := newTestConfig(t, schemaDSN)
|
||||
confB.ClusterNodeID = "node-b"
|
||||
confB.ClusterListen = listenerB.Addr().String() // Enables clustering; the test serves it below
|
||||
confB.ClusterSecret = "s3cret"
|
||||
confB.ClusterAdvertiseURL = "http://" + listenerB.Addr().String()
|
||||
sB := newTestServer(t, confB)
|
||||
srvB := &http.Server{Handler: sB.clusterHandler()}
|
||||
go srvB.Serve(listenerB)
|
||||
defer srvB.Close()
|
||||
// Node A: publish-only in this test, so its advertise URL is never called
|
||||
confA := newTestConfig(t, schemaDSN)
|
||||
confA.ClusterNodeID = "node-a"
|
||||
confA.ClusterListen = "127.0.0.1:1" // Enables clustering; not bound since Run() is not called
|
||||
confA.ClusterSecret = "s3cret"
|
||||
confA.ClusterAdvertiseURL = "http://127.0.0.1:1"
|
||||
sA := newTestServer(t, confA)
|
||||
// Subscribe on node B
|
||||
topics, err := sB.topicsFromIDs(nil, "mytopic")
|
||||
require.Nil(t, err)
|
||||
var mu sync.Mutex
|
||||
var received []*model.Message
|
||||
topics[0].Subscribe(func(_ *visitor, m *model.Message) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
received = append(received, m)
|
||||
return nil
|
||||
}, "", func() {})
|
||||
// Publish on node A
|
||||
response := request(t, sA, "PUT", "/mytopic", "hello cluster", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(received) == 1
|
||||
})
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.Equal(t, "hello cluster", received[0].Message)
|
||||
}
|
||||
|
||||
func TestServer_Cluster_DeliverFromBus(t *testing.T) {
|
||||
// deliverFromBus is the receive side of the broadcaster: a message that originated on a peer
|
||||
// node must reach this node's local subscribers, but must NOT be re-broadcast (loop) nor
|
||||
// re-trigger origin-only side effects.
|
||||
s := newTestServer(t, newTestConfig(t, ""))
|
||||
b := &fakeCluster{}
|
||||
s.cluster = b
|
||||
topics, err := s.topicsFromIDs(nil, "mytopic")
|
||||
require.Nil(t, err)
|
||||
var mu sync.Mutex
|
||||
var received []*model.Message
|
||||
topics[0].Subscribe(func(_ *visitor, m *model.Message) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
received = append(received, m)
|
||||
return nil
|
||||
}, "", func() {})
|
||||
m := model.NewDefaultMessage("mytopic", "from peer")
|
||||
m.Sender = netip.MustParseAddr("5.6.7.8")
|
||||
s.deliverFromBus(m)
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(received) == 1
|
||||
})
|
||||
require.Empty(t, b.Messages()) // Peer messages are never re-relayed
|
||||
}
|
||||
|
||||
func TestServer_Cluster_FirstSubscriberAnnounces(t *testing.T) {
|
||||
// A topic gaining its FIRST subscriber is announced to peers exactly once, so publishers on
|
||||
// other nodes stop skipping this node for it without waiting for the next state push.
|
||||
s := newTestServer(t, newTestConfig(t, ""))
|
||||
b := &fakeCluster{}
|
||||
s.cluster = b
|
||||
topics, err := s.topicsFromIDs(nil, "mytopic")
|
||||
require.Nil(t, err)
|
||||
subscriber := func(_ *visitor, _ *model.Message) error { return nil }
|
||||
topics[0].Subscribe(subscriber, "", func() {})
|
||||
waitFor(t, func() bool {
|
||||
return len(b.Announced()) == 1 && b.Announced()[0] == "mytopic"
|
||||
})
|
||||
// A second subscriber does not re-announce
|
||||
topics[0].Subscribe(subscriber, "", func() {})
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
require.Len(t, b.Announced(), 1)
|
||||
}
|
||||
|
||||
func TestServer_Cluster_ManagerPrunesOnlyOnLeader(t *testing.T) {
|
||||
c := newTestConfig(t, "")
|
||||
s := newTestServer(t, c)
|
||||
cl := &fakeCluster{notLeader: true}
|
||||
s.cluster = cl
|
||||
|
||||
// Publish and expire a message
|
||||
rr := request(t, s, "POST", "/mytopic", "hi", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
m := toMessage(t, rr.Body.String())
|
||||
require.Nil(t, s.messageCache.ExpireMessages("mytopic"))
|
||||
|
||||
// A non-leader node leaves shared-database pruning to the leader
|
||||
s.execManager()
|
||||
_, err := s.messageCache.Message(m.ID)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Once this node is the leader, the same run prunes
|
||||
cl.setLeader(true)
|
||||
s.execManager()
|
||||
_, err = s.messageCache.Message(m.ID)
|
||||
require.Equal(t, model.ErrMessageNotFound, err)
|
||||
}
|
||||
|
||||
func TestServer_Cluster_StatsResetOnlyOnLeader(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t, "")
|
||||
s := newTestServer(t, c)
|
||||
cl := &fakeCluster{notLeader: true}
|
||||
s.cluster = cl
|
||||
|
||||
// An anonymous visitor with an in-memory message count
|
||||
v := newVisitor(c, s.messageCache, s.userManager, netip.MustParseAddr("1.2.3.4"), nil)
|
||||
require.True(t, v.MessageAllowed())
|
||||
s.mu.Lock()
|
||||
s.visitors["ip:1.2.3.4"] = v
|
||||
s.mu.Unlock()
|
||||
require.Equal(t, int64(1), v.Stats().Messages)
|
||||
|
||||
// A user with persisted stats in the (shared) user database
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil1234", user.RoleUser, false))
|
||||
authDB, err := sql.Open("sqlite3", c.AuthFile)
|
||||
require.Nil(t, err)
|
||||
defer authDB.Close()
|
||||
_, err = authDB.Exec(`UPDATE user SET stats_messages = 5 WHERE user = 'phil'`)
|
||||
require.Nil(t, err)
|
||||
|
||||
// A non-leader node resets its own in-memory visitor stats, but leaves the user database
|
||||
// to the leader
|
||||
s.resetStats()
|
||||
require.Equal(t, int64(0), v.Stats().Messages)
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(5), u.Stats.Messages)
|
||||
|
||||
// The leader resets the user database too
|
||||
cl.setLeader(true)
|
||||
s.resetStats()
|
||||
u, err = s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(0), u.Stats.Messages)
|
||||
}
|
||||
|
||||
func TestServer_Cluster_FirebaseKeepaliverOnlyOnLeader(t *testing.T) {
|
||||
// Every FCM keepalive wakes all subscribed phones, so only the leader may send them;
|
||||
// N nodes sending N keepalives would multiply the battery cost for every user
|
||||
c := newTestConfig(t, "")
|
||||
c.FirebaseKeepaliveInterval = 20 * time.Millisecond
|
||||
s := newTestServer(t, c)
|
||||
sender := newTestFirebaseSender(100)
|
||||
s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})
|
||||
cl := &fakeCluster{notLeader: true}
|
||||
s.cluster = cl
|
||||
s.closeChan = make(chan bool) // Closed by Stop() in the test cleanup
|
||||
go s.runFirebaseKeepaliver()
|
||||
|
||||
// A non-leader node stays silent
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
require.Empty(t, sender.Messages())
|
||||
|
||||
// The leader sends keepalives
|
||||
cl.setLeader(true)
|
||||
waitFor(t, func() bool { return len(sender.Messages()) > 0 })
|
||||
}
|
||||
|
||||
func TestServer_Cluster_HealthReflectsCluster(t *testing.T) {
|
||||
// A node whose registry heartbeat went stale no longer receives forwarded messages, so
|
||||
// health checks must pull it from rotation (the fail-open policy lives in the checker)
|
||||
s := newTestServer(t, newTestConfig(t, ""))
|
||||
cl := &fakeCluster{}
|
||||
s.cluster = cl
|
||||
rr := request(t, s, "GET", "/v1/health", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Contains(t, rr.Body.String(), `"healthy":true`)
|
||||
cl.setHealthy(false)
|
||||
rr = request(t, s, "GET", "/v1/health", "", nil)
|
||||
require.Equal(t, 503, rr.Code)
|
||||
require.Contains(t, rr.Body.String(), `"healthy":false`)
|
||||
// The cluster listener's health endpoint reflects the same state
|
||||
rr2 := httptest.NewRecorder()
|
||||
req, err := http.NewRequest("GET", "/v1/health", nil)
|
||||
require.Nil(t, err)
|
||||
s.clusterHandler().ServeHTTP(rr2, req)
|
||||
require.Equal(t, 503, rr2.Code)
|
||||
}
|
||||
+16
-11
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/metrics"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
@@ -9,12 +10,16 @@ func (s *Server) execManager() {
|
||||
// WARNING: Make sure to only selectively lock with the mutex, and be aware that this
|
||||
// there is no mutex for the entire function.
|
||||
|
||||
// Prune all the things
|
||||
// Prune all the things. In-memory state is pruned on every node; jobs touching shared
|
||||
// databases (and the web push job, which also sends expiry-warning notifications) run on
|
||||
// the cluster leader only. In a single-node setup, IsLeader is always true.
|
||||
s.pruneVisitors()
|
||||
s.pruneTokens()
|
||||
s.pruneAttachments()
|
||||
s.pruneMessages()
|
||||
s.pruneAndNotifyWebPushSubscriptions()
|
||||
if s.cluster.IsLeader() {
|
||||
s.pruneTokens()
|
||||
s.pruneAttachments()
|
||||
s.pruneMessages()
|
||||
s.pruneAndNotifyWebPushSubscriptions()
|
||||
}
|
||||
|
||||
// Message count
|
||||
messagesCached, err := s.messageCache.MessagesCount()
|
||||
@@ -93,13 +98,13 @@ func (s *Server) execManager() {
|
||||
"emails_sent_failure": sentMailFailure,
|
||||
}).
|
||||
Info("Server stats")
|
||||
mset(metricMessagesCached, messagesCached)
|
||||
mset(metricVisitors, visitorsCount)
|
||||
mset(metricUsers, usersCount)
|
||||
mset(metricSubscribers, subscribers)
|
||||
mset(metricTopics, topicsCount)
|
||||
metrics.MessagesCached.Set(float64(messagesCached))
|
||||
metrics.Visitors.Set(float64(visitorsCount))
|
||||
metrics.Users.Set(float64(usersCount))
|
||||
metrics.Subscribers.Set(float64(subscribers))
|
||||
metrics.Topics.Set(float64(topicsCount))
|
||||
if s.attachment != nil {
|
||||
mset(metricAttachmentsTotalSize, s.attachment.Size())
|
||||
metrics.AttachmentsTotalSize.Set(float64(s.attachment.Size()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var (
|
||||
metricMessagesPublishedSuccess prometheus.Counter
|
||||
metricMessagesPublishedFailure prometheus.Counter
|
||||
metricMessagesCached prometheus.Gauge
|
||||
metricMessagePublishDurationMillis prometheus.Gauge
|
||||
metricFirebasePublishedSuccess prometheus.Counter
|
||||
metricFirebasePublishedFailure prometheus.Counter
|
||||
metricEmailsPublishedSuccess prometheus.Counter
|
||||
metricEmailsPublishedFailure prometheus.Counter
|
||||
metricEmailsReceivedSuccess prometheus.Counter
|
||||
metricEmailsReceivedFailure prometheus.Counter
|
||||
metricCallsMadeSuccess prometheus.Counter
|
||||
metricCallsMadeFailure prometheus.Counter
|
||||
metricUnifiedPushPublishedSuccess prometheus.Counter
|
||||
metricMatrixPublishedSuccess prometheus.Counter
|
||||
metricMatrixPublishedFailure prometheus.Counter
|
||||
metricAttachmentsTotalSize prometheus.Gauge
|
||||
metricVisitors prometheus.Gauge
|
||||
metricSubscribers prometheus.Gauge
|
||||
metricTopics prometheus.Gauge
|
||||
metricUsers prometheus.Gauge
|
||||
metricHTTPRequests *prometheus.CounterVec
|
||||
)
|
||||
|
||||
func initMetrics() {
|
||||
metricMessagesPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_messages_published_success",
|
||||
})
|
||||
metricMessagesPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_messages_published_failure",
|
||||
})
|
||||
metricMessagesCached = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_messages_cached_total",
|
||||
})
|
||||
metricMessagePublishDurationMillis = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_message_publish_duration_ms",
|
||||
})
|
||||
metricFirebasePublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_firebase_published_success",
|
||||
})
|
||||
metricFirebasePublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_firebase_published_failure",
|
||||
})
|
||||
metricEmailsPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_sent_success",
|
||||
})
|
||||
metricEmailsPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_sent_failure",
|
||||
})
|
||||
metricEmailsReceivedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_received_success",
|
||||
})
|
||||
metricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_received_failure",
|
||||
})
|
||||
metricCallsMadeSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_calls_made_success",
|
||||
})
|
||||
metricCallsMadeFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_calls_made_failure",
|
||||
})
|
||||
metricUnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_unifiedpush_published_success",
|
||||
})
|
||||
metricMatrixPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_matrix_published_success",
|
||||
})
|
||||
metricMatrixPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_matrix_published_failure",
|
||||
})
|
||||
metricAttachmentsTotalSize = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_attachments_total_size",
|
||||
})
|
||||
metricVisitors = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_visitors_total",
|
||||
})
|
||||
metricUsers = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_users_total",
|
||||
})
|
||||
metricSubscribers = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_subscribers_total",
|
||||
})
|
||||
metricTopics = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_topics_total",
|
||||
})
|
||||
metricHTTPRequests = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "ntfy_http_requests_total",
|
||||
}, []string{"http_code", "ntfy_code", "http_method"})
|
||||
prometheus.MustRegister(
|
||||
metricMessagesPublishedSuccess,
|
||||
metricMessagesPublishedFailure,
|
||||
metricMessagesCached,
|
||||
metricMessagePublishDurationMillis,
|
||||
metricFirebasePublishedSuccess,
|
||||
metricFirebasePublishedFailure,
|
||||
metricEmailsPublishedSuccess,
|
||||
metricEmailsPublishedFailure,
|
||||
metricEmailsReceivedSuccess,
|
||||
metricEmailsReceivedFailure,
|
||||
metricCallsMadeSuccess,
|
||||
metricCallsMadeFailure,
|
||||
metricUnifiedPushPublishedSuccess,
|
||||
metricMatrixPublishedSuccess,
|
||||
metricMatrixPublishedFailure,
|
||||
metricAttachmentsTotalSize,
|
||||
metricVisitors,
|
||||
metricUsers,
|
||||
metricSubscribers,
|
||||
metricTopics,
|
||||
metricHTTPRequests,
|
||||
)
|
||||
}
|
||||
|
||||
// minc increments a prometheus.Counter if it is non-nil
|
||||
func minc(counter prometheus.Counter) {
|
||||
if counter != nil {
|
||||
counter.Inc()
|
||||
}
|
||||
}
|
||||
|
||||
// mset sets a prometheus.Gauge if it is non-nil
|
||||
func mset[T int | int64 | float64](gauge prometheus.Gauge, value T) {
|
||||
if gauge != nil {
|
||||
gauge.Set(float64(value))
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const (
|
||||
contextRateVisitor contextKey = iota + 2586
|
||||
contextTopic
|
||||
contextMatrixPushKey
|
||||
contextVisitorIP // Client IP extracted in maybeAuthenticate; reused by the abuse ban-feed (see recordStatus)
|
||||
contextVisitorIP // Client IP extracted in maybeAuthenticate; reused by the abuse ban-feed (see ban.Service.Record)
|
||||
)
|
||||
|
||||
func (s *Server) limitRequests(next handleFunc) handleFunc {
|
||||
|
||||
+70
-16
@@ -2,10 +2,14 @@ package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template/parse"
|
||||
"time"
|
||||
@@ -17,7 +21,33 @@ import (
|
||||
"heckel.io/ntfy/v2/util/sprig"
|
||||
)
|
||||
|
||||
func (s *Server) handleBodyAsTemplatedTextMessage(m *model.Message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error {
|
||||
var (
|
||||
//go:embed templates
|
||||
templatesFs embed.FS // Contains template config files (e.g. grafana.yml, github.yml, ...)
|
||||
templatesDir = "templates"
|
||||
|
||||
templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
|
||||
|
||||
// templatePrintfLargeSizeRegex matches a printf directive whose width or precision is a star
|
||||
// (taken from an argument) or has four or more digits, i.e. is at least 1000. It deliberately
|
||||
// scans the flag/width/precision characters after a % without requiring a well-formed
|
||||
// directive: fmt pads even malformed ones (e.g. "%000 9999999#" emits 10 MB), so anything
|
||||
// unrecognized must still be caught.
|
||||
templatePrintfLargeSizeRegex = regexp.MustCompile(`%[-+# 0-9.*\[\]]*(\*|[0-9]{4})`)
|
||||
|
||||
// templateMaxExecutionTime is the wall-clock deadline for a single template render, a DoS guard
|
||||
// (GHSA-rhwf-xgc9-m9fp). It is a var (not a const) solely so tests can raise it; it is never
|
||||
// mutated in production.
|
||||
templateMaxExecutionTime = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
const (
|
||||
templateMaxOutputBytes = 1024 * 1024 // Maximum number of bytes a template can output, used to prevent DoS attacks
|
||||
templateMaxTemplateBytes = 32 * 1024 // Maximum size of a template (inline or from a template file), used to prevent DoS attacks
|
||||
templateFileExtension = ".yml" // Template files must end with this extension
|
||||
)
|
||||
|
||||
func (s *Server) handleBodyAsTemplatedTextMessage(ctx context.Context, m *model.Message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error {
|
||||
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -26,11 +56,11 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *model.Message, template tem
|
||||
}
|
||||
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
|
||||
if template.FileMode() {
|
||||
if err := s.renderTemplateFromFile(m, template.FileName(), peekedBody); err != nil {
|
||||
if err := s.renderTemplateFromFile(ctx, m, template.FileName(), peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := s.renderTemplateFromParams(m, peekedBody, priorityStr); err != nil {
|
||||
if err := s.renderTemplateFromParams(ctx, m, peekedBody, priorityStr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -42,7 +72,7 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *model.Message, template tem
|
||||
|
||||
// renderTemplateFromFile transforms the JSON message body according to a template from the filesystem.
|
||||
// The template file must be in the templates directory, or in the configured template directory.
|
||||
func (s *Server) renderTemplateFromFile(m *model.Message, templateName, peekedBody string) error {
|
||||
func (s *Server) renderTemplateFromFile(ctx context.Context, m *model.Message, templateName, peekedBody string) error {
|
||||
if !templateNameRegex.MatchString(templateName) {
|
||||
return errHTTPBadRequestTemplateFileNotFound
|
||||
}
|
||||
@@ -61,17 +91,17 @@ func (s *Server) renderTemplateFromFile(m *model.Message, templateName, peekedBo
|
||||
}
|
||||
var err error
|
||||
if tpl.Message != nil {
|
||||
if m.Message, err = s.renderTemplate(templateName+" (message)", *tpl.Message, peekedBody); err != nil {
|
||||
if m.Message, err = s.renderTemplate(ctx, templateName+" (message)", *tpl.Message, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tpl.Title != nil {
|
||||
if m.Title, err = s.renderTemplate(templateName+" (title)", *tpl.Title, peekedBody); err != nil {
|
||||
if m.Title, err = s.renderTemplate(ctx, templateName+" (title)", *tpl.Title, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tpl.Priority != nil {
|
||||
renderedPriority, err := s.renderTemplate(templateName+" (priority)", *tpl.Priority, peekedBody)
|
||||
renderedPriority, err := s.renderTemplate(ctx, templateName+" (priority)", *tpl.Priority, peekedBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -84,16 +114,16 @@ func (s *Server) renderTemplateFromFile(m *model.Message, templateName, peekedBo
|
||||
|
||||
// renderTemplateFromParams transforms the JSON message body according to the inline template in the
|
||||
// message, title, and priority parameters.
|
||||
func (s *Server) renderTemplateFromParams(m *model.Message, peekedBody string, priorityStr string) error {
|
||||
func (s *Server) renderTemplateFromParams(ctx context.Context, m *model.Message, peekedBody string, priorityStr string) error {
|
||||
var err error
|
||||
if m.Message, err = s.renderTemplate("priority query parameter", m.Message, peekedBody); err != nil {
|
||||
if m.Message, err = s.renderTemplate(ctx, "priority query parameter", m.Message, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Title, err = s.renderTemplate("title query parameter", m.Title, peekedBody); err != nil {
|
||||
if m.Title, err = s.renderTemplate(ctx, "title query parameter", m.Title, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
if priorityStr != "" {
|
||||
renderedPriority, err := s.renderTemplate("priority query parameter", priorityStr, peekedBody)
|
||||
renderedPriority, err := s.renderTemplate(ctx, "priority query parameter", priorityStr, peekedBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -105,23 +135,30 @@ func (s *Server) renderTemplateFromParams(m *model.Message, peekedBody string, p
|
||||
}
|
||||
|
||||
// renderTemplate renders a template with the given JSON source data.
|
||||
func (s *Server) renderTemplate(name, tpl, source string) (string, error) {
|
||||
func (s *Server) renderTemplate(ctx context.Context, name, tpl, source string) (string, error) {
|
||||
if len(tpl) > templateMaxTemplateBytes {
|
||||
return "", errHTTPBadRequestTemplateTooLarge
|
||||
}
|
||||
var data any
|
||||
if err := json.Unmarshal([]byte(source), &data); err != nil {
|
||||
return "", errHTTPBadRequestTemplateMessageNotJSON
|
||||
}
|
||||
t, err := gotext.New("").Funcs(sprig.TxtFuncMap()).Parse(tpl)
|
||||
t, err := gotext.New("").Funcs(sprig.TxtFuncMap()).Funcs(gotext.FuncMap{"printf": templatePrintf}).Parse(tpl)
|
||||
if err != nil {
|
||||
return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error())
|
||||
}
|
||||
if templateUsesDisallowedFeatures(t) {
|
||||
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
|
||||
}
|
||||
t.SetExecutionDeadline(time.Now().Add(templateMaxExecutionTime)) // Bail out of runaway templates (GHSA-rhwf-xgc9-m9fp)
|
||||
// Bail out of runaway templates (GHSA-rhwf-xgc9-m9fp). The deadline starts here, after the body
|
||||
// has already been read, so a slow upload is not counted against it. Deriving from the request
|
||||
// context means a client disconnect aborts the render too.
|
||||
execCtx, cancel := context.WithTimeout(ctx, templateMaxExecutionTime)
|
||||
defer cancel()
|
||||
var buf bytes.Buffer
|
||||
limitWriter := util.NewLimitWriter(&buf, util.NewFixedLimiter(templateMaxOutputBytes))
|
||||
if err := t.Execute(limitWriter, data); err != nil {
|
||||
if errors.Is(err, gotext.ErrExecutionInterrupted) {
|
||||
if err := t.ExecuteContext(execCtx, limitWriter, data); err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "", errHTTPBadRequestTemplateExecutionTimeout
|
||||
}
|
||||
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error())
|
||||
@@ -164,6 +201,8 @@ func treeContainsDisallowedNode(node parse.Node) bool {
|
||||
return treeContainsDisallowedNode(n.Pipe) || treeContainsDisallowedNode(n.List) || treeContainsDisallowedNode(n.ElseList)
|
||||
case *parse.TemplateNode: // {{template}} or {{block}} invocation
|
||||
return true
|
||||
case *parse.ChainNode: // A term followed by field accesses, e.g. (call .x).y
|
||||
return treeContainsDisallowedNode(n.Node)
|
||||
case *parse.PipeNode:
|
||||
if n == nil {
|
||||
return false
|
||||
@@ -184,3 +223,18 @@ func treeContainsDisallowedNode(node parse.Node) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// templatePrintf is the template builtin printf, guarded against memory amplification: fmt
|
||||
// allows widths and precisions up to 1e6 per verb, so a small template like
|
||||
// {{printf "%999999d%999999d..." ...}} can allocate gigabytes inside a single fmt call -- and the
|
||||
// executor's cancellation context is only checked between template nodes, never inside one.
|
||||
// Widths and precisions of 1000 or more are therefore rejected, as is the star (*) form, which
|
||||
// takes the width from an argument. Combined with the template size limit, this bounds a single
|
||||
// render to a few MB. Registered via Funcs, which takes precedence over the builtin, and checked
|
||||
// at call time so a format string assembled during execution is covered too.
|
||||
func templatePrintf(format string, args ...any) (string, error) {
|
||||
if templatePrintfLargeSizeRegex.MatchString(strings.ReplaceAll(format, "%%", "")) { // Strip escaped percent signs, they take no width
|
||||
return "", errors.New("printf width or precision too large")
|
||||
}
|
||||
return fmt.Sprintf(format, args...), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServer_MessageTemplate_TooLarge(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
response := request(t, s, "PUT", "/mytopic", `{"foo":"bar"}`, map[string]string{
|
||||
"X-Message": "{{.foo}}" + strings.Repeat("x", 33*1024),
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40056, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_PrintfWidthTooLarge(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
// A handful of 1MB-wide verbs would allocate several MB inside a single fmt call, where
|
||||
// the executor's context is never checked; the printf guard must reject the call before
|
||||
// fmt runs, not after the limit writer sees the output
|
||||
response := request(t, s, "PUT", "/mytopic", `{"n":1}`, map[string]string{
|
||||
"X-Message": `{{printf "%1000000d%1000000d%1000000d" .n .n .n}}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code)
|
||||
require.Contains(t, response.Body.String(), "printf width or precision too large")
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_PrintfWidthTooLarge_DynamicFormat(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
// The format string is assembled at execution time, so the guard must inspect the actual
|
||||
// argument, not the template source
|
||||
response := request(t, s, "PUT", "/mytopic", `{"n":1}`, map[string]string{
|
||||
"X-Message": `{{$f := print "%" "999999" "d" "%" "999999" "d"}}{{printf $f .n .n}}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Contains(t, response.Body.String(), "printf width or precision too large")
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_PrintfStarWidthTooLarge(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
// Star widths take the width from an argument; sprig's math functions (int64 results)
|
||||
// make large integer arguments reachable from a template
|
||||
response := request(t, s, "PUT", "/mytopic", `{"n":1}`, map[string]string{
|
||||
"X-Message": `{{printf "%*d" (mul 1000 2000) 1}}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Contains(t, response.Body.String(), "printf width or precision too large")
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_PrintfSmallWidthStillWorks(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
response := request(t, s, "PUT", "/mytopic", `{"n":7}`, map[string]string{
|
||||
"X-Message": `{{printf "%05d" 7}}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, "00007", toMessage(t, response.Body.String()).Message)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_templatePrintf(t *testing.T) {
|
||||
tests := []struct {
|
||||
format string
|
||||
args []any
|
||||
want string // Empty means the call must be rejected
|
||||
}{
|
||||
{"%d", []any{5}, "5"},
|
||||
{"%05d", []any{5}, "00005"},
|
||||
{"%-8.3f|", []any{1.5}, "1.500 |"},
|
||||
{"%1000d", []any{1}, ""}, // Rejected: four digits
|
||||
{"%.1000s", []any{"x"}, ""},
|
||||
{"%*d", []any{500, 1}, ""}, // Rejected: star width
|
||||
{"%.*s", []any{400, "x"}, ""}, // Rejected: star precision
|
||||
{"%[1]1000000d", []any{1}, ""}, // Rejected: explicit arg index does not hide the width
|
||||
{"%[2]*[1]d", []any{6, 12}, ""}, // Rejected: star width behind an arg index
|
||||
{"100%% of 2024 values", nil, "100% of 2024 values"}, // Literal digits are not a width
|
||||
}
|
||||
for _, test := range tests {
|
||||
out, err := templatePrintf(test.format, test.args...)
|
||||
if test.want == "" {
|
||||
require.Error(t, err, "format %q must be rejected", test.format)
|
||||
require.Contains(t, err.Error(), "too large")
|
||||
} else {
|
||||
require.Nil(t, err, "format %q", test.format)
|
||||
require.Equal(t, test.want, out)
|
||||
}
|
||||
}
|
||||
|
||||
// The largest allowed width still produces bounded output
|
||||
out, err := templatePrintf("%999d", 1)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, out, 999)
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_DisallowedCallInChain(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
// {{call}} behind a field access parses into a ChainNode. JSON data cannot produce a
|
||||
// function value, so this cannot be exploited today, but the ban must catch every
|
||||
// syntactic form rather than relying on the call failing at runtime.
|
||||
response := request(t, s, "PUT", "/mytopic", `{"fn":1}`, map[string]string{
|
||||
"X-Message": `{{(call .fn).x}}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40044, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
+76
-3
@@ -22,6 +22,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
dbtest "heckel.io/ntfy/v2/db/test"
|
||||
@@ -323,6 +324,40 @@ func TestServer_WebEnabled(t *testing.T) {
|
||||
require.Equal(t, 200, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
// TestServer_MetricsEnabled ensures that the /metrics endpoint serves the registered ntfy metrics
|
||||
// once the metrics handler is set (as Serve does when enable-metrics is configured).
|
||||
func TestServer_MetricsEnabled(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
s.metricsHandler = promhttp.Handler() // Serve sets this when enable-metrics is configured
|
||||
|
||||
// Count at least one request first: Prometheus only reports a CounterVec such as
|
||||
// ntfy_http_requests_total once it has children
|
||||
request(t, s, "GET", "/v1/health", "", nil)
|
||||
|
||||
rr := request(t, s, "GET", "/metrics", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Contains(t, rr.Body.String(), "ntfy_messages_published_success")
|
||||
require.Contains(t, rr.Body.String(), "ntfy_http_requests_total")
|
||||
})
|
||||
}
|
||||
|
||||
// TestServer_MetricsDisabled ensures that the ntfy metrics are not exposed when the metrics handler
|
||||
// is unset (the default). The collectors are always registered with the Prometheus registry, so a
|
||||
// nil metrics handler is the only thing keeping them off the wire.
|
||||
func TestServer_MetricsDisabled(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfig(t, databaseURL)
|
||||
conf.WebRoot = "" // Disable the web app, so its catch-all does not mask the /metrics route
|
||||
s := newTestServer(t, conf)
|
||||
|
||||
rr := request(t, s, "GET", "/metrics", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
require.NotContains(t, rr.Body.String(), "ntfy_messages_published_success")
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_PublishLargeMessage(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
c := newTestConfig(t, databaseURL)
|
||||
@@ -1684,6 +1719,7 @@ func TestServer_PublishEmailVerify_BoolValueUsesPrimary(t *testing.T) {
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
waitFor(t, func() bool { return mailer.LastTo() != "" }) // E-Mail publishing happens in a Go routine
|
||||
require.Equal(t, "zzz@example.com", mailer.LastTo())
|
||||
})
|
||||
}
|
||||
@@ -1710,6 +1746,7 @@ func TestServer_PublishEmailVerify_BoolValueNoVerifyUsesPrimary(t *testing.T) {
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
waitFor(t, func() bool { return mailer.LastTo() != "" }) // E-Mail publishing happens in a Go routine
|
||||
require.Equal(t, "zzz@example.com", mailer.LastTo())
|
||||
})
|
||||
}
|
||||
@@ -1751,6 +1788,7 @@ func TestServer_PublishEmailVerify_BoolValueProvisionedUsesPrimary(t *testing.T)
|
||||
"Authorization": util.BasicAuth("prov", "provpass"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
waitFor(t, func() bool { return mailer.LastTo() != "" }) // E-Mail publishing happens in a Go routine
|
||||
require.Equal(t, "zzz@example.com", mailer.LastTo())
|
||||
})
|
||||
}
|
||||
@@ -3764,9 +3802,8 @@ func (b *slowBody) Close() error { return nil }
|
||||
func TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t, ""))
|
||||
start := time.Now()
|
||||
// The loop makes the template execute enough nodes (>256) to actually hit the deadline check,
|
||||
// so this test distinguishes correct behavior from a deadline that includes upload time -- yet
|
||||
// it runs in ~1ms, far under the deadline, so on correct code it renders fine.
|
||||
// The template runs in ~1ms, far under the deadline, so on correct code it renders fine; the
|
||||
// point is that the deadline starts at execution, not when the (slow) upload began.
|
||||
response := request(t, s, "POST", "/mytopic", `{"foo":"bar"}`, map[string]string{
|
||||
"Template": "yes",
|
||||
"X-Message": `{{range until 5000}}{{$x := .}}{{end}}hello {{.foo}}`,
|
||||
@@ -3780,6 +3817,40 @@ func TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline(t *testing.
|
||||
require.Equal(t, "hello bar", m.Message)
|
||||
}
|
||||
|
||||
// TestServer_MessageTemplate_ClientDisconnect_CancelsRender verifies that canceling the request
|
||||
// context (e.g. the client disconnecting) aborts an in-progress template render. The execution
|
||||
// deadline is raised well above the cancel delay for this test so that cancellation -- not the
|
||||
// deadline -- is what stops the render: a runaway template is canceled 500ms in and must abort
|
||||
// shortly after (well under the raised deadline), yielding the generic execute-failed code (40045),
|
||||
// not the timeout code (40055).
|
||||
//
|
||||
// Not parallel: it temporarily raises the package-global templateMaxExecutionTime. Non-parallel
|
||||
// tests run in their own phase (parallel tests are paused), so the override is race-free.
|
||||
func TestServer_MessageTemplate_ClientDisconnect_CancelsRender(t *testing.T) {
|
||||
origDeadline := templateMaxExecutionTime
|
||||
templateMaxExecutionTime = 30 * time.Second // large enough that only the cancel can stop the render
|
||||
defer func() { templateMaxExecutionTime = origDeadline }()
|
||||
|
||||
s := newTestServer(t, newTestConfig(t, ""))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
start := time.Now()
|
||||
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
|
||||
"X-Message": `{{$x := until 10000}}{{range $x}}{{range $x}}{{end}}{{end}}done`,
|
||||
"X-Template": "1",
|
||||
}, func(r *http.Request) {
|
||||
*r = *r.WithContext(ctx)
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code, "a canceled render should map to execute-failed, not the timeout code 40055")
|
||||
require.Greater(t, elapsed, 500*time.Millisecond, "render must still be running when the cancel fires (took %s)", elapsed)
|
||||
require.Less(t, elapsed, 700*time.Millisecond, "request-context cancel should abort the render promptly after firing (took %s)", elapsed)
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
@@ -5167,6 +5238,7 @@ func TestServer_BanFeed_RateLimitedIPBanned(t *testing.T) {
|
||||
}
|
||||
}
|
||||
require.Greater(t, got429, 2)
|
||||
s.ban.Close() // Writes are async (runWriteLoop); Close flushes the buffer before we read
|
||||
data, err := os.ReadFile(banFile)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(data), "9.9.9.9 9.9.9.9/32 429 42901") // <ip> <prefix> <http> <ntfy-code>
|
||||
@@ -5186,5 +5258,6 @@ func TestServer_BanFeed_SuccessfulRequestsNotBanned(t *testing.T) {
|
||||
rr := request(t, s, "PUT", "/mytopic", fmt.Sprintf("m%d", i), nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
s.ban.Close() // Flush any buffered bans (there should be none) before asserting no file
|
||||
require.NoFileExists(t, banFile)
|
||||
}
|
||||
|
||||
+17
-188
@@ -1,77 +1,21 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/metrics"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/twilio"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
// twilioClient talks to the Twilio API to make phone calls (for the "Call" feature) and to verify
|
||||
// phone numbers. It holds the Twilio configuration and the user manager (used to look up a user's
|
||||
// verified phone numbers), so that this functionality is decoupled from the main Server.
|
||||
type twilioClient struct {
|
||||
config *Config
|
||||
userManager *user.Manager // May be nil!
|
||||
}
|
||||
|
||||
func newTwilioClient(conf *Config, userManager *user.Manager) *twilioClient {
|
||||
return &twilioClient{
|
||||
config: conf,
|
||||
userManager: userManager,
|
||||
}
|
||||
}
|
||||
|
||||
// defaultTwilioCallFormatTemplate is the default TwiML template used for Twilio calls.
|
||||
// It can be overridden in the server configuration's twilio-call-format field.
|
||||
//
|
||||
// The format uses Go template syntax with the following fields:
|
||||
// {{.Topic}}, {{.Title}}, {{.Message}}, {{.Priority}}, {{.Tags}}, {{.Sender}}
|
||||
// String fields are automatically XML-escaped.
|
||||
var defaultTwilioCallFormatTemplate = template.Must(template.New("twiml").Parse(`
|
||||
<Response>
|
||||
<Pause length="1"/>
|
||||
<Say loop="3">
|
||||
You have a message from notify on topic {{.Topic}}. Message:
|
||||
<break time="1s"/>
|
||||
{{.Message}}
|
||||
<break time="1s"/>
|
||||
End of message.
|
||||
<break time="1s"/>
|
||||
This message was sent by user {{.Sender}}. It will be repeated three times.
|
||||
To unsubscribe from calls like this, remove your phone number in the notify web app.
|
||||
<break time="3s"/>
|
||||
</Say>
|
||||
<Say>Goodbye.</Say>
|
||||
</Response>`))
|
||||
|
||||
// twilioCallData holds the data passed to the Twilio call format template
|
||||
type twilioCallData struct {
|
||||
Topic string
|
||||
Title string
|
||||
Message string
|
||||
Priority int
|
||||
Tags []string
|
||||
Sender string
|
||||
}
|
||||
|
||||
// convertPhoneNumber checks if the given phone number is verified for the given user, and if so, returns the verified
|
||||
// phone number. It also converts a boolean string ("yes", "1", "true") to the first verified phone number.
|
||||
// If the user is anonymous, it will return an error.
|
||||
func (c *twilioClient) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) {
|
||||
func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) {
|
||||
if u == nil {
|
||||
return "", errHTTPBadRequestAnonymousCallsNotAllowed
|
||||
}
|
||||
phoneNumbers, err := c.userManager.PhoneNumbers(u.ID)
|
||||
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
|
||||
if err != nil {
|
||||
return "", errHTTPInternalError
|
||||
} else if len(phoneNumbers) == 0 {
|
||||
@@ -87,139 +31,24 @@ func (c *twilioClient) convertPhoneNumber(u *user.User, phoneNumber string) (str
|
||||
|
||||
// callPhone calls the Twilio API to make a phone call to the given phone number, using the given message.
|
||||
// Failures will be logged, but not returned to the caller.
|
||||
func (c *twilioClient) callPhone(v *visitor, r *http.Request, m *model.Message, to string) {
|
||||
func (s *Server) callPhone(v *visitor, m *model.Message, to string) {
|
||||
u, sender := v.User(), m.Sender.String()
|
||||
if u != nil {
|
||||
sender = u.Name
|
||||
}
|
||||
tmpl := defaultTwilioCallFormatTemplate
|
||||
if c.config.TwilioCallFormat != nil {
|
||||
tmpl = c.config.TwilioCallFormat
|
||||
}
|
||||
tags := make([]string, len(m.Tags))
|
||||
for i, tag := range m.Tags {
|
||||
tags[i] = xmlEscapeText(tag)
|
||||
}
|
||||
templateData := &twilioCallData{
|
||||
Topic: xmlEscapeText(m.Topic),
|
||||
Title: xmlEscapeText(m.Title),
|
||||
Message: xmlEscapeText(m.Message),
|
||||
logvm(v, m).Tag(tagTwilio).Field("twilio_to", to).Info("Making phone call to %s", to)
|
||||
err := s.twilio.Call(to, &twilio.CallData{
|
||||
Topic: m.Topic,
|
||||
Title: m.Title,
|
||||
Message: m.Message,
|
||||
Priority: m.Priority,
|
||||
Tags: tags,
|
||||
Sender: xmlEscapeText(sender),
|
||||
}
|
||||
var bodyBuf bytes.Buffer
|
||||
if err := tmpl.Execute(&bodyBuf, templateData); err != nil {
|
||||
logvrm(v, r, m).Tag(tagTwilio).Err(err).Warn("Error executing Twilio call format template")
|
||||
minc(metricCallsMadeFailure)
|
||||
Tags: m.Tags,
|
||||
Sender: sender,
|
||||
})
|
||||
if err != nil {
|
||||
logvm(v, m).Tag(tagTwilio).Field("twilio_to", to).Err(err).Warn("Unable to call phone %s: %v", to, err.Error())
|
||||
metrics.CallsMadeFailure.Inc()
|
||||
return
|
||||
}
|
||||
body := bodyBuf.String()
|
||||
data := url.Values{}
|
||||
data.Set("From", c.config.TwilioPhoneNumber)
|
||||
data.Set("To", to)
|
||||
data.Set("Twiml", body)
|
||||
ev := logvrm(v, r, m).Tag(tagTwilio).Field("twilio_to", to).FieldIf("twilio_body", body, log.TraceLevel).Debug("Sending Twilio request")
|
||||
response, err := c.callPhoneInternal(data)
|
||||
if err != nil {
|
||||
ev.Field("twilio_response", response).Err(err).Warn("Error sending Twilio request")
|
||||
minc(metricCallsMadeFailure)
|
||||
return
|
||||
}
|
||||
ev.FieldIf("twilio_response", response, log.TraceLevel).Debug("Received successful Twilio response")
|
||||
minc(metricCallsMadeSuccess)
|
||||
}
|
||||
|
||||
func (c *twilioClient) callPhoneInternal(data url.Values) (string, error) {
|
||||
requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/Calls.json", c.config.TwilioCallsBaseURL, c.config.TwilioAccount)
|
||||
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "ntfy/"+c.config.BuildVersion)
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", util.BasicAuth(c.config.TwilioAccount, c.config.TwilioAuthToken))
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
response, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(response), nil
|
||||
}
|
||||
|
||||
func (c *twilioClient) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, channel string) error {
|
||||
ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Field("twilio_channel", channel).Debug("Sending phone verification")
|
||||
data := url.Values{}
|
||||
data.Set("To", phoneNumber)
|
||||
data.Set("Channel", channel)
|
||||
requestURL := fmt.Sprintf("%s/v2/Services/%s/Verifications", c.config.TwilioVerifyBaseURL, c.config.TwilioVerifyService)
|
||||
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "ntfy/"+c.config.BuildVersion)
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", util.BasicAuth(c.config.TwilioAccount, c.config.TwilioAuthToken))
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
ev.Err(err).Warn("Error sending Twilio phone verification request")
|
||||
return err
|
||||
}
|
||||
ev.FieldIf("twilio_response", string(response), log.TraceLevel).Debug("Received Twilio phone verification response")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *twilioClient) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber, code string) error {
|
||||
ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification")
|
||||
data := url.Values{}
|
||||
data.Set("To", phoneNumber)
|
||||
data.Set("Code", code)
|
||||
requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", c.config.TwilioVerifyBaseURL, c.config.TwilioVerifyService)
|
||||
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "ntfy/"+c.config.BuildVersion)
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", util.BasicAuth(c.config.TwilioAccount, c.config.TwilioAuthToken))
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if resp.StatusCode != http.StatusOK {
|
||||
if ev.IsTrace() {
|
||||
response, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ev.Field("twilio_response", string(response))
|
||||
}
|
||||
ev.Warn("Twilio phone verification failed with status code %d", resp.StatusCode)
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return errHTTPGonePhoneVerificationExpired
|
||||
}
|
||||
return errHTTPInternalError
|
||||
}
|
||||
response, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ev.IsTrace() {
|
||||
ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response")
|
||||
} else if ev.IsDebug() {
|
||||
ev.Debug("Received successful Twilio phone verification response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func xmlEscapeText(text string) string {
|
||||
var buf bytes.Buffer
|
||||
_ = xml.EscapeText(&buf, []byte(text))
|
||||
return buf.String()
|
||||
metrics.CallsMadeSuccess.Inc()
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"heckel.io/ntfy/v2/metrics"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
@@ -180,7 +181,7 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
s.backend.mu.Lock()
|
||||
s.backend.success++
|
||||
s.backend.mu.Unlock()
|
||||
minc(metricEmailsReceivedSuccess)
|
||||
metrics.EmailsReceivedSuccess.Inc()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -238,7 +239,7 @@ func (s *smtpSession) withFailCount(fn func() error) error {
|
||||
// We do not want to spam the log with WARN messages.
|
||||
logem(s.conn).Err(err).Debug("Incoming mail error")
|
||||
s.backend.failure++
|
||||
minc(metricEmailsReceivedFailure)
|
||||
metrics.EmailsReceivedFailure.Inc()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
+10
-5
@@ -20,11 +20,12 @@ const (
|
||||
// topic represents a channel to which subscribers can subscribe, and publishers
|
||||
// can publish a message
|
||||
type topic struct {
|
||||
ID string
|
||||
subscribers map[int]*topicSubscriber
|
||||
rateVisitor *visitor
|
||||
lastAccess time.Time
|
||||
mu sync.RWMutex
|
||||
ID string
|
||||
subscribers map[int]*topicSubscriber
|
||||
rateVisitor *visitor
|
||||
lastAccess time.Time
|
||||
onFirstSubscriber func() // Fired (async) when the subscriber count goes 0 -> 1; may be nil
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type topicSubscriber struct {
|
||||
@@ -56,6 +57,10 @@ func (t *topic) Subscribe(s subscriber, userID string, cancel func()) (subscribe
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(t.subscribers) == 0 && t.onFirstSubscriber != nil {
|
||||
// Fired async so cluster announcements never run under the topic lock
|
||||
go t.onFirstSubscriber()
|
||||
}
|
||||
t.subscribers[subscriberID] = &topicSubscriber{
|
||||
userID: userID, // May be empty
|
||||
subscriber: s,
|
||||
|
||||
@@ -29,6 +29,17 @@ type publishMessage struct {
|
||||
Delay string `json:"delay"`
|
||||
}
|
||||
|
||||
// dispatchOpts selects which delivery targets fire for a published message, beyond delivery
|
||||
// to local subscribers and the cross-node forward, which always happen (see Server.dispatch)
|
||||
type dispatchOpts struct {
|
||||
firebase bool // Send to Firebase (if configured)
|
||||
email string // Send an email to this address (if a mailer is configured)
|
||||
call string // Call this phone number (if Twilio is configured)
|
||||
upstream bool // Forward a poll request to the upstream server (if configured)
|
||||
webPush bool // Publish to web push endpoints (if configured)
|
||||
async bool // Deliver to local subscribers in a goroutine, logging errors instead of returning them
|
||||
}
|
||||
|
||||
// messageEncoder is a function that knows how to encode a message
|
||||
type messageEncoder func(msg *model.Message) (string, error)
|
||||
|
||||
@@ -217,6 +228,14 @@ type apiAccountTokenResponse struct {
|
||||
Provisioned bool `json:"provisioned,omitempty"` // True if this token was provisioned by the server config
|
||||
}
|
||||
|
||||
// apiAccountLoginResponse is the body of POST /v1/account/login: it authenticates a
|
||||
// username-or-email + password, mints a session token, and returns the token together with the
|
||||
// canonical username (which may differ from the identifier the user typed, e.g. a primary email).
|
||||
type apiAccountLoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type apiAccountPhoneNumberVerifyRequest struct {
|
||||
Number string `json:"number"`
|
||||
Channel string `json:"channel"`
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/netip"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -71,8 +70,6 @@ type visitor struct {
|
||||
authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil
|
||||
firebase time.Time // Next allowed Firebase message
|
||||
seen time.Time // Last seen time of this visitor (needed for removal of stale visitors)
|
||||
banLimiter *rate.Limiter // Abuse ban-feed: single weighted breach detector (rate.Limiter is concurrency-safe), nil when feature disabled
|
||||
banEmit *rate.Limiter // Abuse ban-feed: throttles writes so a persistent offender only re-appears occasionally, nil when disabled
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
@@ -145,13 +142,6 @@ func newVisitor(conf *Config, messageCache *message.Cache, userManager *user.Man
|
||||
accountLimiter: nil, // Set in resetLimiters, may be nil
|
||||
authLimiter: nil, // Set in resetLimiters, may be nil
|
||||
}
|
||||
// Abuse ban-feed: only wire up per-visitor tracking when the feature is enabled. One weighted
|
||||
// token bucket (capacity BanThreshold, refilled at BanThreshold/BanWindow per second) is the
|
||||
// breach detector; banEmit throttles writes so the feed stays tiny.
|
||||
if conf.BanFile != "" {
|
||||
v.banLimiter = rate.NewLimiter(rate.Limit(float64(conf.BanThreshold)/conf.BanWindow.Seconds()), conf.BanThreshold)
|
||||
v.banEmit = rate.NewLimiter(rate.Every(conf.BanWindow), 1)
|
||||
}
|
||||
v.resetLimitersNoLock(messages, emails, calls, false)
|
||||
return v
|
||||
}
|
||||
@@ -561,73 +551,6 @@ func dailyLimitToRate(limit int64) rate.Limit {
|
||||
return rate.Limit(limit) * rate.Every(oneDay)
|
||||
}
|
||||
|
||||
// recordStatus is called once per request with the offending request's IP and the final HTTP status
|
||||
// and ntfy code. Each rejection (4xx/5xx only) consumes weightFor(ntfyCode) tokens from the visitor's
|
||||
// single weighted ban bucket; a code weighted 0 (or one that matches no rule) is exempt and never
|
||||
// counts. When the bucket cannot cover a rejection the visitor has breached, and ip is appended to
|
||||
// the ban file that fail2ban tails (throttled via banEmit so a persistent offender only re-appears
|
||||
// occasionally). ip is passed in rather than read from v.ip because an account-keyed (tier'd) visitor
|
||||
// is shared across all its source IPs, so v.ip would be stale; we ban the address that breached.
|
||||
// This is on the hot path, so it no-ops immediately when the feature is disabled.
|
||||
func (v *visitor) recordStatus(ip netip.Addr, httpCode, ntfyCode int) {
|
||||
if v.config.BanFile == "" {
|
||||
return // Feature disabled
|
||||
}
|
||||
if httpCode < 400 {
|
||||
return // Only rejections (4xx/5xx) count toward a ban; success and redirects never do
|
||||
}
|
||||
// Note: tier'd (account-keyed) visitors are NOT exempt. A persistent 429 stream is abusive
|
||||
// regardless of the account behind it, and banning the offending IP is the right response --
|
||||
// exactly what the pre-existing nginx-side jails already did. The legit case (a paid user merely
|
||||
// hitting a plan/quota limit) is spared by the per-code weights instead: quota codes like 42908
|
||||
// are weighted 0 below. Only request-limiter floods (42901, weight 1) actually accrue strikes.
|
||||
weight := v.config.weightFor(ntfyCode)
|
||||
if weight <= 0 {
|
||||
return // Exempt (weight 0) or unmatched code: no strike
|
||||
}
|
||||
if v.banLimiter.AllowN(time.Now(), weight) {
|
||||
return // The bucket covered this rejection; still within the strike budget
|
||||
}
|
||||
// Breached. Throttle so the feed stays tiny even if the offender keeps hammering.
|
||||
if v.banEmit.Allow() {
|
||||
writeBanLine(v.config.BanFile, ip, visitorPrefix(ip, v.config), httpCode, ntfyCode)
|
||||
}
|
||||
}
|
||||
|
||||
// banFileMu serializes appends to the ban file across all visitors (multiple visitors may breach
|
||||
// concurrently). The feed is pre-filtered, so write volume is low and a single mutex is plenty.
|
||||
var banFileMu sync.Mutex
|
||||
|
||||
// writeBanLine appends a single line to the ban file:
|
||||
//
|
||||
// <RFC3339-UTC-timestamp> <ip> <prefix> <http-code> <ntfy-code>
|
||||
//
|
||||
// e.g. "2026-07-18T20:56:32Z 1.2.3.4 1.2.3.4/32 429 42901". <prefix> is <ip> masked to the configured
|
||||
// rate-limiting prefix (VisitorPrefixBitsIPv4/IPv6); that is the unit fail2ban should ban, so an IPv6
|
||||
// client (which owns a whole /64) is banned as one, matching how ntfy rate-limits it. This exact
|
||||
// format is a contract that a fail2ban jail parses. Best-effort; any error is swallowed, because a
|
||||
// failure to write the ban feed must never fail the underlying request.
|
||||
func writeBanLine(path string, ip netip.Addr, prefix netip.Prefix, httpCode, ntfyCode int) {
|
||||
banFileMu.Lock()
|
||||
defer banFileMu.Unlock()
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
fmt.Fprintf(f, "%s %s %s %d %d\n", time.Now().UTC().Format(time.RFC3339), ip.String(), prefix.String(), httpCode, ntfyCode)
|
||||
}
|
||||
|
||||
// visitorPrefix masks ip to the configured rate-limiting prefix (VisitorPrefixBitsIPv4/IPv6), so the
|
||||
// ban feed reports the same unit ntfy rate-limits by -- e.g. a whole /64 for an IPv6 client -- rather
|
||||
// than a single address. fail2ban bans that prefix.
|
||||
func visitorPrefix(ip netip.Addr, conf *Config) netip.Prefix {
|
||||
if ip.Is4() {
|
||||
return netip.PrefixFrom(ip, conf.VisitorPrefixBitsIPv4).Masked()
|
||||
}
|
||||
return netip.PrefixFrom(ip, conf.VisitorPrefixBitsIPv6).Masked()
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
)
|
||||
|
||||
// newBanTestVisitor creates a visitor wired for ban-feed testing, with the given ban file,
|
||||
// weighted-bucket threshold, and weights, plus a 1-minute window (so the emit throttle only
|
||||
// fires once per test).
|
||||
func newBanTestVisitor(t *testing.T, banFile string, threshold int, weights map[string]int) *visitor {
|
||||
conf := NewConfig()
|
||||
conf.BanFile = banFile
|
||||
conf.BanWindow = time.Minute
|
||||
conf.BanThreshold = threshold
|
||||
conf.BanWeights = weights
|
||||
return newVisitor(conf, nil, nil, netip.MustParseAddr("1.2.3.4"), nil)
|
||||
}
|
||||
|
||||
func readBanLines(t *testing.T, path string) []string {
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
return strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
}
|
||||
|
||||
func TestVisitor_RecordStatus_Weight2BansAtHalfThreshold(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
// Threshold 10, code weight 2 -> the budget covers exactly 5 hits, so the 6th breaches.
|
||||
v := newBanTestVisitor(t, banFile, 10, map[string]int{"*": 2})
|
||||
for i := 0; i < 5; i++ {
|
||||
v.recordStatus(v.ip, 400, 40001)
|
||||
}
|
||||
require.NoFileExists(t, banFile) // 5 hits * weight 2 = 10 == budget, exactly at the limit, not over
|
||||
v.recordStatus(v.ip, 400, 40001) // 6th hit cannot be covered -> breach
|
||||
lines := readBanLines(t, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 400 40001")) // <ip> <prefix> <http> <ntfy-code>
|
||||
}
|
||||
|
||||
func TestVisitor_RecordStatus_Weight10BansFast(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
// Threshold 10, code weight 10 -> a single hit drains the whole budget, so the 2nd breaches.
|
||||
v := newBanTestVisitor(t, banFile, 10, map[string]int{"42909": 10, "*": 1})
|
||||
v.recordStatus(v.ip, 429, 42909)
|
||||
require.NoFileExists(t, banFile)
|
||||
v.recordStatus(v.ip, 429, 42909) // 2nd hit cannot be covered -> breach
|
||||
lines := readBanLines(t, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 429 42909"))
|
||||
}
|
||||
|
||||
func TestVisitor_RecordStatus_Weight0NeverBans(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
// The legit-quota code is exempt (weight 0), so no number of hits ever bans.
|
||||
v := newBanTestVisitor(t, banFile, 10, map[string]int{"42908": 0, "*": 1})
|
||||
for i := 0; i < 100; i++ {
|
||||
v.recordStatus(v.ip, 429, 42908)
|
||||
}
|
||||
require.NoFileExists(t, banFile)
|
||||
}
|
||||
|
||||
func TestVisitor_RecordStatus_SingleBucketNoRelaxation(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
// One shared bucket: different codes draw down the SAME budget, so mixing them creates no extra
|
||||
// headroom (unlike per-code buckets, which would relax the effective limit for a mixed offender).
|
||||
v := newBanTestVisitor(t, banFile, 10, map[string]int{"403*": 2, "*": 1})
|
||||
v.recordStatus(v.ip, 403, 40301) // weight 2 -> 8 left
|
||||
v.recordStatus(v.ip, 403, 40301) // weight 2 -> 6 left
|
||||
v.recordStatus(v.ip, 403, 40301) // weight 2 -> 4 left
|
||||
require.NoFileExists(t, banFile)
|
||||
for i := 0; i < 4; i++ {
|
||||
v.recordStatus(v.ip, 400, 40001) // weight 1 each -> drains the remaining 4 -> 0 left
|
||||
}
|
||||
require.NoFileExists(t, banFile) // 6 + 4 = 10 == budget exactly, still not over
|
||||
v.recordStatus(v.ip, 400, 40001) // one more cannot be covered -> breach
|
||||
lines := readBanLines(t, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
}
|
||||
|
||||
func TestVisitor_RecordStatus_ExactLineFormat(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
v := newBanTestVisitor(t, banFile, 1, map[string]int{"*": 1})
|
||||
before := time.Now().UTC().Truncate(time.Second)
|
||||
for i := 0; i < 3; i++ {
|
||||
v.recordStatus(v.ip, 429, 42901)
|
||||
}
|
||||
after := time.Now().UTC()
|
||||
lines := readBanLines(t, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
parts := strings.Split(lines[0], " ")
|
||||
require.Len(t, parts, 5) // "<timestamp> <ip> <prefix> <http-code> <ntfy-code>"
|
||||
ts, err := time.Parse(time.RFC3339, parts[0])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.UTC, ts.Location())
|
||||
require.False(t, ts.Before(before))
|
||||
require.False(t, ts.After(after.Add(time.Second)))
|
||||
require.Equal(t, "1.2.3.4", parts[1]) // full IP
|
||||
require.Equal(t, "1.2.3.4/32", parts[2]) // masked to the default IPv4 prefix (/32)
|
||||
require.Equal(t, "429", parts[3]) // HTTP status
|
||||
require.Equal(t, "42901", parts[4]) // ntfy code
|
||||
}
|
||||
|
||||
func TestVisitor_RecordStatus_IPv6MaskedToPrefix(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
conf := NewConfig()
|
||||
conf.BanFile = banFile
|
||||
conf.BanWindow = time.Minute
|
||||
conf.BanThreshold = 1
|
||||
conf.BanWeights = map[string]int{"*": 1}
|
||||
v := newVisitor(conf, nil, nil, netip.MustParseAddr("2001:db8::abcd"), nil)
|
||||
for i := 0; i < 3; i++ {
|
||||
v.recordStatus(v.ip, 429, 42901)
|
||||
}
|
||||
parts := strings.Split(readBanLines(t, banFile)[0], " ")
|
||||
require.Len(t, parts, 5)
|
||||
require.Equal(t, "2001:db8::abcd", parts[1]) // full IPv6 address
|
||||
require.Equal(t, "2001:db8::/64", parts[2]) // masked to the default IPv6 prefix (/64)
|
||||
}
|
||||
|
||||
func TestVisitor_RecordStatus_TierUserAlsoBanned(t *testing.T) {
|
||||
// A tier'd user who keeps hammering the request limiter (42901) is banned by IP like anyone
|
||||
// else -- a persistent 429 stream is abusive regardless of the account behind it. The legit
|
||||
// case (hitting a paid plan/quota limit) is spared by the per-code weights, not by a tier skip:
|
||||
// codes like 42908 are weight 0. So tier does not exempt a visitor from the ban feed.
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
v := newBanTestVisitor(t, banFile, 1, map[string]int{"*": 1})
|
||||
v.user = &user.User{ID: "u_test", Name: "test", Tier: &user.Tier{ID: "ti_test"}}
|
||||
for i := 0; i < 10; i++ {
|
||||
v.recordStatus(v.ip, 429, 42901)
|
||||
}
|
||||
lines := readBanLines(t, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 429 42901"))
|
||||
}
|
||||
|
||||
func TestVisitor_RecordStatus_BansOffendingIPNotVisitorIP(t *testing.T) {
|
||||
// For an account-keyed (tier'd) visitor, one visitor object serves many source IPs and its
|
||||
// stored v.ip is whichever IP created it first -- stale. The ban feed must write the IP of the
|
||||
// request that actually breached, passed in per-call, not the visitor's stored IP.
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
v := newBanTestVisitor(t, banFile, 1, map[string]int{"*": 1}) // v.ip == 1.2.3.4
|
||||
offender := netip.MustParseAddr("5.6.7.8")
|
||||
for i := 0; i < 3; i++ {
|
||||
v.recordStatus(offender, 429, 42901)
|
||||
}
|
||||
parts := strings.Split(readBanLines(t, banFile)[0], " ")
|
||||
require.Equal(t, "5.6.7.8", parts[1]) // the offending request IP, not v.ip (1.2.3.4)
|
||||
require.Equal(t, "5.6.7.8/32", parts[2]) // its prefix, not the visitor's stored-IP prefix
|
||||
}
|
||||
|
||||
func TestVisitor_RecordStatus_DisabledWhenNoBanFile(t *testing.T) {
|
||||
v := newBanTestVisitor(t, "", 10, map[string]int{"*": 1})
|
||||
for i := 0; i < 5; i++ {
|
||||
v.recordStatus(v.ip, 403, 40301) // Feature disabled: must be a no-op, must not panic
|
||||
}
|
||||
require.Nil(t, v.banEmit) // No throttle limiter is created when the feature is off
|
||||
}
|
||||
|
||||
func TestVisitor_RecordStatus_Ignores2xx3xx(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
v := newBanTestVisitor(t, banFile, 3, map[string]int{"*": 1})
|
||||
// Success and redirects must never count toward a ban, even over the threshold -- otherwise a
|
||||
// legit high-volume publisher (lots of 200s) would get banned.
|
||||
for i := 0; i < 20; i++ {
|
||||
v.recordStatus(v.ip, 200, 20000)
|
||||
v.recordStatus(v.ip, 302, 30000)
|
||||
}
|
||||
require.NoFileExists(t, banFile)
|
||||
// A 4xx over the same budget still gets written.
|
||||
for i := 0; i < 5; i++ {
|
||||
v.recordStatus(v.ip, 400, 40001)
|
||||
}
|
||||
lines := readBanLines(t, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 400 40001"))
|
||||
}
|
||||
|
||||
func TestParseBanWeights(t *testing.T) {
|
||||
// Exact codes, a bare 3-digit HTTP status (normalized to a family), an exempt code, and "*".
|
||||
weights, err := ParseBanWeights([]string{"42909:10", "403:2", "42908:0", "*:1"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]int{"42909": 10, "403*": 2, "42908": 0, "*": 1}, weights)
|
||||
|
||||
// A bare 3-digit HTTP status normalizes to its family.
|
||||
weights, err = ParseBanWeights([]string{"429:5"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]int{"429*": 5}, weights)
|
||||
|
||||
// An explicit family key stays as-is.
|
||||
weights, err = ParseBanWeights([]string{"429*:5"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]int{"429*": 5}, weights)
|
||||
|
||||
// Weight 0 is valid and means exempt.
|
||||
weights, err = ParseBanWeights([]string{"42908:0"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]int{"42908": 0}, weights)
|
||||
|
||||
_, err = ParseBanWeights([]string{"401"}) // Missing weight
|
||||
require.Error(t, err)
|
||||
_, err = ParseBanWeights([]string{"401:-1"}) // Negative weight
|
||||
require.Error(t, err)
|
||||
_, err = ParseBanWeights([]string{"401:abc"}) // Non-integer weight
|
||||
require.Error(t, err)
|
||||
_, err = ParseBanWeights([]string{"abc:10"}) // Non-numeric key
|
||||
require.Error(t, err)
|
||||
_, err = ParseBanWeights([]string{"4*3:10"}) // Star not at the end
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestConfig_WeightFor(t *testing.T) {
|
||||
conf := NewConfig()
|
||||
weights, err := ParseBanWeights([]string{"42908:0", "42903:0", "42905:0", "42910:0", "42909:10", "429*:1", "403*:2", "4*:1", "5*:1"})
|
||||
require.NoError(t, err)
|
||||
conf.BanWeights = weights
|
||||
// Longest-match-wins: exact 5-digit beats "429*" beats "4*" beats "*".
|
||||
require.Equal(t, 0, conf.weightFor(42908))
|
||||
require.Equal(t, 10, conf.weightFor(42909))
|
||||
require.Equal(t, 1, conf.weightFor(42901))
|
||||
require.Equal(t, 2, conf.weightFor(40311))
|
||||
require.Equal(t, 1, conf.weightFor(40011))
|
||||
require.Equal(t, 1, conf.weightFor(50312))
|
||||
// A code that matches nothing (no "*" here) returns 0.
|
||||
require.Equal(t, 0, conf.weightFor(30012))
|
||||
}
|
||||
+30
-18
@@ -1,8 +1,8 @@
|
||||
# `template/gotext/` -- vendored `text/template` with an execution deadline
|
||||
# `template/gotext/` -- vendored `text/template` with context cancellation
|
||||
|
||||
This directory is a **verbatim copy of Go's standard-library `text/template` package**, plus one
|
||||
small patch that adds a wall-clock execution deadline. It exists for exactly one reason: to stop
|
||||
**user-supplied** message templates (`Template: yes`, see the [templating docs](https://ntfy.sh/docs/publish/#message-templating))
|
||||
small patch that adds context-aware execution (`ExecuteContext`). It exists for exactly one reason:
|
||||
to stop **user-supplied** message templates (`Template: yes`, see the [templating docs](https://ntfy.sh/docs/publish/#message-templating))
|
||||
from burning CPU.
|
||||
|
||||
- **Source:** Go stdlib `text/template` (+ `internal/fmtsort`), `$(go env GOROOT)/src`
|
||||
@@ -14,7 +14,8 @@ from burning CPU.
|
||||
|
||||
ntfy lets users send a Go template that is rendered against a JSON body. Go's `text/template`
|
||||
**cannot be interrupted mid-execution** -- there is no context, no deadline, no cancellation
|
||||
([golang/go#31107](https://github.com/golang/go/issues/31107) was declined). So a crafted template
|
||||
([golang/go#31107](https://github.com/golang/go/issues/31107) proposed `ExecuteContext` but was
|
||||
declined, over a bundled context-*values* feature, not cancellation itself). So a crafted template
|
||||
with a tight or nested `{{range}}` (e.g. ranging over a large JSON array with a big loop body that
|
||||
writes no output) can run for tens of seconds on a single request. That is a CPU denial of service
|
||||
(GHSA-rhwf-xgc9-m9fp).
|
||||
@@ -22,13 +23,17 @@ writes no output) can run for tens of seconds on a single request. That is a CPU
|
||||
There is no way to add an interrupt from the outside -- the executor's per-node `walk` loop is
|
||||
unexported. The only robust fix is to patch the executor itself. Rather than reach for fragile
|
||||
heuristics (guessing iteration counts, wrapping every function, etc.), we vendor the package and add
|
||||
a **single check inside `walk`**: every ~256 nodes it checks a wall-clock deadline and aborts (via
|
||||
the normal `ExecError` path) if it has passed. This bounds CPU for *any* template shape -- cheap
|
||||
loops and expensive functions alike -- by construction.
|
||||
the cancellation half of #31107 as a patch: `ExecuteContext(ctx, ...)` that aborts with `ctx.Err()`
|
||||
when `ctx` is canceled or its deadline passes. The check is a **single poll inside `walk`** of an
|
||||
atomic flag that a `context.AfterFunc` watcher flips -- so it bounds CPU for *any* template shape
|
||||
(cheap loops and expensive functions alike), it is exact (observed within one node), and it adds no
|
||||
measurable overhead. If #31107's cancellation half ever lands upstream, this fork can be deleted and
|
||||
the call site keeps compiling unchanged.
|
||||
|
||||
The one user-facing execution site (`server/server_template.go` `renderTemplate`) sets the deadline
|
||||
with `SetExecutionDeadline` and maps the resulting error to a `400`. Trusted templates (operator
|
||||
config: Twilio, `cmd/serve.go`) keep using the standard library -- they are not user-supplied.
|
||||
The one user-facing execution site (`server/server_template.go` `renderTemplate`) wraps execution in
|
||||
`context.WithTimeout` and calls `ExecuteContext`, mapping `context.DeadlineExceeded` to a `400`.
|
||||
Trusted templates (operator config: Twilio, `cmd/serve.go`) keep using the standard library -- they
|
||||
are not user-supplied.
|
||||
|
||||
## What's here
|
||||
|
||||
@@ -36,7 +41,7 @@ config: Twilio, `cmd/serve.go`) keep using the standard library -- they are not
|
||||
|------|--------|
|
||||
| `*.go` (`exec.go`, `funcs.go`, `template.go`, `option.go`, `helper.go`, `doc.go`) | verbatim from `$(go env GOROOT)/src/text/template/`, enumerated with `go list` so files added/removed upstream are picked up automatically |
|
||||
| `fmtsort/sort.go` | verbatim from `$(go env GOROOT)/src/internal/fmtsort/` -- `exec.go` needs it, and `internal/...` packages can't be imported from outside GOROOT, so it comes along |
|
||||
| `patches/0001-exec-deadline.patch` | our only real change (see below) |
|
||||
| `patches/0001-exec-context.patch` | our only real change (see below) |
|
||||
| `GENERATED_FROM` | the exact Go version `make update-template` last regenerated this copy from; provenance, written by that target |
|
||||
|
||||
The Go toolchain version this copy is pinned to lives in the repo-root [`.go-version`](../../.go-version)
|
||||
@@ -49,19 +54,26 @@ plain import.
|
||||
## The patch
|
||||
|
||||
`patches/` is a quilt-style ordered series (apply `0001-*`, then `0002-*`, ...). Today there is just
|
||||
`0001-exec-deadline.patch` -- small, purely additive, and touching only `exec.go`/`template.go`:
|
||||
`0001-exec-context.patch` -- small, purely additive, and touching only `exec.go`:
|
||||
|
||||
- adds `deadline`/`steps` fields to the executor `state` and a `deadline` field + a
|
||||
`SetExecutionDeadline(time.Time)` method on `Template`
|
||||
- adds the amortized deadline check at the top of `state.walk`
|
||||
- adds the exported sentinel `ErrExecutionInterrupted` (detect with `errors.Is`)
|
||||
- adds `ctx context.Context` and a shared `cancelled *atomic.Bool` to the executor `state`
|
||||
- adds `ExecuteContext` / `ExecuteTemplateContext`; `Execute` / `ExecuteTemplate` become
|
||||
`context.Background()` wrappers, so their behavior and cost are unchanged
|
||||
- when `ctx.Done() != nil`, arms one `context.AfterFunc` watcher that flips the flag; `walk` polls it
|
||||
per node and aborts via a `cancelError` that `errRecover` strips to the bare `ctx.Err()`
|
||||
(`errors.Is(err, context.DeadlineExceeded)`)
|
||||
|
||||
The flag is a `*atomic.Bool` (not a value) because `walkTemplate` copies `state` for nested
|
||||
`{{template}}` invocations; a shared pointer keeps one flag across all copies and avoids `go vet`
|
||||
copylocks. `template.go` is unchanged -- the context is per-call, not stored on the `Template`.
|
||||
|
||||
Two *mechanical* transforms are applied by `make update-template` with `sed`, **not** the patch --
|
||||
renaming the package to `gotext`, and rewriting the `internal/fmtsort` import to
|
||||
`heckel.io/ntfy/v2/template/gotext/fmtsort`. Keeping them out of the patch means they apply to
|
||||
whatever files `go list` returns, so they survive upstream files being added or removed.
|
||||
whatever files `go list` returns, so they survive upstream files being added or removed. (These two
|
||||
transforms are also the only difference between our patch and the upstream `text/template` diff.)
|
||||
|
||||
Keeping the patch tiny (deadline logic only, on two stable files) is deliberate: it makes re-basing
|
||||
Keeping the patch tiny (cancellation only, on one stable file) is deliberate: it makes re-basing
|
||||
onto a new Go release cheap.
|
||||
|
||||
## Updating (when bumping the Go toolchain)
|
||||
|
||||
+66
-23
@@ -5,14 +5,15 @@
|
||||
package gotext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"text/template/parse"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/template/gotext/fmtsort"
|
||||
)
|
||||
@@ -34,13 +35,13 @@ func initMaxExecDepth() int {
|
||||
// template so that multiple executions of the same template
|
||||
// can execute in parallel.
|
||||
type state struct {
|
||||
tmpl *Template
|
||||
wr io.Writer
|
||||
node parse.Node // current node, for errors
|
||||
vars []variable // push-down stack of variable values.
|
||||
depth int // the height of the stack of executing templates.
|
||||
deadline time.Time // ntfy: wall-clock bail-out; zero means no limit
|
||||
steps int64 // ntfy: node counter for amortized deadline checks
|
||||
tmpl *Template
|
||||
ctx context.Context // ctx-ex: execution context; Execute uses context.Background.
|
||||
wr io.Writer
|
||||
node parse.Node // current node, for errors
|
||||
vars []variable // push-down stack of variable values.
|
||||
depth int // the height of the stack of executing templates.
|
||||
cancelled *atomic.Bool // ctx-ex: shared flag set by the context.AfterFunc watcher; nil if ctx cannot be canceled
|
||||
}
|
||||
|
||||
// variable holds the dynamic value of a variable such as $, $x etc.
|
||||
@@ -135,10 +136,6 @@ func (e ExecError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// ErrExecutionInterrupted is wrapped into the error returned by Execute when a template exceeds the
|
||||
// deadline set via Template.SetExecutionDeadline. Detect it with errors.Is. (ntfy addition)
|
||||
var ErrExecutionInterrupted = errors.New("template execution interrupted")
|
||||
|
||||
// errorf records an ExecError and terminates processing.
|
||||
func (s *state) errorf(format string, args ...any) {
|
||||
name := doublePercent(s.tmpl.Name())
|
||||
@@ -168,6 +165,14 @@ func (s *state) writeError(err error) {
|
||||
})
|
||||
}
|
||||
|
||||
// cancelError is the wrapper type used internally when execution is aborted
|
||||
// because the context is done. Like writeError, it is stripped in errRecover
|
||||
// so the caller receives the original ctx.Err(). It is not an implementation
|
||||
// of error, so it cannot escape from the package as an error value.
|
||||
type cancelError struct {
|
||||
Err error // Original context error.
|
||||
}
|
||||
|
||||
// errRecover is the handler that turns panics into returns from the top
|
||||
// level of Parse.
|
||||
func errRecover(errp *error) {
|
||||
@@ -178,6 +183,8 @@ func errRecover(errp *error) {
|
||||
panic(e)
|
||||
case writeError:
|
||||
*errp = err.Err // Strip the wrapper.
|
||||
case cancelError:
|
||||
*errp = err.Err // Strip the wrapper; return the context error.
|
||||
case ExecError:
|
||||
*errp = err // Keep the wrapper.
|
||||
default:
|
||||
@@ -194,11 +201,19 @@ func errRecover(errp *error) {
|
||||
// A template may be executed safely in parallel, although if parallel
|
||||
// executions share a Writer the output may be interleaved.
|
||||
func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error {
|
||||
return t.ExecuteTemplateContext(context.Background(), wr, name, data)
|
||||
}
|
||||
|
||||
// ExecuteTemplateContext is like [Template.ExecuteTemplate], but aborts and
|
||||
// returns ctx.Err() if ctx is canceled or its deadline is exceeded before
|
||||
// execution completes. See [Template.ExecuteContext] for the cancellation
|
||||
// semantics.
|
||||
func (t *Template) ExecuteTemplateContext(ctx context.Context, wr io.Writer, name string, data any) error {
|
||||
tmpl := t.Lookup(name)
|
||||
if tmpl == nil {
|
||||
return fmt.Errorf("template: no template %q associated with template %q", name, t.name)
|
||||
}
|
||||
return tmpl.Execute(wr, data)
|
||||
return tmpl.ExecuteContext(ctx, wr, data)
|
||||
}
|
||||
|
||||
// Execute applies a parsed template to the specified data object,
|
||||
@@ -212,20 +227,47 @@ func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error {
|
||||
// If data is a [reflect.Value], the template applies to the concrete
|
||||
// value that the reflect.Value holds, as in [fmt.Print].
|
||||
func (t *Template) Execute(wr io.Writer, data any) error {
|
||||
return t.execute(wr, data)
|
||||
return t.executeContext(context.Background(), wr, data)
|
||||
}
|
||||
|
||||
func (t *Template) execute(wr io.Writer, data any) (err error) {
|
||||
// ExecuteContext is like [Template.Execute], but aborts and returns ctx.Err()
|
||||
// (either [context.Canceled] or [context.DeadlineExceeded], retrievable with
|
||||
// [errors.Is]) if ctx is canceled or its deadline is exceeded before execution
|
||||
// completes.
|
||||
//
|
||||
// Cancellation is observed between node evaluations as the template is walked,
|
||||
// so long-running renders -- including tight or nested {{range}} loops that
|
||||
// write no output -- are aborted promptly. A template blocked inside a single
|
||||
// function call is not interrupted until that call returns. Partial results may
|
||||
// already have been written to wr.
|
||||
func (t *Template) ExecuteContext(ctx context.Context, wr io.Writer, data any) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return t.executeContext(ctx, wr, data)
|
||||
}
|
||||
|
||||
func (t *Template) executeContext(ctx context.Context, wr io.Writer, data any) (err error) {
|
||||
defer errRecover(&err)
|
||||
value, ok := data.(reflect.Value)
|
||||
if !ok {
|
||||
value = reflect.ValueOf(data)
|
||||
}
|
||||
state := &state{
|
||||
tmpl: t,
|
||||
wr: wr,
|
||||
vars: []variable{{"$", value}},
|
||||
deadline: t.deadline, // ntfy: wall-clock execution bail-out
|
||||
tmpl: t,
|
||||
ctx: ctx,
|
||||
wr: wr,
|
||||
vars: []variable{{"$", value}},
|
||||
}
|
||||
// If the context can be canceled, watch it with a single context.AfterFunc
|
||||
// callback that flips an atomic flag; walk polls that flag per node (a cheap
|
||||
// monomorphic atomic load) instead of calling ctx.Err() every node.
|
||||
// Contexts that can never be canceled (Background, TODO) have a nil Done
|
||||
// channel, so the default Execute path installs nothing and pays nothing.
|
||||
if ctx.Done() != nil {
|
||||
state.cancelled = new(atomic.Bool)
|
||||
stop := context.AfterFunc(ctx, func() { state.cancelled.Store(true) })
|
||||
defer stop()
|
||||
}
|
||||
if t.Tree == nil || t.Root == nil {
|
||||
state.errorf("%q is an incomplete or empty template", t.Name())
|
||||
@@ -269,10 +311,11 @@ var (
|
||||
// generating output as they go.
|
||||
func (s *state) walk(dot reflect.Value, node parse.Node) {
|
||||
s.at(node)
|
||||
// ntfy: amortized wall-clock bail-out to prevent CPU DoS from user-supplied templates
|
||||
// (tight/nested ranges that never write output). See GHSA-rhwf-xgc9-m9fp.
|
||||
if s.steps++; s.steps&0xff == 0 && !s.deadline.IsZero() && time.Now().After(s.deadline) {
|
||||
s.errorf("execution interrupted: %w", ErrExecutionInterrupted)
|
||||
// Abort if the context has been canceled or its deadline has passed. The
|
||||
// flag is set by the watcher installed in executeContext; observing it here
|
||||
// interrupts any template shape, including loops that write no output.
|
||||
if s.cancelled != nil && s.cancelled.Load() {
|
||||
panic(cancelError{s.ctx.Err()})
|
||||
}
|
||||
switch node := node.(type) {
|
||||
case *parse.ActionNode:
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
--- a/exec.go 2026-07-10 01:31:35.188129862 +0200
|
||||
+++ b/exec.go 2026-07-10 01:31:35.189129894 +0200
|
||||
@@ -5,14 +5,17 @@
|
||||
package gotext
|
||||
|
||||
import (
|
||||
+ "context"
|
||||
"errors"
|
||||
"fmt"
|
||||
- "heckel.io/ntfy/v2/template/gotext/fmtsort"
|
||||
"io"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
+ "sync/atomic"
|
||||
"text/template/parse"
|
||||
+
|
||||
+ "heckel.io/ntfy/v2/template/gotext/fmtsort"
|
||||
)
|
||||
|
||||
// maxExecDepth specifies the maximum stack depth of templates within
|
||||
@@ -32,11 +35,13 @@
|
||||
// template so that multiple executions of the same template
|
||||
// can execute in parallel.
|
||||
type state struct {
|
||||
- tmpl *Template
|
||||
- wr io.Writer
|
||||
- node parse.Node // current node, for errors
|
||||
- vars []variable // push-down stack of variable values.
|
||||
- depth int // the height of the stack of executing templates.
|
||||
+ tmpl *Template
|
||||
+ ctx context.Context // ctx-ex: execution context; Execute uses context.Background.
|
||||
+ wr io.Writer
|
||||
+ node parse.Node // current node, for errors
|
||||
+ vars []variable // push-down stack of variable values.
|
||||
+ depth int // the height of the stack of executing templates.
|
||||
+ cancelled *atomic.Bool // ctx-ex: shared flag set by the context.AfterFunc watcher; nil if ctx cannot be canceled
|
||||
}
|
||||
|
||||
// variable holds the dynamic value of a variable such as $, $x etc.
|
||||
@@ -160,6 +165,14 @@
|
||||
})
|
||||
}
|
||||
|
||||
+// cancelError is the wrapper type used internally when execution is aborted
|
||||
+// because the context is done. Like writeError, it is stripped in errRecover
|
||||
+// so the caller receives the original ctx.Err(). It is not an implementation
|
||||
+// of error, so it cannot escape from the package as an error value.
|
||||
+type cancelError struct {
|
||||
+ Err error // Original context error.
|
||||
+}
|
||||
+
|
||||
// errRecover is the handler that turns panics into returns from the top
|
||||
// level of Parse.
|
||||
func errRecover(errp *error) {
|
||||
@@ -170,6 +183,8 @@
|
||||
panic(e)
|
||||
case writeError:
|
||||
*errp = err.Err // Strip the wrapper.
|
||||
+ case cancelError:
|
||||
+ *errp = err.Err // Strip the wrapper; return the context error.
|
||||
case ExecError:
|
||||
*errp = err // Keep the wrapper.
|
||||
default:
|
||||
@@ -186,11 +201,19 @@
|
||||
// A template may be executed safely in parallel, although if parallel
|
||||
// executions share a Writer the output may be interleaved.
|
||||
func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error {
|
||||
+ return t.ExecuteTemplateContext(context.Background(), wr, name, data)
|
||||
+}
|
||||
+
|
||||
+// ExecuteTemplateContext is like [Template.ExecuteTemplate], but aborts and
|
||||
+// returns ctx.Err() if ctx is canceled or its deadline is exceeded before
|
||||
+// execution completes. See [Template.ExecuteContext] for the cancellation
|
||||
+// semantics.
|
||||
+func (t *Template) ExecuteTemplateContext(ctx context.Context, wr io.Writer, name string, data any) error {
|
||||
tmpl := t.Lookup(name)
|
||||
if tmpl == nil {
|
||||
return fmt.Errorf("template: no template %q associated with template %q", name, t.name)
|
||||
}
|
||||
- return tmpl.Execute(wr, data)
|
||||
+ return tmpl.ExecuteContext(ctx, wr, data)
|
||||
}
|
||||
|
||||
// Execute applies a parsed template to the specified data object,
|
||||
@@ -204,10 +227,27 @@
|
||||
// If data is a [reflect.Value], the template applies to the concrete
|
||||
// value that the reflect.Value holds, as in [fmt.Print].
|
||||
func (t *Template) Execute(wr io.Writer, data any) error {
|
||||
- return t.execute(wr, data)
|
||||
+ return t.executeContext(context.Background(), wr, data)
|
||||
}
|
||||
|
||||
-func (t *Template) execute(wr io.Writer, data any) (err error) {
|
||||
+// ExecuteContext is like [Template.Execute], but aborts and returns ctx.Err()
|
||||
+// (either [context.Canceled] or [context.DeadlineExceeded], retrievable with
|
||||
+// [errors.Is]) if ctx is canceled or its deadline is exceeded before execution
|
||||
+// completes.
|
||||
+//
|
||||
+// Cancellation is observed between node evaluations as the template is walked,
|
||||
+// so long-running renders -- including tight or nested {{range}} loops that
|
||||
+// write no output -- are aborted promptly. A template blocked inside a single
|
||||
+// function call is not interrupted until that call returns. Partial results may
|
||||
+// already have been written to wr.
|
||||
+func (t *Template) ExecuteContext(ctx context.Context, wr io.Writer, data any) error {
|
||||
+ if err := ctx.Err(); err != nil {
|
||||
+ return err
|
||||
+ }
|
||||
+ return t.executeContext(ctx, wr, data)
|
||||
+}
|
||||
+
|
||||
+func (t *Template) executeContext(ctx context.Context, wr io.Writer, data any) (err error) {
|
||||
defer errRecover(&err)
|
||||
value, ok := data.(reflect.Value)
|
||||
if !ok {
|
||||
@@ -215,9 +255,20 @@
|
||||
}
|
||||
state := &state{
|
||||
tmpl: t,
|
||||
+ ctx: ctx,
|
||||
wr: wr,
|
||||
vars: []variable{{"$", value}},
|
||||
}
|
||||
+ // If the context can be canceled, watch it with a single context.AfterFunc
|
||||
+ // callback that flips an atomic flag; walk polls that flag per node (a cheap
|
||||
+ // monomorphic atomic load) instead of calling ctx.Err() every node.
|
||||
+ // Contexts that can never be canceled (Background, TODO) have a nil Done
|
||||
+ // channel, so the default Execute path installs nothing and pays nothing.
|
||||
+ if ctx.Done() != nil {
|
||||
+ state.cancelled = new(atomic.Bool)
|
||||
+ stop := context.AfterFunc(ctx, func() { state.cancelled.Store(true) })
|
||||
+ defer stop()
|
||||
+ }
|
||||
if t.Tree == nil || t.Root == nil {
|
||||
state.errorf("%q is an incomplete or empty template", t.Name())
|
||||
}
|
||||
@@ -260,6 +311,12 @@
|
||||
// generating output as they go.
|
||||
func (s *state) walk(dot reflect.Value, node parse.Node) {
|
||||
s.at(node)
|
||||
+ // Abort if the context has been canceled or its deadline has passed. The
|
||||
+ // flag is set by the watcher installed in executeContext; observing it here
|
||||
+ // interrupts any template shape, including loops that write no output.
|
||||
+ if s.cancelled != nil && s.cancelled.Load() {
|
||||
+ panic(cancelError{s.ctx.Err()})
|
||||
+ }
|
||||
switch node := node.(type) {
|
||||
case *parse.ActionNode:
|
||||
// Do not pop variables so they persist until next end.
|
||||
@@ -1,113 +0,0 @@
|
||||
diff -ruN a/exec.go b/exec.go
|
||||
--- a/exec.go 2026-07-08 21:46:30.952555712 +0200
|
||||
+++ b/exec.go 2026-07-08 21:46:30.953912265 +0200
|
||||
@@ -7,12 +7,14 @@
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
- "heckel.io/ntfy/v2/template/gotext/fmtsort"
|
||||
"io"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"text/template/parse"
|
||||
+ "time"
|
||||
+
|
||||
+ "heckel.io/ntfy/v2/template/gotext/fmtsort"
|
||||
)
|
||||
|
||||
// maxExecDepth specifies the maximum stack depth of templates within
|
||||
@@ -32,11 +34,13 @@
|
||||
// template so that multiple executions of the same template
|
||||
// can execute in parallel.
|
||||
type state struct {
|
||||
- tmpl *Template
|
||||
- wr io.Writer
|
||||
- node parse.Node // current node, for errors
|
||||
- vars []variable // push-down stack of variable values.
|
||||
- depth int // the height of the stack of executing templates.
|
||||
+ tmpl *Template
|
||||
+ wr io.Writer
|
||||
+ node parse.Node // current node, for errors
|
||||
+ vars []variable // push-down stack of variable values.
|
||||
+ depth int // the height of the stack of executing templates.
|
||||
+ deadline time.Time // ntfy: wall-clock bail-out; zero means no limit
|
||||
+ steps int64 // ntfy: node counter for amortized deadline checks
|
||||
}
|
||||
|
||||
// variable holds the dynamic value of a variable such as $, $x etc.
|
||||
@@ -131,6 +135,10 @@
|
||||
return e.Err
|
||||
}
|
||||
|
||||
+// ErrExecutionInterrupted is wrapped into the error returned by Execute when a template exceeds the
|
||||
+// deadline set via Template.SetExecutionDeadline. Detect it with errors.Is. (ntfy addition)
|
||||
+var ErrExecutionInterrupted = errors.New("template execution interrupted")
|
||||
+
|
||||
// errorf records an ExecError and terminates processing.
|
||||
func (s *state) errorf(format string, args ...any) {
|
||||
name := doublePercent(s.tmpl.Name())
|
||||
@@ -214,9 +222,10 @@
|
||||
value = reflect.ValueOf(data)
|
||||
}
|
||||
state := &state{
|
||||
- tmpl: t,
|
||||
- wr: wr,
|
||||
- vars: []variable{{"$", value}},
|
||||
+ tmpl: t,
|
||||
+ wr: wr,
|
||||
+ vars: []variable{{"$", value}},
|
||||
+ deadline: t.deadline, // ntfy: wall-clock execution bail-out
|
||||
}
|
||||
if t.Tree == nil || t.Root == nil {
|
||||
state.errorf("%q is an incomplete or empty template", t.Name())
|
||||
@@ -260,6 +269,11 @@
|
||||
// generating output as they go.
|
||||
func (s *state) walk(dot reflect.Value, node parse.Node) {
|
||||
s.at(node)
|
||||
+ // ntfy: amortized wall-clock bail-out to prevent CPU DoS from user-supplied templates
|
||||
+ // (tight/nested ranges that never write output). See GHSA-rhwf-xgc9-m9fp.
|
||||
+ if s.steps++; s.steps&0xff == 0 && !s.deadline.IsZero() && time.Now().After(s.deadline) {
|
||||
+ s.errorf("execution interrupted: %w", ErrExecutionInterrupted)
|
||||
+ }
|
||||
switch node := node.(type) {
|
||||
case *parse.ActionNode:
|
||||
// Do not pop variables so they persist until next end.
|
||||
diff -ruN a/template.go b/template.go
|
||||
--- a/template.go 2026-07-08 21:46:30.952848382 +0200
|
||||
+++ b/template.go 2026-07-08 21:46:30.953952891 +0200
|
||||
@@ -9,13 +9,15 @@
|
||||
"reflect"
|
||||
"sync"
|
||||
"text/template/parse"
|
||||
+ "time"
|
||||
)
|
||||
|
||||
// common holds the information shared by related templates.
|
||||
type common struct {
|
||||
- tmpl map[string]*Template // Map from name to defined templates.
|
||||
- muTmpl sync.RWMutex // protects tmpl
|
||||
- option option
|
||||
+ tmpl map[string]*Template // Map from name to defined templates.
|
||||
+ muTmpl sync.RWMutex // protects tmpl
|
||||
+ option option
|
||||
+ deadline time.Time // ntfy: wall-clock execution deadline (zero = none)
|
||||
// We use two maps, one for parsing and one for execution.
|
||||
// This separation makes the API cleaner since it doesn't
|
||||
// expose reflection to the client.
|
||||
@@ -49,6 +51,15 @@
|
||||
return t.name
|
||||
}
|
||||
|
||||
+// SetExecutionDeadline sets a wall-clock deadline after which Execute aborts with an error wrapping
|
||||
+// ErrExecutionInterrupted. A zero deadline disables the limit. It bounds CPU for untrusted templates
|
||||
+// that text/template cannot otherwise interrupt. (ntfy addition, see GHSA-rhwf-xgc9-m9fp.)
|
||||
+func (t *Template) SetExecutionDeadline(deadline time.Time) *Template {
|
||||
+ t.init()
|
||||
+ t.deadline = deadline
|
||||
+ return t
|
||||
+}
|
||||
+
|
||||
// New allocates a new, undefined template associated with the given one and with the same
|
||||
// delimiters. The association, which is transitive, allows one template to
|
||||
// invoke another with a {{template}} action.
|
||||
@@ -9,15 +9,13 @@ import (
|
||||
"reflect"
|
||||
"sync"
|
||||
"text/template/parse"
|
||||
"time"
|
||||
)
|
||||
|
||||
// common holds the information shared by related templates.
|
||||
type common struct {
|
||||
tmpl map[string]*Template // Map from name to defined templates.
|
||||
muTmpl sync.RWMutex // protects tmpl
|
||||
option option
|
||||
deadline time.Time // ntfy: wall-clock execution deadline (zero = none)
|
||||
tmpl map[string]*Template // Map from name to defined templates.
|
||||
muTmpl sync.RWMutex // protects tmpl
|
||||
option option
|
||||
// We use two maps, one for parsing and one for execution.
|
||||
// This separation makes the API cleaner since it doesn't
|
||||
// expose reflection to the client.
|
||||
@@ -51,15 +49,6 @@ func (t *Template) Name() string {
|
||||
return t.name
|
||||
}
|
||||
|
||||
// SetExecutionDeadline sets a wall-clock deadline after which Execute aborts with an error wrapping
|
||||
// ErrExecutionInterrupted. A zero deadline disables the limit. It bounds CPU for untrusted templates
|
||||
// that text/template cannot otherwise interrupt. (ntfy addition, see GHSA-rhwf-xgc9-m9fp.)
|
||||
func (t *Template) SetExecutionDeadline(deadline time.Time) *Template {
|
||||
t.init()
|
||||
t.deadline = deadline
|
||||
return t
|
||||
}
|
||||
|
||||
// New allocates a new, undefined template associated with the given one and with the same
|
||||
// delimiters. The association, which is transitive, allows one template to
|
||||
// invoke another with a {{template}} action.
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// Package twilio talks to the Twilio API to make phone calls (for the "Call" feature) and to
|
||||
// verify phone numbers. It holds the Twilio configuration, so that this functionality is
|
||||
// decoupled from the ntfy server.
|
||||
package twilio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
tagTwilio = "twilio"
|
||||
)
|
||||
|
||||
// Client is the Twilio API client
|
||||
type Client struct {
|
||||
config *Config
|
||||
}
|
||||
|
||||
// NewClient creates a new Twilio Client with the given config
|
||||
func NewClient(config *Config) *Client {
|
||||
return &Client{config: config}
|
||||
}
|
||||
|
||||
// Call calls the Twilio API to make a phone call to the given phone number, using the given data
|
||||
func (c *Client) Call(to string, data *CallData) error {
|
||||
tmpl := defaultCallFormatTemplate
|
||||
if c.config.CallFormat != nil {
|
||||
tmpl = c.config.CallFormat
|
||||
}
|
||||
var bodyBuf bytes.Buffer
|
||||
if err := tmpl.Execute(&bodyBuf, data.escaped()); err != nil {
|
||||
log.Tag(tagTwilio).Err(err).Warn("Error executing Twilio call format template")
|
||||
return err
|
||||
}
|
||||
body := bodyBuf.String()
|
||||
form := url.Values{}
|
||||
form.Set("From", c.config.PhoneNumber)
|
||||
form.Set("To", to)
|
||||
form.Set("Twiml", body)
|
||||
ev := log.Tag(tagTwilio).
|
||||
Field("twilio_to", to).
|
||||
FieldIf("twilio_body", body, log.TraceLevel).
|
||||
Debug("Sending Twilio request")
|
||||
requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/Calls.json", c.config.CallsBaseURL, c.config.Account)
|
||||
response, code, err := c.request(requestURL, form)
|
||||
if err != nil {
|
||||
ev.Field("twilio_response", response).Err(err).Warn("Error sending Twilio request")
|
||||
return err
|
||||
} else if !success(code) {
|
||||
// Twilio rejects calls with a 4xx, e.g. for an invalid phone number, or if the account
|
||||
// is out of funds. Without this check, a rejected call would be counted as a success.
|
||||
ev.Field("twilio_status", code).Field("twilio_response", response).Warn("Twilio call failed with status code %d", code)
|
||||
return fmt.Errorf("twilio call failed with status code %d", code)
|
||||
}
|
||||
ev.FieldIf("twilio_response", response, log.TraceLevel).Debug("Received successful Twilio response")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify calls the Twilio Verify API to send a verification code to the given phone
|
||||
// number, via the given channel ("sms" or "call")
|
||||
func (c *Client) Verify(phoneNumber, channel string) error {
|
||||
ev := log.Tag(tagTwilio).Field("twilio_to", phoneNumber).Field("twilio_channel", channel).Debug("Sending phone verification")
|
||||
form := url.Values{}
|
||||
form.Set("To", phoneNumber)
|
||||
form.Set("Channel", channel)
|
||||
requestURL := fmt.Sprintf("%s/v2/Services/%s/Verifications", c.config.VerifyBaseURL, c.config.VerifyService)
|
||||
response, code, err := c.request(requestURL, form)
|
||||
if err != nil {
|
||||
ev.Err(err).Warn("Error sending Twilio phone verification request")
|
||||
return err
|
||||
} else if !success(code) {
|
||||
// Without this check, a rejected verification would look like a success to the caller,
|
||||
// and the user would be told to wait for an SMS that was never sent.
|
||||
ev.Field("twilio_status", code).Field("twilio_response", response).Warn("Twilio phone verification request failed with status code %d", code)
|
||||
return fmt.Errorf("twilio phone verification request failed with status code %d", code)
|
||||
}
|
||||
ev.FieldIf("twilio_response", response, log.TraceLevel).Debug("Received Twilio phone verification response")
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckVerify calls the Twilio Verify API to check the verification code for the given
|
||||
// phone number. It returns ErrVerificationExpired if the code has expired or never existed.
|
||||
func (c *Client) CheckVerify(phoneNumber, code string) error {
|
||||
ev := log.Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification")
|
||||
form := url.Values{}
|
||||
form.Set("To", phoneNumber)
|
||||
form.Set("Code", code)
|
||||
requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", c.config.VerifyBaseURL, c.config.VerifyService)
|
||||
req, err := c.newRequest(requestURL, form)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if ev.IsTrace() {
|
||||
response, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ev.Field("twilio_response", string(response))
|
||||
}
|
||||
ev.Warn("Twilio phone verification failed with status code %d", resp.StatusCode)
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return ErrVerificationExpired
|
||||
}
|
||||
return fmt.Errorf("twilio phone verification failed with status code %d", resp.StatusCode)
|
||||
}
|
||||
response, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ev.IsTrace() {
|
||||
ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response")
|
||||
} else if ev.IsDebug() {
|
||||
ev.Debug("Received successful Twilio phone verification response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// request POSTs the given form to the given Twilio API URL, and returns the raw response body
|
||||
// and status code. It does not treat a non-2xx status code as an error; that is up to the
|
||||
// caller. The response body is returned even if the request failed, so that it can be logged.
|
||||
func (c *Client) request(requestURL string, form url.Values) (string, int, error) {
|
||||
req, err := c.newRequest(requestURL, form)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
response, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", resp.StatusCode, err
|
||||
}
|
||||
return string(response), resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// success reports whether the given HTTP status code indicates success. Note that the Twilio
|
||||
// Calls API returns 201 Created (not 200 OK) for a successfully queued call.
|
||||
func success(code int) bool {
|
||||
return code >= 200 && code <= 299
|
||||
}
|
||||
|
||||
// newRequest creates a form-encoded POST request against the Twilio API, with the auth and
|
||||
// User-Agent headers set
|
||||
func (c *Client) newRequest(requestURL string, form url.Values) (*http.Request, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "ntfy/"+c.config.BuildVersion)
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", util.BasicAuth(c.config.Account, c.config.AuthToken))
|
||||
return req, nil
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package twilio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"text/template"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestClient_Call_Success(t *testing.T) {
|
||||
var body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
|
||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
||||
require.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type"))
|
||||
require.Equal(t, "ntfy/1.2.3", r.Header.Get("User-Agent"))
|
||||
b, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
body = string(b)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
require.Nil(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there", Sender: "phil"}))
|
||||
|
||||
form, err := url.ParseQuery(body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "+1234567890", form.Get("From"))
|
||||
require.Equal(t, "+11122233344", form.Get("To"))
|
||||
require.Contains(t, form.Get("Twiml"), "You have a message from notify on topic mytopic. Message:")
|
||||
require.Contains(t, form.Get("Twiml"), "hi there")
|
||||
require.Contains(t, form.Get("Twiml"), "This message was sent by user phil.")
|
||||
}
|
||||
|
||||
// TestClient_Call_EscapesXML ensures that user-controlled fields cannot break out of the
|
||||
// TwiML document, i.e. that a message containing XML is escaped rather than interpreted
|
||||
func TestClient_Call_EscapesXML(t *testing.T) {
|
||||
var body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
body = string(b)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
data := &CallData{
|
||||
Topic: "mytopic",
|
||||
Message: `</Say><Say>evil</Say>`,
|
||||
Tags: []string{"<tag>"},
|
||||
Sender: `phil & "friends"`,
|
||||
}
|
||||
require.Nil(t, c.Call("+11122233344", data))
|
||||
|
||||
form, err := url.ParseQuery(body)
|
||||
require.Nil(t, err)
|
||||
twiml := form.Get("Twiml")
|
||||
require.NotContains(t, twiml, "<Say>evil</Say>")
|
||||
require.Contains(t, twiml, "</Say><Say>evil</Say>")
|
||||
require.Contains(t, twiml, "phil & "friends"")
|
||||
// The caller's data must not be modified by the escaping
|
||||
require.Equal(t, `</Say><Say>evil</Say>`, data.Message)
|
||||
require.Equal(t, []string{"<tag>"}, data.Tags)
|
||||
}
|
||||
|
||||
func TestClient_Call_CustomCallFormat(t *testing.T) {
|
||||
var body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
body = string(b)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
conf := testConfig(server.URL)
|
||||
conf.CallFormat = template.Must(template.New("twiml").Parse(`<Response><Say>{{.Message}} von {{.Sender}}</Say></Response>`))
|
||||
c := NewClient(conf)
|
||||
require.Nil(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there", Sender: "phil"}))
|
||||
|
||||
form, err := url.ParseQuery(body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "<Response><Say>hi there von phil</Say></Response>", form.Get("Twiml"))
|
||||
}
|
||||
|
||||
// TestClient_Call_RendersAllFields covers the fields that the default TwiML template does not
|
||||
// use, i.e. Title, Priority and Tags, including the escaping of every tag
|
||||
func TestClient_Call_RendersAllFields(t *testing.T) {
|
||||
var body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
body = string(b)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
conf := testConfig(server.URL)
|
||||
conf.CallFormat = template.Must(template.New("twiml").Parse(`<Response><Say>{{.Title}}/{{.Priority}}{{range .Tags}}/{{.}}{{end}}</Say></Response>`))
|
||||
c := NewClient(conf)
|
||||
data := &CallData{
|
||||
Topic: "mytopic",
|
||||
Title: "a <title>",
|
||||
Priority: 5,
|
||||
Tags: []string{"<one>", "two & three"},
|
||||
}
|
||||
require.Nil(t, c.Call("+11122233344", data))
|
||||
|
||||
form, err := url.ParseQuery(body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "<Response><Say>a <title>/5/<one>/two & three</Say></Response>", form.Get("Twiml"))
|
||||
}
|
||||
|
||||
func TestClient_Call_TemplateError(t *testing.T) {
|
||||
conf := testConfig("http://dummy.invalid")
|
||||
conf.CallFormat = template.Must(template.New("twiml").Parse(`{{.DoesNotExist}}`))
|
||||
c := NewClient(conf)
|
||||
require.Error(t, c.Call("+11122233344", &CallData{Topic: "mytopic"}))
|
||||
}
|
||||
|
||||
// TestClient_Call_Created ensures that a 201 Created is treated as a success. The Twilio Calls
|
||||
// API returns 201 (not 200) for a successfully queued call, so this must not be an error.
|
||||
func TestClient_Call_Created(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write([]byte(`{"status":"queued"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
require.Nil(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there"}))
|
||||
}
|
||||
|
||||
// TestClient_Call_TwilioError ensures that a non-2xx response from Twilio is returned as an
|
||||
// error, so that the server counts it as a failure instead of a success. Twilio rejects calls
|
||||
// with a 4xx, e.g. for an invalid "To" number, or when the account is out of funds.
|
||||
func TestClient_Call_TwilioError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"code":21211,"message":"Invalid 'To' Phone Number: +invalid"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
err := c.Call("+invalid", &CallData{Topic: "mytopic", Message: "hi there"})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "400")
|
||||
}
|
||||
|
||||
func TestClient_Call_TwilioServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
require.Error(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there"}))
|
||||
}
|
||||
|
||||
// TestClient_Call_TransportError ensures that a call to an unreachable Twilio API returns an
|
||||
// error, so that the server can count it as a failure
|
||||
func TestClient_Call_TransportError(t *testing.T) {
|
||||
c := NewClient(testConfig(closedServerURL(t)))
|
||||
require.Error(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there"}))
|
||||
}
|
||||
|
||||
func TestClient_Call_InvalidBaseURL(t *testing.T) {
|
||||
c := NewClient(testConfig("://invalid"))
|
||||
require.Error(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there"}))
|
||||
}
|
||||
|
||||
// TestClient_Verify_Created ensures that a 201 Created is treated as a success. The Twilio
|
||||
// Verify API returns 201 (not 200) when it creates a verification, so this must not be an error.
|
||||
func TestClient_Verify_Created(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write([]byte(`{"status":"pending"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
require.Nil(t, c.Verify("+12223334444", "sms"))
|
||||
}
|
||||
|
||||
// TestClient_Verify_TwilioError ensures that a non-2xx response from Twilio is returned as an
|
||||
// error. Without this, no SMS is sent, but the user is still told to check their phone.
|
||||
func TestClient_Verify_TwilioError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"code":60200,"message":"Invalid parameter"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
err := c.Verify("+12223334444", "sms")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "400")
|
||||
}
|
||||
|
||||
func TestClient_Verify_Unauthorized(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
require.Error(t, c.Verify("+12223334444", "sms"))
|
||||
}
|
||||
|
||||
func TestClient_Verify_TransportError(t *testing.T) {
|
||||
c := NewClient(testConfig(closedServerURL(t)))
|
||||
require.Error(t, c.Verify("+12223334444", "sms"))
|
||||
}
|
||||
|
||||
func TestClient_CheckVerify_TransportError(t *testing.T) {
|
||||
c := NewClient(testConfig(closedServerURL(t)))
|
||||
err := c.CheckVerify("+12223334444", "123456")
|
||||
require.Error(t, err)
|
||||
require.False(t, errors.Is(err, ErrVerificationExpired))
|
||||
}
|
||||
|
||||
func TestClient_Verify_Success(t *testing.T) {
|
||||
var body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/v2/Services/VA1234567890/Verifications", r.URL.Path)
|
||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
||||
b, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
body = string(b)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
require.Nil(t, c.Verify("+12223334444", "sms"))
|
||||
require.Equal(t, "Channel=sms&To=%2B12223334444", body)
|
||||
}
|
||||
|
||||
func TestClient_CheckVerify_Success(t *testing.T) {
|
||||
var body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/v2/Services/VA1234567890/VerificationCheck", r.URL.Path)
|
||||
b, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
body = string(b)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
require.Nil(t, c.CheckVerify("+12223334444", "123456"))
|
||||
require.Equal(t, "Code=123456&To=%2B12223334444", body)
|
||||
}
|
||||
|
||||
// TestClient_CheckVerify_Expired ensures that a 404 from the Twilio Verify API is
|
||||
// mapped to ErrVerificationExpired, which the server turns into an HTTP 410
|
||||
func TestClient_CheckVerify_Expired(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
err := c.CheckVerify("+12223334444", "123456")
|
||||
require.True(t, errors.Is(err, ErrVerificationExpired))
|
||||
}
|
||||
|
||||
func TestClient_CheckVerify_OtherError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
err := c.CheckVerify("+12223334444", "123456")
|
||||
require.Error(t, err)
|
||||
require.False(t, errors.Is(err, ErrVerificationExpired))
|
||||
}
|
||||
|
||||
// closedServerURL returns the URL of a server that is not listening anymore, to simulate an
|
||||
// unreachable Twilio API
|
||||
func closedServerURL(t *testing.T) string {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("Should not be called")
|
||||
}))
|
||||
server.Close()
|
||||
return server.URL
|
||||
}
|
||||
|
||||
func testConfig(baseURL string) *Config {
|
||||
return &Config{
|
||||
Account: "AC1234567890",
|
||||
AuthToken: "AAEAA1234567890",
|
||||
PhoneNumber: "+1234567890",
|
||||
CallsBaseURL: baseURL,
|
||||
VerifyBaseURL: baseURL,
|
||||
VerifyService: "VA1234567890",
|
||||
BuildVersion: "1.2.3",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package twilio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// ErrVerificationExpired is returned by CheckVerify if the verification code has
|
||||
// expired, or if it never existed in the first place
|
||||
var ErrVerificationExpired = errors.New("phone number verification expired or does not exist")
|
||||
|
||||
// Config holds the Twilio configuration for the client
|
||||
type Config struct {
|
||||
Account string // Twilio account SID, e.g. AC123...
|
||||
AuthToken string // Twilio auth token
|
||||
PhoneNumber string // Twilio number to use for outgoing calls
|
||||
CallsBaseURL string // Base URL of the Twilio Calls API
|
||||
VerifyBaseURL string // Base URL of the Twilio Verify API
|
||||
VerifyService string // Twilio Verify service ID, e.g. VA123...
|
||||
CallFormat *template.Template // TwiML template for calls; if nil, defaultCallFormatTemplate is used
|
||||
BuildVersion string // ntfy version, used for the User-Agent header
|
||||
}
|
||||
|
||||
// defaultCallFormatTemplate is the default TwiML template used for Twilio calls.
|
||||
// It can be overridden in the server configuration's twilio-call-format field.
|
||||
//
|
||||
// The format uses Go template syntax with the following fields:
|
||||
// {{.Topic}}, {{.Title}}, {{.Message}}, {{.Priority}}, {{.Tags}}, {{.Sender}}
|
||||
// String fields are automatically XML-escaped.
|
||||
var defaultCallFormatTemplate = template.Must(template.New("twiml").Parse(`
|
||||
<Response>
|
||||
<Pause length="1"/>
|
||||
<Say loop="3">
|
||||
You have a message from notify on topic {{.Topic}}. Message:
|
||||
<break time="1s"/>
|
||||
{{.Message}}
|
||||
<break time="1s"/>
|
||||
End of message.
|
||||
<break time="1s"/>
|
||||
This message was sent by user {{.Sender}}. It will be repeated three times.
|
||||
To unsubscribe from calls like this, remove your phone number in the notify web app.
|
||||
<break time="3s"/>
|
||||
</Say>
|
||||
<Say>Goodbye.</Say>
|
||||
</Response>`))
|
||||
|
||||
// CallData holds the data passed to the Twilio call format template. String fields are
|
||||
// XML-escaped before the template is executed, so callers pass them unescaped.
|
||||
type CallData struct {
|
||||
Topic string
|
||||
Title string
|
||||
Message string
|
||||
Priority int
|
||||
Tags []string
|
||||
Sender string
|
||||
}
|
||||
|
||||
// escaped returns a copy of the call data with all string fields XML-escaped
|
||||
func (d *CallData) escaped() *CallData {
|
||||
tags := make([]string, len(d.Tags))
|
||||
for i, tag := range d.Tags {
|
||||
tags[i] = xmlEscapeText(tag)
|
||||
}
|
||||
return &CallData{
|
||||
Topic: xmlEscapeText(d.Topic),
|
||||
Title: xmlEscapeText(d.Title),
|
||||
Message: xmlEscapeText(d.Message),
|
||||
Priority: d.Priority,
|
||||
Tags: tags,
|
||||
Sender: xmlEscapeText(d.Sender),
|
||||
}
|
||||
}
|
||||
|
||||
func xmlEscapeText(text string) string {
|
||||
var buf bytes.Buffer
|
||||
_ = xml.EscapeText(&buf, []byte(text))
|
||||
return buf.String()
|
||||
}
|
||||
+27
-9
@@ -33,6 +33,7 @@ const (
|
||||
tokenLength = 32
|
||||
tokenMaxCount = 60 // Only keep this many tokens in the table per user
|
||||
tag = "user_manager"
|
||||
schemaStore = "user" // Store name in the schema_version table (see db/schema)
|
||||
)
|
||||
|
||||
// Default constants that may be overridden by configs
|
||||
@@ -153,24 +154,26 @@ func (a *Manager) asyncExpiredMagicLinkReapLoop(interval time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate checks username and password and returns a User if correct, and the user has not been
|
||||
// marked as deleted. The method returns in constant-ish time, regardless of whether the user exists or
|
||||
// the password is correct or incorrect.
|
||||
func (a *Manager) Authenticate(username, password string) (*User, error) {
|
||||
if username == Everyone {
|
||||
// Authenticate checks a login identifier (a username or a verified primary email) and password, and
|
||||
// returns a User if correct and not marked as deleted. The identifier is resolved in a single query
|
||||
// via userByNameOrEmail, so a user can log in with either their username or their primary
|
||||
// email. The method returns in constant-ish time (one query, one bcrypt compare), regardless of
|
||||
// whether the identifier exists or the password is correct or incorrect.
|
||||
func (a *Manager) Authenticate(identifier, password string) (*User, error) {
|
||||
if identifier == Everyone {
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
user, err := a.User(username)
|
||||
user, err := a.userByNameOrEmail(identifier)
|
||||
if err != nil {
|
||||
log.Tag(tag).Field("user_name", username).Err(err).Trace("Authentication of user failed (1)")
|
||||
log.Tag(tag).Field("user_name", identifier).Err(err).Trace("Authentication of user failed (1)")
|
||||
bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks"))
|
||||
return nil, ErrUnauthenticated
|
||||
} else if user.Deleted {
|
||||
log.Tag(tag).Field("user_name", username).Trace("Authentication of user failed (2): user marked deleted")
|
||||
log.Tag(tag).Field("user_name", identifier).Trace("Authentication of user failed (2): user marked deleted")
|
||||
bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks"))
|
||||
return nil, ErrUnauthenticated
|
||||
} else if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil {
|
||||
log.Tag(tag).Field("user_name", username).Err(err).Trace("Authentication of user failed (3)")
|
||||
log.Tag(tag).Field("user_name", identifier).Err(err).Trace("Authentication of user failed (3)")
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
return user, nil
|
||||
@@ -532,6 +535,21 @@ func (a *Manager) UserByEmailOrUsername(identifier string) (*User, error) {
|
||||
return a.User(identifier)
|
||||
}
|
||||
|
||||
// userByNameOrEmail resolves a login identifier to a single user in one query, matching it
|
||||
// against the username first and a verified primary email address second. This is the INVERSE
|
||||
// precedence of UserByEmailOrUsername (used by password reset): at login a freely-chosen username
|
||||
// must win over a look-alike primary email, so a user whose username happens to equal another
|
||||
// account's email is not locked out of their own account. Because Authenticate still gates the match
|
||||
// on a password check, returning the username owner here never grants access to the email owner's
|
||||
// account. Returns ErrUserNotFound if neither matches.
|
||||
func (a *Manager) userByNameOrEmail(identifier string) (*User, error) {
|
||||
rows, err := a.db.Query(a.queries.selectUserByNameOrPrimaryEmail, identifier, identifier, identifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.readUser(rows)
|
||||
}
|
||||
|
||||
// userByToken returns the user with the given token if it exists and is not expired, or ErrUserNotFound otherwise
|
||||
func (a *Manager) userByToken(token string) (*User, error) {
|
||||
rows, err := a.db.Query(a.queries.selectUserByToken, token, time.Now().Unix())
|
||||
|
||||
+86
-75
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/schema"
|
||||
)
|
||||
|
||||
// PostgreSQL queries
|
||||
@@ -33,6 +34,15 @@ const (
|
||||
LEFT JOIN tier t on t.id = u.tier_id
|
||||
WHERE user_name = $1
|
||||
`
|
||||
postgresSelectUserByNameOrPrimaryEmailQuery = `
|
||||
SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||
FROM "user" u
|
||||
LEFT JOIN tier t on t.id = u.tier_id
|
||||
WHERE u.user_name = $1
|
||||
OR u.id = (SELECT user_id FROM user_email WHERE email = $2 AND is_primary)
|
||||
ORDER BY CASE WHEN u.user_name = $3 THEN 0 ELSE 1 END
|
||||
LIMIT 1
|
||||
`
|
||||
postgresSelectUserByTokenQuery = `
|
||||
SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||
FROM "user" u
|
||||
@@ -260,85 +270,86 @@ func postgresSelectAccessCacheUsersQuery(n int) string {
|
||||
|
||||
// NewPostgresManager creates a new Manager backed by a PostgreSQL database using an existing connection pool.
|
||||
var postgresQueries = queries{
|
||||
selectUserByID: postgresSelectUserByIDQuery,
|
||||
selectUserByName: postgresSelectUserByNameQuery,
|
||||
selectUserByToken: postgresSelectUserByTokenQuery,
|
||||
selectUserByStripeCustomerID: postgresSelectUserByStripeCustomerIDQuery,
|
||||
selectUsernames: postgresSelectUsernamesQuery,
|
||||
selectUsers: postgresSelectUsersQuery,
|
||||
selectUserCount: postgresSelectUserCountQuery,
|
||||
selectUserIDFromUsername: postgresSelectUserIDFromUsernameQuery,
|
||||
insertUser: postgresInsertUserQuery,
|
||||
updateUserPass: postgresUpdateUserPassQuery,
|
||||
updateUserRole: postgresUpdateUserRoleQuery,
|
||||
updateUserProvisioned: postgresUpdateUserProvisionedQuery,
|
||||
updateUserPrefs: postgresUpdateUserPrefsQuery,
|
||||
updateUserStats: postgresUpdateUserStatsQuery,
|
||||
updateUserStatsResetAll: postgresUpdateUserStatsResetAllQuery,
|
||||
updateUserTier: postgresUpdateUserTierQuery,
|
||||
updateUserDeleted: postgresUpdateUserDeletedQuery,
|
||||
deleteUser: postgresDeleteUserQuery,
|
||||
deleteUserTier: postgresDeleteUserTierQuery,
|
||||
deleteUsersMarked: postgresDeleteUsersMarkedQuery,
|
||||
deleteUsersProvisioned: postgresDeleteUsersProvisionedQuery,
|
||||
selectTopicPerms: postgresSelectTopicPermsQuery,
|
||||
selectAccessCacheAll: postgresSelectAccessCacheAllQuery,
|
||||
selectAccessCacheUsers: postgresSelectAccessCacheUsersQuery,
|
||||
selectUserAllAccess: postgresSelectUserAllAccessQuery,
|
||||
selectUserAccess: postgresSelectUserAccessQuery,
|
||||
selectUserReservations: postgresSelectUserReservationsQuery,
|
||||
selectUserReservationsCount: postgresSelectUserReservationsCountQuery,
|
||||
selectUserReservationsOwner: postgresSelectUserReservationsOwnerQuery,
|
||||
selectUserHasReservation: postgresSelectUserHasReservationQuery,
|
||||
selectOtherAccessCount: postgresSelectOtherAccessCountQuery,
|
||||
upsertUserAccess: postgresUpsertUserAccessQuery,
|
||||
deleteUserAccess: postgresDeleteUserAccessQuery,
|
||||
deleteUserAccessProvisioned: postgresDeleteUserAccessProvisionedQuery,
|
||||
deleteTopicAccess: postgresDeleteTopicAccessQuery,
|
||||
deleteAllAccess: postgresDeleteAllAccessQuery,
|
||||
selectToken: postgresSelectTokenQuery,
|
||||
selectTokens: postgresSelectTokensQuery,
|
||||
selectTokenCount: postgresSelectTokenCountQuery,
|
||||
selectAllProvisionedTokens: postgresSelectAllProvisionedTokensQuery,
|
||||
upsertToken: postgresUpsertTokenQuery,
|
||||
updateToken: postgresUpdateTokenQuery,
|
||||
updateTokenLastAccess: postgresUpdateTokenLastAccessQuery,
|
||||
deleteToken: postgresDeleteTokenQuery,
|
||||
deleteProvisionedToken: postgresDeleteProvisionedTokenQuery,
|
||||
deleteAllProvisionedTokens: postgresDeleteAllProvisionedTokensQuery,
|
||||
deleteAllToken: postgresDeleteAllTokenQuery,
|
||||
deleteExpiredTokens: postgresDeleteExpiredTokensQuery,
|
||||
deleteExcessTokens: postgresDeleteExcessTokensQuery,
|
||||
insertTier: postgresInsertTierQuery,
|
||||
selectTiers: postgresSelectTiersQuery,
|
||||
selectTierByCode: postgresSelectTierByCodeQuery,
|
||||
selectTierByPriceID: postgresSelectTierByPriceIDQuery,
|
||||
updateTier: postgresUpdateTierQuery,
|
||||
deleteTier: postgresDeleteTierQuery,
|
||||
selectPhoneNumbers: postgresSelectPhoneNumbersQuery,
|
||||
insertPhoneNumber: postgresInsertPhoneNumberQuery,
|
||||
deletePhoneNumber: postgresDeletePhoneNumberQuery,
|
||||
selectEmails: postgresSelectEmailsQuery,
|
||||
insertEmail: postgresInsertEmailQuery,
|
||||
insertEmailIgnore: postgresInsertEmailIgnoreQuery,
|
||||
deleteEmail: postgresDeleteEmailQuery,
|
||||
selectPrimaryEmail: postgresSelectPrimaryEmailQuery,
|
||||
selectUserIDByPrimary: postgresSelectUserIDByPrimaryQuery,
|
||||
updateEmailSetPrimary: postgresUpdateEmailSetPrimaryQuery,
|
||||
updateEmailClearPrimary: postgresUpdateEmailClearPrimaryQuery,
|
||||
insertMagicLink: postgresInsertMagicLinkQuery,
|
||||
selectMagicLinkByHash: postgresSelectMagicLinkByHashQuery,
|
||||
deleteMagicLinkByHash: postgresDeleteMagicLinkByHashQuery,
|
||||
deleteMagicLinkEmailVerify: postgresDeleteVerifyScopeQuery,
|
||||
deleteMagicLinkResetPassword: postgresDeleteResetScopeQuery,
|
||||
selectPendingEmails: postgresSelectPendingEmailsQuery,
|
||||
deleteExpiredMagicLinks: postgresDeleteExpiredMagicLinksQuery,
|
||||
updateBilling: postgresUpdateBillingQuery,
|
||||
selectUserByID: postgresSelectUserByIDQuery,
|
||||
selectUserByName: postgresSelectUserByNameQuery,
|
||||
selectUserByNameOrPrimaryEmail: postgresSelectUserByNameOrPrimaryEmailQuery,
|
||||
selectUserByToken: postgresSelectUserByTokenQuery,
|
||||
selectUserByStripeCustomerID: postgresSelectUserByStripeCustomerIDQuery,
|
||||
selectUsernames: postgresSelectUsernamesQuery,
|
||||
selectUsers: postgresSelectUsersQuery,
|
||||
selectUserCount: postgresSelectUserCountQuery,
|
||||
selectUserIDFromUsername: postgresSelectUserIDFromUsernameQuery,
|
||||
insertUser: postgresInsertUserQuery,
|
||||
updateUserPass: postgresUpdateUserPassQuery,
|
||||
updateUserRole: postgresUpdateUserRoleQuery,
|
||||
updateUserProvisioned: postgresUpdateUserProvisionedQuery,
|
||||
updateUserPrefs: postgresUpdateUserPrefsQuery,
|
||||
updateUserStats: postgresUpdateUserStatsQuery,
|
||||
updateUserStatsResetAll: postgresUpdateUserStatsResetAllQuery,
|
||||
updateUserTier: postgresUpdateUserTierQuery,
|
||||
updateUserDeleted: postgresUpdateUserDeletedQuery,
|
||||
deleteUser: postgresDeleteUserQuery,
|
||||
deleteUserTier: postgresDeleteUserTierQuery,
|
||||
deleteUsersMarked: postgresDeleteUsersMarkedQuery,
|
||||
deleteUsersProvisioned: postgresDeleteUsersProvisionedQuery,
|
||||
selectTopicPerms: postgresSelectTopicPermsQuery,
|
||||
selectAccessCacheAll: postgresSelectAccessCacheAllQuery,
|
||||
selectAccessCacheUsers: postgresSelectAccessCacheUsersQuery,
|
||||
selectUserAllAccess: postgresSelectUserAllAccessQuery,
|
||||
selectUserAccess: postgresSelectUserAccessQuery,
|
||||
selectUserReservations: postgresSelectUserReservationsQuery,
|
||||
selectUserReservationsCount: postgresSelectUserReservationsCountQuery,
|
||||
selectUserReservationsOwner: postgresSelectUserReservationsOwnerQuery,
|
||||
selectUserHasReservation: postgresSelectUserHasReservationQuery,
|
||||
selectOtherAccessCount: postgresSelectOtherAccessCountQuery,
|
||||
upsertUserAccess: postgresUpsertUserAccessQuery,
|
||||
deleteUserAccess: postgresDeleteUserAccessQuery,
|
||||
deleteUserAccessProvisioned: postgresDeleteUserAccessProvisionedQuery,
|
||||
deleteTopicAccess: postgresDeleteTopicAccessQuery,
|
||||
deleteAllAccess: postgresDeleteAllAccessQuery,
|
||||
selectToken: postgresSelectTokenQuery,
|
||||
selectTokens: postgresSelectTokensQuery,
|
||||
selectTokenCount: postgresSelectTokenCountQuery,
|
||||
selectAllProvisionedTokens: postgresSelectAllProvisionedTokensQuery,
|
||||
upsertToken: postgresUpsertTokenQuery,
|
||||
updateToken: postgresUpdateTokenQuery,
|
||||
updateTokenLastAccess: postgresUpdateTokenLastAccessQuery,
|
||||
deleteToken: postgresDeleteTokenQuery,
|
||||
deleteProvisionedToken: postgresDeleteProvisionedTokenQuery,
|
||||
deleteAllProvisionedTokens: postgresDeleteAllProvisionedTokensQuery,
|
||||
deleteAllToken: postgresDeleteAllTokenQuery,
|
||||
deleteExpiredTokens: postgresDeleteExpiredTokensQuery,
|
||||
deleteExcessTokens: postgresDeleteExcessTokensQuery,
|
||||
insertTier: postgresInsertTierQuery,
|
||||
selectTiers: postgresSelectTiersQuery,
|
||||
selectTierByCode: postgresSelectTierByCodeQuery,
|
||||
selectTierByPriceID: postgresSelectTierByPriceIDQuery,
|
||||
updateTier: postgresUpdateTierQuery,
|
||||
deleteTier: postgresDeleteTierQuery,
|
||||
selectPhoneNumbers: postgresSelectPhoneNumbersQuery,
|
||||
insertPhoneNumber: postgresInsertPhoneNumberQuery,
|
||||
deletePhoneNumber: postgresDeletePhoneNumberQuery,
|
||||
selectEmails: postgresSelectEmailsQuery,
|
||||
insertEmail: postgresInsertEmailQuery,
|
||||
insertEmailIgnore: postgresInsertEmailIgnoreQuery,
|
||||
deleteEmail: postgresDeleteEmailQuery,
|
||||
selectPrimaryEmail: postgresSelectPrimaryEmailQuery,
|
||||
selectUserIDByPrimary: postgresSelectUserIDByPrimaryQuery,
|
||||
updateEmailSetPrimary: postgresUpdateEmailSetPrimaryQuery,
|
||||
updateEmailClearPrimary: postgresUpdateEmailClearPrimaryQuery,
|
||||
insertMagicLink: postgresInsertMagicLinkQuery,
|
||||
selectMagicLinkByHash: postgresSelectMagicLinkByHashQuery,
|
||||
deleteMagicLinkByHash: postgresDeleteMagicLinkByHashQuery,
|
||||
deleteMagicLinkEmailVerify: postgresDeleteVerifyScopeQuery,
|
||||
deleteMagicLinkResetPassword: postgresDeleteResetScopeQuery,
|
||||
selectPendingEmails: postgresSelectPendingEmailsQuery,
|
||||
deleteExpiredMagicLinks: postgresDeleteExpiredMagicLinksQuery,
|
||||
updateBilling: postgresUpdateBillingQuery,
|
||||
}
|
||||
|
||||
// NewPostgresManager creates a new Manager backed by a PostgreSQL database
|
||||
func NewPostgresManager(d *db.DB, config *Config) (*Manager, error) {
|
||||
if err := setupPostgres(d.Primary()); err != nil {
|
||||
if err := schema.Migrate(d.Primary(), schema.Postgres, schemaStore, postgresCurrentSchemaVersion, postgresCreateTables, postgresMigrations); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newManager(d, postgresQueries, config)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/v2/db/schema"
|
||||
)
|
||||
|
||||
// Initial PostgreSQL schema
|
||||
@@ -90,21 +89,14 @@ const (
|
||||
PRIMARY KEY (token_hash)
|
||||
);
|
||||
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
store TEXT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
);
|
||||
INSERT INTO "user" (id, user_name, pass, role, sync_topic, provisioned, created)
|
||||
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
`
|
||||
)
|
||||
|
||||
// Schema table management queries for Postgres
|
||||
const (
|
||||
postgresCurrentSchemaVersion = 8
|
||||
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'`
|
||||
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)`
|
||||
postgresCurrentSchemaVersion = 9
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -133,62 +125,16 @@ const (
|
||||
);
|
||||
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
|
||||
`
|
||||
postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'user'`
|
||||
)
|
||||
|
||||
var postgresMigrations = map[int]func(db *sql.DB) error{
|
||||
6: postgresMigrateFrom6,
|
||||
7: postgresMigrateFrom7,
|
||||
}
|
||||
var (
|
||||
postgresCreateTables = schema.AsMigrateFunc(postgresCreateTablesQueries)
|
||||
|
||||
func setupPostgres(db *sql.DB) error {
|
||||
var schemaVersion int
|
||||
err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion)
|
||||
if err != nil {
|
||||
return setupNewPostgres(db)
|
||||
// postgresMigrations maps a schema version to the migration upgrading it to the next
|
||||
// version. Always append migrations at the end, never insert in the middle.
|
||||
postgresMigrations = map[int]schema.MigrateFunc{
|
||||
6: schema.AsMigrateFunc(postgresMigrate6To7UpdateQueries),
|
||||
7: schema.AsMigrateFunc(postgresMigrate7To8UpdateQueries),
|
||||
8: schema.NopMigrateFunc, // 8 -> 9 repairs a SQLite-only foreign key defect; nothing to do on Postgres
|
||||
}
|
||||
if schemaVersion == postgresCurrentSchemaVersion {
|
||||
return nil
|
||||
} else if schemaVersion > postgresCurrentSchemaVersion {
|
||||
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, postgresCurrentSchemaVersion)
|
||||
}
|
||||
for i := schemaVersion; i < postgresCurrentSchemaVersion; i++ {
|
||||
fn, ok := postgresMigrations[i]
|
||||
if !ok {
|
||||
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
|
||||
} else if err := fn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func postgresMigrateFrom6(db *sql.DB) error {
|
||||
if _, err := db.Exec(postgresMigrate6To7UpdateQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(postgresUpdateSchemaVersionQuery, 7); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func postgresMigrateFrom7(db *sql.DB) error {
|
||||
if _, err := db.Exec(postgresMigrate7To8UpdateQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(postgresUpdateSchemaVersionQuery, 8); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupNewPostgres(db *sql.DB) error {
|
||||
if _, err := db.Exec(postgresCreateTablesQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(postgresInsertSchemaVersionQuery, postgresCurrentSchemaVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
)
|
||||
|
||||
+90
-76
@@ -9,6 +9,7 @@ import (
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/schema"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
@@ -37,6 +38,15 @@ const (
|
||||
LEFT JOIN tier t on t.id = u.tier_id
|
||||
WHERE user = ?
|
||||
`
|
||||
sqliteSelectUserByNameOrPrimaryEmailQuery = `
|
||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||
FROM user u
|
||||
LEFT JOIN tier t on t.id = u.tier_id
|
||||
WHERE u.user = ?
|
||||
OR u.id = (SELECT user_id FROM user_email WHERE email = ? AND is_primary = 1)
|
||||
ORDER BY CASE WHEN u.user = ? THEN 0 ELSE 1 END
|
||||
LIMIT 1
|
||||
`
|
||||
sqliteSelectUserByTokenQuery = `
|
||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||
FROM user u
|
||||
@@ -256,80 +266,81 @@ func sqliteSelectAccessCacheUsersQuery(n int) string {
|
||||
}
|
||||
|
||||
var sqliteQueries = queries{
|
||||
selectUserByID: sqliteSelectUserByIDQuery,
|
||||
selectUserByName: sqliteSelectUserByNameQuery,
|
||||
selectUserByToken: sqliteSelectUserByTokenQuery,
|
||||
selectUserByStripeCustomerID: sqliteSelectUserByStripeCustomerIDQuery,
|
||||
selectUsernames: sqliteSelectUsernamesQuery,
|
||||
selectUsers: sqliteSelectUsersQuery,
|
||||
selectUserCount: sqliteSelectUserCountQuery,
|
||||
selectUserIDFromUsername: sqliteSelectUserIDFromUsernameQuery,
|
||||
insertUser: sqliteInsertUserQuery,
|
||||
updateUserPass: sqliteUpdateUserPassQuery,
|
||||
updateUserRole: sqliteUpdateUserRoleQuery,
|
||||
updateUserProvisioned: sqliteUpdateUserProvisionedQuery,
|
||||
updateUserPrefs: sqliteUpdateUserPrefsQuery,
|
||||
updateUserStats: sqliteUpdateUserStatsQuery,
|
||||
updateUserStatsResetAll: sqliteUpdateUserStatsResetAllQuery,
|
||||
updateUserTier: sqliteUpdateUserTierQuery,
|
||||
updateUserDeleted: sqliteUpdateUserDeletedQuery,
|
||||
deleteUser: sqliteDeleteUserQuery,
|
||||
deleteUserTier: sqliteDeleteUserTierQuery,
|
||||
deleteUsersMarked: sqliteDeleteUsersMarkedQuery,
|
||||
deleteUsersProvisioned: sqliteDeleteUsersProvisionedQuery,
|
||||
selectTopicPerms: sqliteSelectTopicPermsQuery,
|
||||
selectAccessCacheAll: sqliteSelectAccessCacheAllQuery,
|
||||
selectAccessCacheUsers: sqliteSelectAccessCacheUsersQuery,
|
||||
selectUserAllAccess: sqliteSelectUserAllAccessQuery,
|
||||
selectUserAccess: sqliteSelectUserAccessQuery,
|
||||
selectUserReservations: sqliteSelectUserReservationsQuery,
|
||||
selectUserReservationsCount: sqliteSelectUserReservationsCountQuery,
|
||||
selectUserReservationsOwner: sqliteSelectUserReservationsOwnerQuery,
|
||||
selectUserHasReservation: sqliteSelectUserHasReservationQuery,
|
||||
selectOtherAccessCount: sqliteSelectOtherAccessCountQuery,
|
||||
upsertUserAccess: sqliteUpsertUserAccessQuery,
|
||||
deleteUserAccess: sqliteDeleteUserAccessQuery,
|
||||
deleteUserAccessProvisioned: sqliteDeleteUserAccessProvisionedQuery,
|
||||
deleteTopicAccess: sqliteDeleteTopicAccessQuery,
|
||||
deleteAllAccess: sqliteDeleteAllAccessQuery,
|
||||
selectToken: sqliteSelectTokenQuery,
|
||||
selectTokens: sqliteSelectTokensQuery,
|
||||
selectTokenCount: sqliteSelectTokenCountQuery,
|
||||
selectAllProvisionedTokens: sqliteSelectAllProvisionedTokensQuery,
|
||||
upsertToken: sqliteUpsertTokenQuery,
|
||||
updateToken: sqliteUpdateTokenQuery,
|
||||
updateTokenLastAccess: sqliteUpdateTokenLastAccessQuery,
|
||||
deleteToken: sqliteDeleteTokenQuery,
|
||||
deleteProvisionedToken: sqliteDeleteProvisionedTokenQuery,
|
||||
deleteAllProvisionedTokens: sqliteDeleteAllProvisionedTokensQuery,
|
||||
deleteAllToken: sqliteDeleteAllTokenQuery,
|
||||
deleteExpiredTokens: sqliteDeleteExpiredTokensQuery,
|
||||
deleteExcessTokens: sqliteDeleteExcessTokensQuery,
|
||||
insertTier: sqliteInsertTierQuery,
|
||||
selectTiers: sqliteSelectTiersQuery,
|
||||
selectTierByCode: sqliteSelectTierByCodeQuery,
|
||||
selectTierByPriceID: sqliteSelectTierByPriceIDQuery,
|
||||
updateTier: sqliteUpdateTierQuery,
|
||||
deleteTier: sqliteDeleteTierQuery,
|
||||
selectPhoneNumbers: sqliteSelectPhoneNumbersQuery,
|
||||
insertPhoneNumber: sqliteInsertPhoneNumberQuery,
|
||||
deletePhoneNumber: sqliteDeletePhoneNumberQuery,
|
||||
selectEmails: sqliteSelectEmailsQuery,
|
||||
insertEmail: sqliteInsertEmailQuery,
|
||||
insertEmailIgnore: sqliteInsertEmailIgnoreQuery,
|
||||
deleteEmail: sqliteDeleteEmailQuery,
|
||||
selectPrimaryEmail: sqliteSelectPrimaryEmailQuery,
|
||||
selectUserIDByPrimary: sqliteSelectUserIDByPrimaryQuery,
|
||||
updateEmailSetPrimary: sqliteUpdateEmailSetPrimaryQuery,
|
||||
updateEmailClearPrimary: sqliteUpdateEmailClearPrimaryQuery,
|
||||
insertMagicLink: sqliteInsertMagicLinkQuery,
|
||||
selectMagicLinkByHash: sqliteSelectMagicLinkByHashQuery,
|
||||
deleteMagicLinkByHash: sqliteDeleteMagicLinkByHashQuery,
|
||||
deleteMagicLinkEmailVerify: sqliteDeleteVerifyScopeQuery,
|
||||
deleteMagicLinkResetPassword: sqliteDeleteResetScopeQuery,
|
||||
selectPendingEmails: sqliteSelectPendingEmailsQuery,
|
||||
deleteExpiredMagicLinks: sqliteDeleteExpiredMagicLinksQuery,
|
||||
updateBilling: sqliteUpdateBillingQuery,
|
||||
selectUserByID: sqliteSelectUserByIDQuery,
|
||||
selectUserByName: sqliteSelectUserByNameQuery,
|
||||
selectUserByNameOrPrimaryEmail: sqliteSelectUserByNameOrPrimaryEmailQuery,
|
||||
selectUserByToken: sqliteSelectUserByTokenQuery,
|
||||
selectUserByStripeCustomerID: sqliteSelectUserByStripeCustomerIDQuery,
|
||||
selectUsernames: sqliteSelectUsernamesQuery,
|
||||
selectUsers: sqliteSelectUsersQuery,
|
||||
selectUserCount: sqliteSelectUserCountQuery,
|
||||
selectUserIDFromUsername: sqliteSelectUserIDFromUsernameQuery,
|
||||
insertUser: sqliteInsertUserQuery,
|
||||
updateUserPass: sqliteUpdateUserPassQuery,
|
||||
updateUserRole: sqliteUpdateUserRoleQuery,
|
||||
updateUserProvisioned: sqliteUpdateUserProvisionedQuery,
|
||||
updateUserPrefs: sqliteUpdateUserPrefsQuery,
|
||||
updateUserStats: sqliteUpdateUserStatsQuery,
|
||||
updateUserStatsResetAll: sqliteUpdateUserStatsResetAllQuery,
|
||||
updateUserTier: sqliteUpdateUserTierQuery,
|
||||
updateUserDeleted: sqliteUpdateUserDeletedQuery,
|
||||
deleteUser: sqliteDeleteUserQuery,
|
||||
deleteUserTier: sqliteDeleteUserTierQuery,
|
||||
deleteUsersMarked: sqliteDeleteUsersMarkedQuery,
|
||||
deleteUsersProvisioned: sqliteDeleteUsersProvisionedQuery,
|
||||
selectTopicPerms: sqliteSelectTopicPermsQuery,
|
||||
selectAccessCacheAll: sqliteSelectAccessCacheAllQuery,
|
||||
selectAccessCacheUsers: sqliteSelectAccessCacheUsersQuery,
|
||||
selectUserAllAccess: sqliteSelectUserAllAccessQuery,
|
||||
selectUserAccess: sqliteSelectUserAccessQuery,
|
||||
selectUserReservations: sqliteSelectUserReservationsQuery,
|
||||
selectUserReservationsCount: sqliteSelectUserReservationsCountQuery,
|
||||
selectUserReservationsOwner: sqliteSelectUserReservationsOwnerQuery,
|
||||
selectUserHasReservation: sqliteSelectUserHasReservationQuery,
|
||||
selectOtherAccessCount: sqliteSelectOtherAccessCountQuery,
|
||||
upsertUserAccess: sqliteUpsertUserAccessQuery,
|
||||
deleteUserAccess: sqliteDeleteUserAccessQuery,
|
||||
deleteUserAccessProvisioned: sqliteDeleteUserAccessProvisionedQuery,
|
||||
deleteTopicAccess: sqliteDeleteTopicAccessQuery,
|
||||
deleteAllAccess: sqliteDeleteAllAccessQuery,
|
||||
selectToken: sqliteSelectTokenQuery,
|
||||
selectTokens: sqliteSelectTokensQuery,
|
||||
selectTokenCount: sqliteSelectTokenCountQuery,
|
||||
selectAllProvisionedTokens: sqliteSelectAllProvisionedTokensQuery,
|
||||
upsertToken: sqliteUpsertTokenQuery,
|
||||
updateToken: sqliteUpdateTokenQuery,
|
||||
updateTokenLastAccess: sqliteUpdateTokenLastAccessQuery,
|
||||
deleteToken: sqliteDeleteTokenQuery,
|
||||
deleteProvisionedToken: sqliteDeleteProvisionedTokenQuery,
|
||||
deleteAllProvisionedTokens: sqliteDeleteAllProvisionedTokensQuery,
|
||||
deleteAllToken: sqliteDeleteAllTokenQuery,
|
||||
deleteExpiredTokens: sqliteDeleteExpiredTokensQuery,
|
||||
deleteExcessTokens: sqliteDeleteExcessTokensQuery,
|
||||
insertTier: sqliteInsertTierQuery,
|
||||
selectTiers: sqliteSelectTiersQuery,
|
||||
selectTierByCode: sqliteSelectTierByCodeQuery,
|
||||
selectTierByPriceID: sqliteSelectTierByPriceIDQuery,
|
||||
updateTier: sqliteUpdateTierQuery,
|
||||
deleteTier: sqliteDeleteTierQuery,
|
||||
selectPhoneNumbers: sqliteSelectPhoneNumbersQuery,
|
||||
insertPhoneNumber: sqliteInsertPhoneNumberQuery,
|
||||
deletePhoneNumber: sqliteDeletePhoneNumberQuery,
|
||||
selectEmails: sqliteSelectEmailsQuery,
|
||||
insertEmail: sqliteInsertEmailQuery,
|
||||
insertEmailIgnore: sqliteInsertEmailIgnoreQuery,
|
||||
deleteEmail: sqliteDeleteEmailQuery,
|
||||
selectPrimaryEmail: sqliteSelectPrimaryEmailQuery,
|
||||
selectUserIDByPrimary: sqliteSelectUserIDByPrimaryQuery,
|
||||
updateEmailSetPrimary: sqliteUpdateEmailSetPrimaryQuery,
|
||||
updateEmailClearPrimary: sqliteUpdateEmailClearPrimaryQuery,
|
||||
insertMagicLink: sqliteInsertMagicLinkQuery,
|
||||
selectMagicLinkByHash: sqliteSelectMagicLinkByHashQuery,
|
||||
deleteMagicLinkByHash: sqliteDeleteMagicLinkByHashQuery,
|
||||
deleteMagicLinkEmailVerify: sqliteDeleteVerifyScopeQuery,
|
||||
deleteMagicLinkResetPassword: sqliteDeleteResetScopeQuery,
|
||||
selectPendingEmails: sqliteSelectPendingEmailsQuery,
|
||||
deleteExpiredMagicLinks: sqliteDeleteExpiredMagicLinksQuery,
|
||||
updateBilling: sqliteUpdateBillingQuery,
|
||||
}
|
||||
|
||||
// NewSQLiteManager creates a new Manager backed by a SQLite database
|
||||
@@ -341,14 +352,17 @@ func NewSQLiteManager(filename, startupQueries string, config *Config) (*Manager
|
||||
// Open with case-sensitive LIKE. ACL topic matching is done via LIKE (see
|
||||
// selectTopicPerms), and SQLite's LIKE is case-insensitive for ASCII by
|
||||
// default -- without this, an ACL rule for "secret" would also match a
|
||||
// request for "SECRET", which is a security iisue. PostgreSQL's LIKE is
|
||||
// request for "SECRET", which is a security issue. PostgreSQL's LIKE is
|
||||
// already case-sensitive, so this only affects SQLite. The pragma is
|
||||
// applied to every pooled connection by the driver.
|
||||
d, err := sql.Open("sqlite3", fmt.Sprintf("%s?_case_sensitive_like=on", filename))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := setupSQLite(d); err != nil {
|
||||
// Migrations must run before the startup queries: the 5 -> 6 table rebuilds rely on
|
||||
// foreign keys being OFF, which is only guaranteed on fresh connections (the foreign_keys
|
||||
// pragma is enabled as part of the builtin startup queries below)
|
||||
if err := schema.Migrate(d, schema.SQLite, schemaStore, sqliteCurrentSchemaVersion, sqliteCreateTables, sqliteMigrations); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := runSQLiteStartupQueries(d, startupQueries); err != nil {
|
||||
|
||||
+64
-175
@@ -2,10 +2,8 @@ package user
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/db/schema"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
@@ -105,10 +103,6 @@ const (
|
||||
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
);
|
||||
INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created)
|
||||
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, UNIXEPOCH())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
@@ -119,12 +113,8 @@ const (
|
||||
sqliteBuiltinStartupQueries = `PRAGMA foreign_keys = ON;`
|
||||
)
|
||||
|
||||
// Schema version table management for SQLite
|
||||
const (
|
||||
sqliteCurrentSchemaVersion = 8
|
||||
sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`
|
||||
sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1`
|
||||
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
||||
sqliteCurrentSchemaVersion = 9
|
||||
)
|
||||
|
||||
// Schema migrations for SQLite
|
||||
@@ -190,10 +180,6 @@ const (
|
||||
PRIMARY KEY (user_id, token),
|
||||
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
);
|
||||
INSERT INTO user (id, user, pass, role, sync_topic, created)
|
||||
VALUES ('u_everyone', '*', '', 'anonymous', '', UNIXEPOCH())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
@@ -270,10 +256,11 @@ const (
|
||||
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
|
||||
`
|
||||
|
||||
// 5 -> 6
|
||||
// 5 -> 6. The table rebuilds below rely on foreign keys being OFF (otherwise RENAME
|
||||
// would rewrite the childrens' REFERENCES clauses to point at the _old tables). This is
|
||||
// guaranteed because migrations run on fresh connections, before the startup queries
|
||||
// enable the foreign_keys pragma; see NewSQLiteManager.
|
||||
sqliteMigrate5To6UpdateQueries = `
|
||||
PRAGMA foreign_keys=off;
|
||||
|
||||
-- Alter user table: Add provisioned column
|
||||
ALTER TABLE user RENAME TO user_old;
|
||||
CREATE TABLE IF NOT EXISTS user (
|
||||
@@ -359,57 +346,45 @@ const (
|
||||
CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);
|
||||
CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);
|
||||
CREATE UNIQUE INDEX idx_user_token ON user_token (token);
|
||||
`
|
||||
|
||||
-- Re-enable foreign keys
|
||||
PRAGMA foreign_keys=on;
|
||||
// 8 -> 9: Repair the user_phone foreign key. The 5 -> 6 migration renamed user to
|
||||
// user_old, which rewrote user_phone's REFERENCES clause to user_old -- a table that was
|
||||
// then dropped (the rebuilt tables got correct fresh foreign keys; user_phone was the only
|
||||
// child table not rebuilt). Rebuilding user_phone re-points the foreign key at user; on
|
||||
// healthy databases the rebuild is a harmless no-op schema-wise.
|
||||
sqliteMigrate8To9UpdateQueries = `
|
||||
ALTER TABLE user_phone RENAME TO user_phone_old;
|
||||
CREATE TABLE user_phone (
|
||||
user_id TEXT NOT NULL,
|
||||
phone_number TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, phone_number),
|
||||
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO user_phone (user_id, phone_number)
|
||||
SELECT user_id, phone_number FROM user_phone_old
|
||||
WHERE user_id IN (SELECT id FROM user); -- Drop orphaned rows that the broken foreign key failed to cascade-delete
|
||||
DROP TABLE user_phone_old;
|
||||
`
|
||||
)
|
||||
|
||||
var (
|
||||
sqliteMigrations = map[int]func(db *sql.DB) error{
|
||||
sqliteCreateTables = schema.AsMigrateFunc(sqliteCreateTablesQueries)
|
||||
|
||||
// sqliteMigrations maps a schema version to the migration upgrading it to the next
|
||||
// version. Always append migrations at the end, never insert in the middle.
|
||||
sqliteMigrations = map[int]schema.MigrateFunc{
|
||||
1: sqliteMigrateFrom1,
|
||||
2: sqliteMigrateFrom2,
|
||||
3: sqliteMigrateFrom3,
|
||||
4: sqliteMigrateFrom4,
|
||||
5: sqliteMigrateFrom5,
|
||||
6: sqliteMigrateFrom6,
|
||||
7: sqliteMigrateFrom7,
|
||||
2: schema.AsMigrateFunc(sqliteMigrate2To3UpdateQueries),
|
||||
3: schema.AsMigrateFunc(sqliteMigrate3To4UpdateQueries),
|
||||
4: schema.AsMigrateFunc(sqliteMigrate4To5UpdateQueries),
|
||||
5: schema.AsMigrateFunc(sqliteMigrate5To6UpdateQueries),
|
||||
6: schema.AsMigrateFunc(sqliteMigrate6To7UpdateQueries),
|
||||
7: schema.AsMigrateFunc(sqliteMigrate7To8UpdateQueries),
|
||||
8: schema.AsMigrateFunc(sqliteMigrate8To9UpdateQueries),
|
||||
}
|
||||
)
|
||||
|
||||
func setupSQLite(db *sql.DB) error {
|
||||
var schemaVersion int
|
||||
if err := db.QueryRow(sqliteSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil {
|
||||
return setupNewSQLite(db)
|
||||
}
|
||||
if schemaVersion == sqliteCurrentSchemaVersion {
|
||||
return nil
|
||||
} else if schemaVersion > sqliteCurrentSchemaVersion {
|
||||
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, sqliteCurrentSchemaVersion)
|
||||
}
|
||||
for i := schemaVersion; i < sqliteCurrentSchemaVersion; i++ {
|
||||
fn, ok := sqliteMigrations[i]
|
||||
if !ok {
|
||||
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
|
||||
} else if err := fn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupNewSQLite(sqlDB *sql.DB) error {
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteCreateTablesQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
|
||||
if _, err := db.Exec(sqliteBuiltinStartupQueries); err != nil {
|
||||
return err
|
||||
@@ -422,122 +397,36 @@ func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom1(sqlDB *sql.DB) error {
|
||||
log.Tag(tag).Info("Migrating user database schema: from 1 to 2")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
// Rename user -> user_old, and create new tables
|
||||
if _, err := tx.Exec(sqliteMigrate1To2CreateTablesQueries); err != nil {
|
||||
func sqliteMigrateFrom1(tx *sql.Tx) error {
|
||||
// Rename user -> user_old, and create new tables
|
||||
if _, err := tx.Exec(sqliteMigrate1To2CreateTablesQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
// Insert users from user_old into new user table, with ID and sync_topic
|
||||
rows, err := tx.Query(sqliteMigrate1To2SelectAllOldUsernamesNoTxQuery)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
usernames := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var username string
|
||||
if err := rows.Scan(&username); err != nil {
|
||||
return err
|
||||
}
|
||||
// Insert users from user_old into new user table, with ID and sync_topic
|
||||
rows, err := tx.Query(sqliteMigrate1To2SelectAllOldUsernamesNoTxQuery)
|
||||
if err != nil {
|
||||
usernames = append(usernames, username)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, username := range usernames {
|
||||
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
|
||||
syncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength)
|
||||
if _, err := tx.Exec(sqliteMigrate1To2InsertUserNoTxQuery, userID, syncTopic, username); err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
usernames := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var username string
|
||||
if err := rows.Scan(&username); err != nil {
|
||||
return err
|
||||
}
|
||||
usernames = append(usernames, username)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, username := range usernames {
|
||||
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
|
||||
syncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength)
|
||||
if _, err := tx.Exec(sqliteMigrate1To2InsertUserNoTxQuery, userID, syncTopic, username); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Migrate old "access" table to "user_access" and drop "access" and "user_old"
|
||||
if _, err := tx.Exec(sqliteMigrate1To2InsertFromOldTablesAndDropNoTxQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 2); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom2(sqlDB *sql.DB) error {
|
||||
log.Tag(tag).Info("Migrating user database schema: from 2 to 3")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate2To3UpdateQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 3); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom3(sqlDB *sql.DB) error {
|
||||
log.Tag(tag).Info("Migrating user database schema: from 3 to 4")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate3To4UpdateQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 4); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom4(sqlDB *sql.DB) error {
|
||||
log.Tag(tag).Info("Migrating user database schema: from 4 to 5")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate4To5UpdateQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 5); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom5(sqlDB *sql.DB) error {
|
||||
log.Tag(tag).Info("Migrating user database schema: from 5 to 6")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate5To6UpdateQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 6); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom6(sqlDB *sql.DB) error {
|
||||
log.Tag(tag).Info("Migrating user database schema: from 6 to 7")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate6To7UpdateQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 7); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom7(sqlDB *sql.DB) error {
|
||||
log.Tag(tag).Info("Migrating user database schema: from 7 to 8")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate7To8UpdateQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 8); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
// Migrate old "access" table to "user_access" and drop "access" and "user_old"
|
||||
_, err = tx.Exec(sqliteMigrate1To2InsertFromOldTablesAndDropNoTxQuery)
|
||||
return err
|
||||
}
|
||||
|
||||
+249
-2
@@ -14,6 +14,7 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
"heckel.io/ntfy/v2/db/schema"
|
||||
dbtest "heckel.io/ntfy/v2/db/test"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
@@ -1566,6 +1567,83 @@ func TestToFromSQLWildcard(t *testing.T) {
|
||||
require.Equal(t, "foo", fromSQLWildcard(toSQLWildcard("foo")))
|
||||
}
|
||||
|
||||
// testPostgresV6Schema is the PostgreSQL schema exactly as created by the version that first
|
||||
// shipped Postgres support (schema version 6), taken from the code at that time; used to
|
||||
// verify the migration chain from its oldest supported version.
|
||||
const testPostgresV6Schema = `
|
||||
CREATE TABLE IF NOT EXISTS tier (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
messages_limit BIGINT NOT NULL,
|
||||
messages_expiry_duration BIGINT NOT NULL,
|
||||
emails_limit BIGINT NOT NULL,
|
||||
calls_limit BIGINT NOT NULL,
|
||||
reservations_limit BIGINT NOT NULL,
|
||||
attachment_file_size_limit BIGINT NOT NULL,
|
||||
attachment_total_size_limit BIGINT NOT NULL,
|
||||
attachment_expiry_duration BIGINT NOT NULL,
|
||||
attachment_bandwidth_limit BIGINT NOT NULL,
|
||||
stripe_monthly_price_id TEXT,
|
||||
stripe_yearly_price_id TEXT,
|
||||
UNIQUE(code),
|
||||
UNIQUE(stripe_monthly_price_id),
|
||||
UNIQUE(stripe_yearly_price_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "user" (
|
||||
id TEXT PRIMARY KEY,
|
||||
tier_id TEXT REFERENCES tier(id),
|
||||
user_name TEXT NOT NULL UNIQUE,
|
||||
pass TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('anonymous', 'admin', 'user')),
|
||||
prefs JSONB NOT NULL DEFAULT '{}',
|
||||
sync_topic TEXT NOT NULL,
|
||||
provisioned BOOLEAN NOT NULL,
|
||||
stats_messages BIGINT NOT NULL DEFAULT 0,
|
||||
stats_emails BIGINT NOT NULL DEFAULT 0,
|
||||
stats_calls BIGINT NOT NULL DEFAULT 0,
|
||||
stripe_customer_id TEXT UNIQUE,
|
||||
stripe_subscription_id TEXT UNIQUE,
|
||||
stripe_subscription_status TEXT,
|
||||
stripe_subscription_interval TEXT,
|
||||
stripe_subscription_paid_until BIGINT,
|
||||
stripe_subscription_cancel_at BIGINT,
|
||||
created BIGINT NOT NULL,
|
||||
deleted BIGINT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_access (
|
||||
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
topic TEXT NOT NULL,
|
||||
read BOOLEAN NOT NULL,
|
||||
write BOOLEAN NOT NULL,
|
||||
owner_user_id TEXT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
provisioned BOOLEAN NOT NULL,
|
||||
PRIMARY KEY (user_id, topic)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_token (
|
||||
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL,
|
||||
last_access BIGINT NOT NULL,
|
||||
last_origin TEXT NOT NULL,
|
||||
expires BIGINT NOT NULL,
|
||||
provisioned BOOLEAN NOT NULL,
|
||||
PRIMARY KEY (user_id, token)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_phone (
|
||||
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
phone_number TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, phone_number)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
store TEXT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
);
|
||||
INSERT INTO "user" (id, user_name, pass, role, sync_topic, provisioned, created)
|
||||
VALUES ('u_everyone', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
`
|
||||
|
||||
func TestMigrationFrom1(t *testing.T) {
|
||||
filename := filepath.Join(t.TempDir(), "user.db")
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
@@ -1649,6 +1727,8 @@ func TestMigrationFrom1(t *testing.T) {
|
||||
require.Equal(t, 1, len(everyoneGrants))
|
||||
require.Equal(t, "stats", everyoneGrants[0].TopicPattern)
|
||||
require.Equal(t, PermissionRead, everyoneGrants[0].Permission)
|
||||
|
||||
checkMigratedSqliteSchema(t, filename)
|
||||
}
|
||||
|
||||
func TestMigrationFrom4(t *testing.T) {
|
||||
@@ -1740,12 +1820,15 @@ func TestMigrationFrom4(t *testing.T) {
|
||||
`)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Insert a few ACL entries
|
||||
// Insert a few ACL entries, and phone numbers: one for a live user, one orphaned (its user
|
||||
// is gone; the broken pre-v9 foreign key never cascade-deleted it)
|
||||
_, err = db.Exec(`
|
||||
BEGIN;
|
||||
INSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'mytopic_', 1, 1);
|
||||
INSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'up%', 1, 1);
|
||||
INSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'down_%', 1, 1);
|
||||
INSERT INTO user_phone (user_id, phone_number) VALUES ('u_everyone', '+12223334444');
|
||||
INSERT INTO user_phone (user_id, phone_number) VALUES ('u_gone', '+15556667777');
|
||||
COMMIT;
|
||||
`)
|
||||
require.Nil(t, err)
|
||||
@@ -1795,6 +1878,68 @@ func TestMigrationFrom4(t *testing.T) {
|
||||
|
||||
require.Nil(t, a.Authorize(nil, "up123", PermissionRead))
|
||||
require.Nil(t, a.Authorize(nil, "up", PermissionRead)) // % matches 0 or more characters
|
||||
|
||||
// The 8 -> 9 repair kept the live user's phone number and dropped the orphaned row
|
||||
phoneNumbers := make([]string, 0)
|
||||
rows, err = db.Query(`SELECT phone_number FROM user_phone ORDER BY phone_number`)
|
||||
require.Nil(t, err)
|
||||
for rows.Next() {
|
||||
var phoneNumber string
|
||||
require.Nil(t, rows.Scan(&phoneNumber))
|
||||
phoneNumbers = append(phoneNumbers, phoneNumber)
|
||||
}
|
||||
require.Nil(t, rows.Close())
|
||||
require.Equal(t, []string{"+12223334444"}, phoneNumbers)
|
||||
|
||||
checkMigratedSqliteSchema(t, filename)
|
||||
}
|
||||
|
||||
// TestMigrationFrom6Postgres tests the Postgres migration chain from its oldest supported
|
||||
// version (6, the version PostgreSQL support first shipped with).
|
||||
func TestMigrationFrom6Postgres(t *testing.T) {
|
||||
testDB := dbtest.CreateTestPostgres(t)
|
||||
_, err := testDB.Exec(testPostgresV6Schema)
|
||||
require.Nil(t, err)
|
||||
_, err = testDB.Exec(`INSERT INTO schema_version (store, version) VALUES ('user', 6)`)
|
||||
require.Nil(t, err)
|
||||
// Create manager to trigger migration
|
||||
a, err := NewPostgresManager(testDB, &Config{DefaultAccess: PermissionDenyAll, BcryptCost: bcrypt.MinCost, QueueWriterInterval: DefaultUserStatsQueueWriterInterval})
|
||||
require.Nil(t, err)
|
||||
var version int
|
||||
require.Nil(t, testDB.QueryRow(`SELECT version FROM schema_version WHERE store = 'user'`).Scan(&version))
|
||||
require.Equal(t, postgresCurrentSchemaVersion, version)
|
||||
// The manager works against the migrated schema
|
||||
require.Nil(t, a.AddUser("phil", "mypass", RoleUser, false))
|
||||
u, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, a.AddEmail(u.ID, "phil@example.com"))
|
||||
// The migrated database must be structurally identical to a freshly created one
|
||||
freshDB := dbtest.CreateTestPostgres(t)
|
||||
_, err = NewPostgresManager(freshDB, &Config{DefaultAccess: PermissionDenyAll, BcryptCost: bcrypt.MinCost, QueueWriterInterval: DefaultUserStatsQueueWriterInterval})
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, dbtest.PostgresSchema(t, freshDB), dbtest.PostgresSchema(t, testDB))
|
||||
}
|
||||
|
||||
// checkMigratedSqliteSchema verifies that a migrated database is structurally identical to a
|
||||
// freshly created one (this pins, among other things, that the foreign keys of the tables
|
||||
// rebuilt in migration 5 -> 6 still point at "user", not at a dropped "user_old"), and that
|
||||
// its data passes SQLite's foreign key consistency check.
|
||||
func checkMigratedSqliteSchema(t *testing.T, filename string) {
|
||||
t.Helper()
|
||||
freshFile := filepath.Join(t.TempDir(), "fresh.db")
|
||||
fresh := newTestManagerFromFile(t, freshFile, "", PermissionDenyAll, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval)
|
||||
defer fresh.Close()
|
||||
freshDB, err := sql.Open("sqlite3", freshFile)
|
||||
require.Nil(t, err)
|
||||
defer freshDB.Close()
|
||||
migratedDB, err := sql.Open("sqlite3", filename)
|
||||
require.Nil(t, err)
|
||||
defer migratedDB.Close()
|
||||
require.Equal(t, dbtest.SQLiteSchema(t, freshDB), dbtest.SQLiteSchema(t, migratedDB))
|
||||
rows, err := migratedDB.Query(`PRAGMA foreign_key_check`)
|
||||
require.Nil(t, err)
|
||||
defer rows.Close()
|
||||
require.False(t, rows.Next(), "foreign_key_check reported violations in the migrated database")
|
||||
}
|
||||
|
||||
func checkSchemaVersion(t *testing.T, d *db.DB) {
|
||||
@@ -2947,6 +3092,108 @@ func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_Authenticate_ByPrimaryEmail(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
// phil verifies phil@example.com -> becomes his primary (recovery) email
|
||||
_, err = a.VerifyEmail(addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour))
|
||||
require.Nil(t, err)
|
||||
|
||||
// Login by username still works
|
||||
u, err := a.Authenticate("phil", "phil")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "phil", u.Name)
|
||||
|
||||
// Login by primary email works and resolves to the same account
|
||||
u, err = a.Authenticate("phil@example.com", "phil")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "phil", u.Name)
|
||||
|
||||
// Login by primary email with the wrong password fails
|
||||
u, err = a.Authenticate("phil@example.com", "wrong")
|
||||
require.Nil(t, u)
|
||||
require.Equal(t, ErrUnauthenticated, err)
|
||||
|
||||
// An unknown email fails
|
||||
u, err = a.Authenticate("nobody@example.com", "phil")
|
||||
require.Nil(t, u)
|
||||
require.Equal(t, ErrUnauthenticated, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_Authenticate_BySecondaryEmailDenied(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
ben, err := a.User("ben")
|
||||
require.Nil(t, err)
|
||||
|
||||
// phil verifies shared@ first -> his primary; ben verifies it too -> only secondary for ben
|
||||
_, err = a.VerifyEmail(addVerifyLink(t, a, phil.ID, "shared@example.com", 24*time.Hour))
|
||||
require.Nil(t, err)
|
||||
_, err = a.VerifyEmail(addVerifyLink(t, a, ben.ID, "shared@example.com", 24*time.Hour))
|
||||
require.Nil(t, err)
|
||||
|
||||
// Login by the shared address resolves to the primary owner (phil), never the secondary (ben)
|
||||
u, err := a.Authenticate("shared@example.com", "phil")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "phil", u.Name)
|
||||
|
||||
// ben's password must not authenticate via the shared address (it is not his primary)
|
||||
u, err = a.Authenticate("shared@example.com", "ben")
|
||||
require.Nil(t, u)
|
||||
require.Equal(t, ErrUnauthenticated, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_Authenticate_UsernameLookalikeEmailPrecedence(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||
|
||||
// The collision: a squatter whose USERNAME is literally "phil@example.com" (usernames may
|
||||
// contain '@' and '.'), and a different account (ben) that owns "phil@example.com" as its
|
||||
// verified primary email. Both are reachable; nothing links usernames to email addresses.
|
||||
require.Nil(t, a.AddUser("phil@example.com", "squatterpass", RoleUser, false))
|
||||
require.Nil(t, a.AddUser("ben", "benpass", RoleUser, false))
|
||||
ben, err := a.User("ben")
|
||||
require.Nil(t, err)
|
||||
_, err = a.VerifyEmail(addVerifyLink(t, a, ben.ID, "phil@example.com", 24*time.Hour))
|
||||
require.Nil(t, err)
|
||||
|
||||
// Login resolves the ambiguous identifier username-FIRST (the ORDER BY CASE in the query):
|
||||
// the squatter owns the login, and returns deterministically even though both rows match.
|
||||
squatter, err := a.Authenticate("phil@example.com", "squatterpass")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "phil@example.com", squatter.Name)
|
||||
|
||||
// Consequently the email owner's password does NOT authenticate via the colliding identifier
|
||||
// at login, but the owner is not locked out: their real username still works.
|
||||
u, err := a.Authenticate("phil@example.com", "benpass")
|
||||
require.Nil(t, u)
|
||||
require.Equal(t, ErrUnauthenticated, err)
|
||||
u, err = a.Authenticate("ben", "benpass")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "ben", u.Name)
|
||||
|
||||
// The inverse: password reset (UserByEmailOrUsername) resolves the SAME identifier email-FIRST,
|
||||
// so the reset link goes to the verified email owner (ben), never the look-alike username. The
|
||||
// two flows deliberately use opposite precedence.
|
||||
loginUser, err := a.userByNameOrEmail("phil@example.com")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "phil@example.com", loginUser.Name) // username owner (squatter)
|
||||
resetUser, err := a.UserByEmailOrUsername("phil@example.com")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "ben", resetUser.Name) // email owner
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_MagicLink_SetPrimary_NotVerified(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||
@@ -3250,7 +3497,7 @@ func TestManager_Emails_PrimaryFlagAndHelpers(t *testing.T) {
|
||||
func openReplicaTestSQLite(t *testing.T, filename string) *sql.DB {
|
||||
d, err := sql.Open("sqlite3", filename+"?_case_sensitive_like=on")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, setupSQLite(d))
|
||||
require.Nil(t, schema.Migrate(d, schema.SQLite, schemaStore, sqliteCurrentSchemaVersion, sqliteCreateTables, sqliteMigrations))
|
||||
return d
|
||||
}
|
||||
|
||||
|
||||
+26
-25
@@ -47,10 +47,10 @@ func (u *User) IsUser() bool {
|
||||
|
||||
// Auther is an interface for authentication and authorization
|
||||
type Auther interface {
|
||||
// Authenticate checks username and password and returns a user if correct. The method
|
||||
// returns in constant-ish time, regardless of whether the user exists or the password is
|
||||
// correct or incorrect.
|
||||
Authenticate(username, password string) (*User, error)
|
||||
// Authenticate checks a login identifier (username or verified primary email) and password
|
||||
// and returns a user if correct. The method returns in constant-ish time, regardless of
|
||||
// whether the identifier exists or the password is correct or incorrect.
|
||||
Authenticate(identifier, password string) (*User, error)
|
||||
|
||||
// Authorize returns nil if the given user has access to the given topic using the desired
|
||||
// permission. The user param may be nil to signal an anonymous user.
|
||||
@@ -338,27 +338,28 @@ var (
|
||||
// queries holds the database-specific SQL queries
|
||||
type queries struct {
|
||||
// User queries
|
||||
selectUserByID string
|
||||
selectUserByName string
|
||||
selectUserByToken string
|
||||
selectUserByStripeCustomerID string
|
||||
selectUsernames string
|
||||
selectUsers string
|
||||
selectUserCount string
|
||||
selectUserIDFromUsername string
|
||||
insertUser string
|
||||
updateUserPass string
|
||||
updateUserRole string
|
||||
updateUserProvisioned string
|
||||
updateUserPrefs string
|
||||
updateUserStats string
|
||||
updateUserStatsResetAll string
|
||||
updateUserTier string
|
||||
updateUserDeleted string
|
||||
deleteUser string
|
||||
deleteUserTier string
|
||||
deleteUsersMarked string
|
||||
deleteUsersProvisioned string
|
||||
selectUserByID string
|
||||
selectUserByName string
|
||||
selectUserByNameOrPrimaryEmail string
|
||||
selectUserByToken string
|
||||
selectUserByStripeCustomerID string
|
||||
selectUsernames string
|
||||
selectUsers string
|
||||
selectUserCount string
|
||||
selectUserIDFromUsername string
|
||||
insertUser string
|
||||
updateUserPass string
|
||||
updateUserRole string
|
||||
updateUserProvisioned string
|
||||
updateUserPrefs string
|
||||
updateUserStats string
|
||||
updateUserStatsResetAll string
|
||||
updateUserTier string
|
||||
updateUserDeleted string
|
||||
deleteUser string
|
||||
deleteUserTier string
|
||||
deleteUsersMarked string
|
||||
deleteUsersProvisioned string
|
||||
|
||||
// Access queries
|
||||
selectTopicPerms string // Direct-DB authorizeTopicAccess query; used when the in-memory cache is disabled
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"hash/fnv"
|
||||
"math"
|
||||
)
|
||||
|
||||
// BloomFilter is a fixed-size probabilistic set: Contains may return false positives (bounded by
|
||||
// the target rate the filter was sized for) but never false negatives. Elements cannot be
|
||||
// removed; rebuild the filter from scratch instead.
|
||||
type BloomFilter struct {
|
||||
bits []uint64
|
||||
k int // number of hash probes per element, derived via double hashing
|
||||
}
|
||||
|
||||
// NewBloomFilter creates a filter sized for n elements at the given false-positive rate.
|
||||
func NewBloomFilter(n int, fpRate float64) *BloomFilter {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
if fpRate <= 0 || fpRate >= 1 {
|
||||
fpRate = 0.01
|
||||
}
|
||||
m := int(math.Ceil(-float64(n) * math.Log(fpRate) / (math.Ln2 * math.Ln2))) // bits
|
||||
k := int(math.Round(float64(m) / float64(n) * math.Ln2)) // probes
|
||||
if k < 1 {
|
||||
k = 1
|
||||
}
|
||||
return &BloomFilter{
|
||||
bits: make([]uint64, (m+63)/64),
|
||||
k: k,
|
||||
}
|
||||
}
|
||||
|
||||
// Add inserts an element into the filter.
|
||||
func (b *BloomFilter) Add(s string) {
|
||||
h1, h2 := hashPair(s)
|
||||
m := uint64(len(b.bits)) * 64
|
||||
for i := 0; i < b.k; i++ {
|
||||
bit := (h1 + uint64(i)*h2) % m
|
||||
b.bits[bit/64] |= 1 << (bit % 64)
|
||||
}
|
||||
}
|
||||
|
||||
// Contains reports whether the element may be in the set. A false result is definitive: the
|
||||
// element was never added.
|
||||
func (b *BloomFilter) Contains(s string) bool {
|
||||
h1, h2 := hashPair(s)
|
||||
m := uint64(len(b.bits)) * 64
|
||||
for i := 0; i < b.k; i++ {
|
||||
bit := (h1 + uint64(i)*h2) % m
|
||||
if b.bits[bit/64]&(1<<(bit%64)) == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MarshalBinary serializes the filter as [k][bits...], with 64-bit little-endian words.
|
||||
func (b *BloomFilter) MarshalBinary() ([]byte, error) {
|
||||
data := make([]byte, 1+len(b.bits)*8)
|
||||
data[0] = byte(b.k)
|
||||
for i, word := range b.bits {
|
||||
for j := 0; j < 8; j++ {
|
||||
data[1+i*8+j] = byte(word >> (8 * j))
|
||||
}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// UnmarshalBloomFilter deserializes a filter produced by MarshalBinary.
|
||||
func UnmarshalBloomFilter(data []byte) (*BloomFilter, error) {
|
||||
if len(data) < 9 || (len(data)-1)%8 != 0 {
|
||||
return nil, errors.New("invalid bloom filter data")
|
||||
}
|
||||
b := &BloomFilter{
|
||||
bits: make([]uint64, (len(data)-1)/8),
|
||||
k: int(data[0]),
|
||||
}
|
||||
if b.k < 1 {
|
||||
return nil, errors.New("invalid bloom filter hash count")
|
||||
}
|
||||
for i := range b.bits {
|
||||
var word uint64
|
||||
for j := 0; j < 8; j++ {
|
||||
word |= uint64(data[1+i*8+j]) << (8 * j)
|
||||
}
|
||||
b.bits[i] = word
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// hashPair derives the two independent hash values used for double hashing (probe i uses
|
||||
// h1 + i*h2), from FNV-1a over the element and a domain-separated variant of it.
|
||||
func hashPair(s string) (uint64, uint64) {
|
||||
f := fnv.New64a()
|
||||
f.Write([]byte(s))
|
||||
h1 := f.Sum64()
|
||||
f.Write([]byte{0xff}) // Domain separation for the second hash
|
||||
h2 := f.Sum64() | 1 // Odd, so probes cycle through all bit positions
|
||||
return h1, h2
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
func TestBloomFilter_NoFalseNegatives(t *testing.T) {
|
||||
// The property routing correctness relies on: an added element is ALWAYS reported present
|
||||
b := util.NewBloomFilter(1000, 0.01)
|
||||
for i := 0; i < 1000; i++ {
|
||||
b.Add(fmt.Sprintf("topic-%d", i))
|
||||
}
|
||||
for i := 0; i < 1000; i++ {
|
||||
require.True(t, b.Contains(fmt.Sprintf("topic-%d", i)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBloomFilter_FalsePositiveRate(t *testing.T) {
|
||||
b := util.NewBloomFilter(10000, 0.01)
|
||||
for i := 0; i < 10000; i++ {
|
||||
b.Add(fmt.Sprintf("added-%d", i))
|
||||
}
|
||||
falsePositives := 0
|
||||
for i := 0; i < 10000; i++ {
|
||||
if b.Contains(fmt.Sprintf("absent-%d", i)) {
|
||||
falsePositives++
|
||||
}
|
||||
}
|
||||
require.Less(t, falsePositives, 300, "expected ~1%% false positives, got %d/10000", falsePositives)
|
||||
}
|
||||
|
||||
func TestBloomFilter_EmptyContainsNothing(t *testing.T) {
|
||||
b := util.NewBloomFilter(100, 0.01)
|
||||
require.False(t, b.Contains("anything"))
|
||||
}
|
||||
|
||||
func TestBloomFilter_MarshalRoundTrip(t *testing.T) {
|
||||
b := util.NewBloomFilter(500, 0.01)
|
||||
for i := 0; i < 500; i++ {
|
||||
b.Add(fmt.Sprintf("topic-%d", i))
|
||||
}
|
||||
data, err := b.MarshalBinary()
|
||||
require.Nil(t, err)
|
||||
b2, err := util.UnmarshalBloomFilter(data)
|
||||
require.Nil(t, err)
|
||||
for i := 0; i < 500; i++ {
|
||||
require.True(t, b2.Contains(fmt.Sprintf("topic-%d", i)))
|
||||
}
|
||||
require.False(t, b2.Contains("never-added-topic"))
|
||||
}
|
||||
|
||||
func TestBloomFilter_UnmarshalGarbage(t *testing.T) {
|
||||
_, err := util.UnmarshalBloomFilter([]byte{})
|
||||
require.Error(t, err)
|
||||
_, err = util.UnmarshalBloomFilter([]byte{1, 2, 3})
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LingerQueue is a bounded, non-blocking batching queue: enqueued elements are emitted as
|
||||
// batches once a linger window expires, a batch count cap is reached, or a batch size cap is
|
||||
// reached, whichever comes first. Unlike BatchingQueue, producers never block: TryEnqueue drops
|
||||
// (returns false) when the queue is full, and Close flushes the remainder and terminates the
|
||||
// consumer channel, so per-entity queues can be created and destroyed dynamically.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// q := NewLingerQueue[int](64, 10, 0, nil, 500*time.Millisecond)
|
||||
// go func() {
|
||||
// for batch := range q.Dequeue() {
|
||||
// send(batch)
|
||||
// }
|
||||
// }()
|
||||
// q.TryEnqueue(1)
|
||||
// q.TryEnqueue(2) // emitted together as [1, 2] after <= 500ms
|
||||
type LingerQueue[T any] struct {
|
||||
in chan T
|
||||
out chan []T
|
||||
max int // max elements per batch
|
||||
maxSize int // max cumulative size per batch; 0 = no size cap
|
||||
size func(T) int // element size function; nil = no size cap
|
||||
linger time.Duration // max time the first element of a batch waits; 0 = emit immediately
|
||||
closed bool
|
||||
mu sync.Mutex // Protects closed, and guards TryEnqueue's send against Close's close(in)
|
||||
}
|
||||
|
||||
// NewLingerQueue creates a LingerQueue holding up to capacity queued elements, emitting batches
|
||||
// of up to max elements or maxSize cumulative size (as measured by size; pass 0/nil for no size
|
||||
// cap) after at most linger.
|
||||
func NewLingerQueue[T any](capacity, max, maxSize int, size func(T) int, linger time.Duration) *LingerQueue[T] {
|
||||
q := &LingerQueue[T]{
|
||||
in: make(chan T, capacity),
|
||||
out: make(chan []T),
|
||||
max: max,
|
||||
maxSize: maxSize,
|
||||
size: size,
|
||||
linger: linger,
|
||||
}
|
||||
go q.run()
|
||||
return q
|
||||
}
|
||||
|
||||
// TryEnqueue enqueues an element without blocking. It returns false if the queue is full or
|
||||
// closed; the caller decides how to account for the drop.
|
||||
func (q *LingerQueue[T]) TryEnqueue(t T) bool {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if q.closed {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case q.in <- t:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Dequeue returns the channel emitting batches. It is closed after Close, once the remaining
|
||||
// elements have been flushed.
|
||||
func (q *LingerQueue[T]) Dequeue() <-chan []T {
|
||||
return q.out
|
||||
}
|
||||
|
||||
// Close stops the queue: remaining elements are flushed as final batches, then the Dequeue
|
||||
// channel is closed. TryEnqueue returns false after Close. Close is idempotent.
|
||||
func (q *LingerQueue[T]) Close() {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if q.closed {
|
||||
return
|
||||
}
|
||||
q.closed = true
|
||||
close(q.in)
|
||||
}
|
||||
|
||||
// run is the batching loop: it blocks for the first element of a batch, then collects more until
|
||||
// the linger expires or a cap is hit, and emits the batch. It exits once the queue is closed and
|
||||
// drained. Note that receiving from the closed in channel still yields the buffered remainder
|
||||
// before reporting closed, which is what flushes on Close.
|
||||
func (q *LingerQueue[T]) run() {
|
||||
defer close(q.out)
|
||||
for {
|
||||
first, ok := <-q.in
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
batch := []T{first}
|
||||
bytes := q.sizeOf(first)
|
||||
var timeout <-chan time.Time
|
||||
if q.linger > 0 {
|
||||
timeout = time.After(q.linger)
|
||||
}
|
||||
closed := false
|
||||
collect:
|
||||
for len(batch) < q.max && (q.maxSize <= 0 || bytes < q.maxSize) {
|
||||
if timeout == nil {
|
||||
// Zero linger: greedily drain what is immediately available, never wait
|
||||
select {
|
||||
case t, ok := <-q.in:
|
||||
if !ok {
|
||||
closed = true
|
||||
break collect
|
||||
}
|
||||
batch = append(batch, t)
|
||||
bytes += q.sizeOf(t)
|
||||
default:
|
||||
break collect
|
||||
}
|
||||
} else {
|
||||
select {
|
||||
case t, ok := <-q.in:
|
||||
if !ok {
|
||||
closed = true
|
||||
break collect
|
||||
}
|
||||
batch = append(batch, t)
|
||||
bytes += q.sizeOf(t)
|
||||
case <-timeout:
|
||||
break collect
|
||||
}
|
||||
}
|
||||
}
|
||||
q.out <- batch
|
||||
if closed {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q *LingerQueue[T]) sizeOf(t T) int {
|
||||
if q.size == nil {
|
||||
return 0
|
||||
}
|
||||
return q.size(t)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
func TestLingerQueue_LingerFlush(t *testing.T) {
|
||||
// Items enqueued within the linger window are emitted as a single batch when it expires
|
||||
q := util.NewLingerQueue[int](16, 100, 0, nil, 50*time.Millisecond)
|
||||
defer q.Close()
|
||||
require.True(t, q.TryEnqueue(1))
|
||||
require.True(t, q.TryEnqueue(2))
|
||||
require.True(t, q.TryEnqueue(3))
|
||||
start := time.Now()
|
||||
batch := <-q.Dequeue()
|
||||
require.Equal(t, []int{1, 2, 3}, batch)
|
||||
require.GreaterOrEqual(t, time.Since(start), 30*time.Millisecond) // Waited out the linger
|
||||
}
|
||||
|
||||
func TestLingerQueue_MaxBatchFlush(t *testing.T) {
|
||||
// Hitting the count cap flushes early, before the linger expires
|
||||
q := util.NewLingerQueue[int](16, 5, 0, nil, time.Minute)
|
||||
defer q.Close()
|
||||
for i := 0; i < 12; i++ {
|
||||
require.True(t, q.TryEnqueue(i))
|
||||
}
|
||||
require.Len(t, <-q.Dequeue(), 5)
|
||||
require.Len(t, <-q.Dequeue(), 5)
|
||||
q.Close() // Flushes the remainder
|
||||
require.Len(t, <-q.Dequeue(), 2)
|
||||
}
|
||||
|
||||
func TestLingerQueue_SizeCapFlush(t *testing.T) {
|
||||
// Hitting the byte cap flushes early, before count cap or linger
|
||||
q := util.NewLingerQueue(16, 100, 10, func(s string) int { return len(s) }, time.Minute)
|
||||
defer q.Close()
|
||||
require.True(t, q.TryEnqueue("aaaa"))
|
||||
require.True(t, q.TryEnqueue("bbbb"))
|
||||
require.True(t, q.TryEnqueue("cccc")) // 12 bytes >= 10 -> flush
|
||||
batch := <-q.Dequeue()
|
||||
require.Equal(t, []string{"aaaa", "bbbb", "cccc"}, batch)
|
||||
}
|
||||
|
||||
func TestLingerQueue_TryEnqueueFull(t *testing.T) {
|
||||
// A full queue drops (returns false) instead of blocking the producer
|
||||
q := util.NewLingerQueue[int](1, 1, 0, nil, 0)
|
||||
defer q.Close()
|
||||
require.True(t, q.TryEnqueue(1)) // Taken by the batcher, blocks emitting (no consumer)
|
||||
waitForCond(t, func() bool { return q.TryEnqueue(2) }) // Fills the buffer once slot frees
|
||||
require.False(t, q.TryEnqueue(3)) // Buffer full, batcher blocked -> drop
|
||||
}
|
||||
|
||||
func TestLingerQueue_CloseFlushesAndCloses(t *testing.T) {
|
||||
q := util.NewLingerQueue[int](16, 100, 0, nil, time.Minute)
|
||||
require.True(t, q.TryEnqueue(1))
|
||||
require.True(t, q.TryEnqueue(2))
|
||||
q.Close()
|
||||
require.Equal(t, []int{1, 2}, <-q.Dequeue()) // Remainder flushed without waiting out the linger
|
||||
_, ok := <-q.Dequeue()
|
||||
require.False(t, ok) // Channel closed
|
||||
require.False(t, q.TryEnqueue(3))
|
||||
q.Close() // Idempotent
|
||||
}
|
||||
|
||||
func TestLingerQueue_LingerZeroImmediate(t *testing.T) {
|
||||
q := util.NewLingerQueue[int](16, 100, 0, nil, 0)
|
||||
defer q.Close()
|
||||
require.True(t, q.TryEnqueue(1))
|
||||
select {
|
||||
case batch := <-q.Dequeue():
|
||||
require.Contains(t, batch, 1)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected immediate flush with zero linger")
|
||||
}
|
||||
}
|
||||
|
||||
func waitForCond(t *testing.T, f func() bool) {
|
||||
t.Helper()
|
||||
for i := 0; i < 100; i++ {
|
||||
if f() {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("timed out waiting for condition")
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const (
|
||||
loopExecutionLimit = 10_000 // Limit the number of loop executions to prevent execution from taking too long
|
||||
stringLengthLimit = 100_000 // Limit the length of strings to prevent memory issues
|
||||
sliceSizeLimit = 10_000 // Limit the size of slices to prevent memory issues
|
||||
indentSpacesLimit = 100 // Limit indentation width to prevent memory issues; indent allocates spaces*lines bytes
|
||||
)
|
||||
|
||||
// TxtFuncMap produces the function map.
|
||||
|
||||
@@ -116,6 +116,7 @@ func cat(v ...any) string {
|
||||
}
|
||||
|
||||
// indent adds a specified number of spaces at the beginning of each line in a string.
|
||||
// It has a safety limit to prevent excessive memory usage.
|
||||
//
|
||||
// Parameters:
|
||||
// - spaces: The number of spaces to add
|
||||
@@ -123,7 +124,13 @@ func cat(v ...any) string {
|
||||
//
|
||||
// Returns:
|
||||
// - string: The indented string
|
||||
//
|
||||
// Panics:
|
||||
// - If spaces exceeds indentSpacesLimit
|
||||
func indent(spaces int, v string) string {
|
||||
if spaces > indentSpacesLimit {
|
||||
panic(fmt.Sprintf("indent %d exceeds limit of %d", spaces, indentSpacesLimit))
|
||||
}
|
||||
pad := strings.Repeat(" ", spaces)
|
||||
return pad + strings.Replace(v, "\n", "\n"+pad, -1)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -214,6 +215,25 @@ func TestNindent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndentLimit(t *testing.T) {
|
||||
// Indentation beyond a sane width is an amplification attempt: indent allocates
|
||||
// spaces * lines bytes in a single uninterruptible call
|
||||
if err := runt(`{{indent 100 "a"}}`, strings.Repeat(" ", 100)+"a"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
for _, tpl := range []string{
|
||||
`{{indent 101 "a"}}`,
|
||||
`{{nindent 101 "a"}}`,
|
||||
`{{indent 1000000000 "a"}}`,
|
||||
} {
|
||||
if _, err := runRaw(tpl, nil); err == nil {
|
||||
t.Errorf("expected %s to be rejected", tpl)
|
||||
} else if !strings.Contains(err.Error(), "exceeds limit") {
|
||||
t.Errorf("expected limit error for %s, got: %v", tpl, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplace(t *testing.T) {
|
||||
tpl := `{{"I Am Henry VIII" | replace " " "-"}}`
|
||||
if err := runt(tpl, "I-Am-Henry-VIII"); err != nil {
|
||||
|
||||
Generated
+409
-447
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@
|
||||
"signup_error_username_taken": "Username {{username}} is already taken",
|
||||
"signup_error_creation_limit_reached": "Account creation limit reached",
|
||||
"login_title": "Sign in to your ntfy account",
|
||||
"login_form_username_label": "Username or email",
|
||||
"login_form_button_submit": "Sign in",
|
||||
"login_link_signup": "Sign up",
|
||||
"login_link_forgot_password": "Forgot password",
|
||||
|
||||
@@ -9,44 +9,44 @@
|
||||
"action_bar_account": "Konts",
|
||||
"action_bar_profile_title": "Profils",
|
||||
"action_bar_profile_settings": "Iestatījumi",
|
||||
"action_bar_profile_logout": "Iziet",
|
||||
"action_bar_profile_logout": "Atteikties",
|
||||
"nav_button_account": "Konts",
|
||||
"nav_button_settings": "Iestatījumi",
|
||||
"nav_button_documentation": "Dokumentācija",
|
||||
"nav_button_connecting": "savienojas",
|
||||
"notifications_list_item": "Paziņojums",
|
||||
"notifications_delete": "Dzēst",
|
||||
"notifications_delete": "Izdzēst",
|
||||
"notifications_tags": "Birkas",
|
||||
"notifications_example": "Piemērs",
|
||||
"publish_dialog_title_label": "Virsraksts",
|
||||
"publish_dialog_message_label": "Ziņojums",
|
||||
"publish_dialog_tags_label": "Birkas",
|
||||
"publish_dialog_priority_label": "Prioritāte",
|
||||
"publish_dialog_priority_label": "Svarīgums",
|
||||
"publish_dialog_email_label": "E-pasta adrese",
|
||||
"publish_dialog_filename_label": "Datnes nosaukums",
|
||||
"publish_dialog_delay_label": "Aizkave",
|
||||
"publish_dialog_button_cancel": "Atcelt",
|
||||
"publish_dialog_button_send": "Sūtīt",
|
||||
"publish_dialog_button_send": "Nosūtīt",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Atcelt",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Abonēt",
|
||||
"subscribe_dialog_login_password_label": "Parole",
|
||||
"subscribe_dialog_error_user_anonymous": "anonīms lietotājs",
|
||||
"subscribe_dialog_error_user_anonymous": "nezināms lietotājs",
|
||||
"account_basics_title": "Konts",
|
||||
"account_basics_username_title": "Lietotājvārds",
|
||||
"account_basics_password_title": "Parole",
|
||||
"account_basics_phone_numbers_dialog_channel_sms": "Nosūtīt īsziņu",
|
||||
"account_basics_phone_numbers_dialog_channel_call": "Zvanīt",
|
||||
"account_basics_phone_numbers_dialog_channel_sms": "Īsziņa",
|
||||
"account_basics_phone_numbers_dialog_channel_call": "Zvans",
|
||||
"account_usage_title": "Lietojums",
|
||||
"account_usage_unlimited": "Neierobežots",
|
||||
"account_basics_tier_admin": "Administrators",
|
||||
"account_basics_tier_admin": "Pārvaldītājs",
|
||||
"account_basics_tier_basic": "Pamata",
|
||||
"account_basics_tier_free": "Bezmaksas",
|
||||
"account_basics_tier_interval_monthly": "ikmēnesi",
|
||||
"account_basics_tier_interval_monthly": "ik mēnesi",
|
||||
"account_basics_tier_interval_yearly": "katru gadu",
|
||||
"account_basics_tier_change_button": "Mainīt",
|
||||
"account_delete_dialog_label": "Parole",
|
||||
"account_delete_dialog_button_cancel": "Atcelt",
|
||||
"account_upgrade_dialog_interval_monthly": "Ikmēnesi",
|
||||
"account_upgrade_dialog_interval_monthly": "Ik mēnesi",
|
||||
"account_upgrade_dialog_interval_yearly": "Katru gadu",
|
||||
"account_upgrade_dialog_tier_price_per_month": "mēnesī",
|
||||
"account_upgrade_dialog_tier_selected_label": "Atlasīts",
|
||||
@@ -62,25 +62,25 @@
|
||||
"prefs_users_dialog_password_label": "Parole",
|
||||
"prefs_appearance_title": "Izskats",
|
||||
"prefs_appearance_language_title": "Valoda",
|
||||
"prefs_appearance_theme_title": "Motīvs",
|
||||
"prefs_appearance_theme_title": "Krāsu palete",
|
||||
"prefs_reservations_table_topic_header": "Tēma",
|
||||
"prefs_reservations_table_access_header": "Piekļuve",
|
||||
"prefs_reservations_dialog_topic_label": "Tēma",
|
||||
"prefs_reservations_dialog_access_label": "Piekļuve",
|
||||
"priority_min": "minimālā",
|
||||
"priority_low": "zema",
|
||||
"priority_min": "viszemākais",
|
||||
"priority_low": "zems",
|
||||
"priority_default": "noklusējuma",
|
||||
"priority_high": "augsta",
|
||||
"priority_max": "maksimālā",
|
||||
"signup_form_confirm_password": "Atkārtot paroli",
|
||||
"priority_high": "augsts",
|
||||
"priority_max": "visaugstākais",
|
||||
"signup_form_confirm_password": "Apstiprināt paroli",
|
||||
"signup_form_button_submit": "Izveidot kontu",
|
||||
"login_link_signup": "Izveidot kontu",
|
||||
"action_bar_show_menu": "Rādīt izvēlni",
|
||||
"action_bar_logo_alt": "ntfy logotips",
|
||||
"action_bar_reservation_add": "Rezervēt tēmu",
|
||||
"action_bar_reservation_edit": "Mainīt rezervāciju",
|
||||
"action_bar_reservation_delete": "Noņemt rezervāciju",
|
||||
"action_bar_reservation_limit_reached": "Sasniegts limits",
|
||||
"action_bar_reservation_add": "Aizņemt tēmu",
|
||||
"action_bar_reservation_edit": "Mainīt aizņemšanu",
|
||||
"action_bar_reservation_delete": "Noņemt aizņemšanu",
|
||||
"action_bar_reservation_limit_reached": "Sasniegts ierobežojums",
|
||||
"action_bar_mute_notifications": "Apklusināt paziņojumus",
|
||||
"action_bar_sign_up": "Izveidot kontu",
|
||||
"message_bar_publish": "Publicēt ziņojumu",
|
||||
@@ -88,38 +88,39 @@
|
||||
"nav_button_all_notifications": "Visi paziņojumi",
|
||||
"nav_button_publish_message": "Publicēt paziņojumu",
|
||||
"nav_button_muted": "Paziņojumi apklusināti",
|
||||
"alert_notification_permission_required_button": "Dot tagad",
|
||||
"alert_notification_permission_required_button": "Piešķirt tagad",
|
||||
"notifications_list": "Paziņojumu saraksts",
|
||||
"notifications_priority_x": "{{priority}} prioritāte",
|
||||
"notifications_priority_x": "{{priority}} svarīgums",
|
||||
"notifications_new_indicator": "Jauns paziņojums",
|
||||
"notifications_attachment_image": "Pielikuma attēls",
|
||||
"notifications_attachment_copy_url_button": "Kopēt URL adresi",
|
||||
"notifications_attachment_copy_url_button": "Ievietot URL starpliktuvē",
|
||||
"notifications_attachment_open_button": "Atvērt pielikumu",
|
||||
"notifications_attachment_file_image": "attēla datne",
|
||||
"notifications_attachment_file_video": "video datne",
|
||||
"notifications_attachment_file_audio": "audio datne",
|
||||
"notifications_attachment_file_document": "cits datnes tips",
|
||||
"notifications_click_copy_url_button": "Kopēt saiti",
|
||||
"notifications_attachment_file_audio": "skaņas datne",
|
||||
"notifications_attachment_file_document": "cits datnes veids",
|
||||
"notifications_click_copy_url_button": "Ievietot saiti starpliktuvē",
|
||||
"notifications_click_open_button": "Atvērt saiti",
|
||||
"notifications_actions_failed_notification": "Neveiksmīga darbība",
|
||||
"notifications_actions_failed_notification": "Nesekmīga darbība",
|
||||
"publish_dialog_title_no_topic": "Publicēt paziņojumu",
|
||||
"publish_dialog_progress_uploading": "Augšupielādē …",
|
||||
"publish_dialog_message_published": "Paziņojums publicēts",
|
||||
"publish_dialog_message_published": "Paziņojums izdots",
|
||||
"publish_dialog_emoji_picker_show": "Atlasīt emocijzīmi",
|
||||
"publish_dialog_priority_min": "Minimāla prioritāte",
|
||||
"publish_dialog_priority_low": "Zema prioritāte",
|
||||
"publish_dialog_priority_default": "Noklusējuma prioritāte",
|
||||
"publish_dialog_priority_high": "Augsta prioritāte",
|
||||
"publish_dialog_priority_max": "Maksimāla prioritāte",
|
||||
"publish_dialog_base_url_label": "Pakalpojuma URL adrese",
|
||||
"publish_dialog_priority_min": "Viszemākais svarīgums",
|
||||
"publish_dialog_priority_low": "Zems svarīgums",
|
||||
"publish_dialog_priority_default": "Noklusējuma svarīgums",
|
||||
"publish_dialog_priority_high": "Augsts svarīgums",
|
||||
"publish_dialog_priority_max": "Visaugstākais svarīgums",
|
||||
"publish_dialog_base_url_label": "Pakalpojuma URL",
|
||||
"publish_dialog_topic_label": "Tēmas nosaukums",
|
||||
"publish_dialog_topic_reset": "Atiestatīt tēmu",
|
||||
"publish_dialog_click_label": "Klikšķināma URL adrese",
|
||||
"publish_dialog_call_label": "Tālruņa zvans",
|
||||
"publish_dialog_attach_label": "Pielikuma URL adrese",
|
||||
"publish_dialog_attach_label": "Pielikuma URL",
|
||||
"publish_dialog_filename_placeholder": "Pielikuma datnes nosaukums",
|
||||
"publish_dialog_other_features": "Citas funkcijas:",
|
||||
"publish_dialog_other_features": "Citas iespējas:",
|
||||
"publish_dialog_chip_call_label": "Tālruņa zvans",
|
||||
"publish_dialog_chip_delay_label": "Aizkavēt piegādi",
|
||||
"publish_dialog_chip_topic_label": "Mainīt tēmu"
|
||||
"publish_dialog_chip_topic_label": "Mainīt tēmu",
|
||||
"common_close": "Aizvērt"
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@
|
||||
"account_usage_messages_title": "Mensagens publicadas",
|
||||
"account_basics_phone_numbers_dialog_verify_button_sms": "Enviar SMS",
|
||||
"account_basics_tier_change_button": "Mudar",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(com nível {{tier}})",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(com tier {{tier}})",
|
||||
"account_basics_title": "Conta",
|
||||
"account_basics_phone_numbers_no_phone_numbers_yet": "Ainda não há números de telefone",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Gerar nome",
|
||||
@@ -244,7 +244,7 @@
|
||||
"account_usage_title": "Uso",
|
||||
"account_basics_tier_upgrade_button": "Atualizar para Pro",
|
||||
"subscribe_dialog_error_topic_already_reserved": "Tópico já reservado",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(sem nível)",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(sem tier)",
|
||||
"account_basics_tier_payment_overdue": "O teu pagamento está atrasado. Por favor, atualize seu método de pagamento, ou sua conta será rebaixada em breve.",
|
||||
"account_basics_tier_description": "Nível de poder da sua conta",
|
||||
"account_basics_tier_free": "Grátis",
|
||||
@@ -406,5 +406,59 @@
|
||||
"web_push_unknown_notification_title": "Notificação desconhecida recebida do servidor",
|
||||
"web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo o aplicativo da Web",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Um usuário provisionado não pode ser editado ou apagado",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não é possível editar ou apagar o token provisionado"
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não é possível editar ou apagar o token provisionado",
|
||||
"common_refresh": "Atualizar",
|
||||
"email_verify_progress_title": "Verificando seu email...",
|
||||
"email_verify_success_title": "E-mail verificado",
|
||||
"email_verify_success_description": "Seu endereço de e-mail foi verificado e adicionado à sua conta.",
|
||||
"email_verify_error_title": "A verificação falhou",
|
||||
"email_verify_error_description": "Esse link de verificação está inválido ou expirou. Você pode solicitar um novo link nas configurações da sua conta.",
|
||||
"email_verify_button_account": "Ir para conta",
|
||||
"version_update_available_title": "Nova versão disponível",
|
||||
"version_update_available_description": "O servidor do ntfy foi atualizado. Por favor, atualize a página.",
|
||||
"signup_form_email": "Email (opcional, para recuperação de conta)",
|
||||
"login_link_forgot_password": "Esqueci minha senha",
|
||||
"reset_password_request_description": "Insira seu nome de usuário ou endereço de e-mail. Se sua conta existir, um link para redefinir sua senha será enviado pelo e-mail.",
|
||||
"reset_password_request_primary_required": "Isso só funciona se você já tiver adicionado e verificado um endereço de e-mail primário.",
|
||||
"reset_password_request_identifier_label": "Nome de usuário ou e-mail",
|
||||
"reset_password_request_button_submit": "Enviar link de redefinição",
|
||||
"reset_password_sent_title": "Verifique sua caixa de entrada",
|
||||
"reset_password_sent_description": "Se uma conta existir, um link para redefinir sua senha será enviado por e-mail.",
|
||||
"reset_password_back_to_login": "Voltar para login",
|
||||
"reset_password_disabled": "A redefinição de senha está desativada",
|
||||
"reset_password_title": "Definir nova senha",
|
||||
"reset_password_form_password": "Nova senha",
|
||||
"reset_password_form_confirm": "Confirmar nova senha",
|
||||
"reset_password_form_button_submit": "Definir senha",
|
||||
"reset_password_form_error_invalid": "Esse link de redefinição está inválido ou expirou. Por favor, peça um novo.",
|
||||
"reset_password_success_title": "Senha atualizada",
|
||||
"reset_password_success_description": "Sua senha mudou. Agora você pode entrar com sua nova senha.",
|
||||
"action_bar_reload": "Atualizar app",
|
||||
"account_basics_emails_title": "Endereços de e-mail",
|
||||
"account_basics_emails_description": "Para notificações de e-mail e redefinição de senha",
|
||||
"account_basics_emails_no_emails_yet": "Nenhum e-mail por enquanto",
|
||||
"account_basics_emails_copied_to_clipboard": "Endereço de e-mail copiado pra área de transferência",
|
||||
"account_basics_emails_chip_actions_verified": "Pode ser usado para notificações. Clique para ver ações.",
|
||||
"account_basics_emails_chip_actions_unverified": "E-mail não verificado, confira sua caixa de entrada para verificar. Clique para ver ações.",
|
||||
"account_basics_emails_unverified": "não verificado",
|
||||
"account_basics_emails_set_primary": "Definir como e-mail primário",
|
||||
"account_basics_emails_delete": "Remover e-mail",
|
||||
"account_basics_emails_resend": "Reenviar e-mail de verificação",
|
||||
"account_basics_emails_resent": "E-mail de verificação enviado, confira sua caixa de entrada",
|
||||
"account_basics_emails_primary_elsewhere": "Esse endereço de e-mail é usado como e-mail primário em outra conta",
|
||||
"account_basics_emails_no_recovery_warning": "Adicione pelo menos um endereço de e-mail para garantir que você possa recuperar sua conta caso perca sua senha.",
|
||||
"account_basics_emails_no_primary_warning": "Adicione um endereço de email primário para garantir que você possa recuperar sua conta caso perca sua senha.",
|
||||
"account_basics_emails_dialog_title": "Adicionar endereço de e-mail",
|
||||
"account_basics_emails_dialog_description": "Insira um endereço de e-mail para adicionar à sua conta. Um link de verificação será enviado pra confirmar seu e-mail.",
|
||||
"account_basics_emails_dialog_email_label": "Endereço de e-mail",
|
||||
"account_basics_emails_dialog_email_placeholder": "exemplo: usuário@exemplo.com",
|
||||
"account_basics_emails_dialog_verify_button": "Enviar link de verificação",
|
||||
"account_basics_emails_dialog_check_inbox": "Verifique sua caixa de entrada e clique no link de verificação para confirmar esse endereço de e-mail. Ele aparecerá como não verificado até que você o verifique.",
|
||||
"account_usage_emails_none": "Nenhuma notificação de e-mail pode ser enviada com essa conta",
|
||||
"prefs_users_dialog_base_url_invalid": "Formato de URL inválido. Deve começar com http:// ou https://",
|
||||
"prefs_users_dialog_base_url_exists": "Um usuário para esse serviço de URL já existe",
|
||||
"common_close": "Fechar",
|
||||
"reset_password_request_title": "Redefinir senha",
|
||||
"account_basics_emails_chip_actions_primary": "E-mail primário, usado como seu endereço de e-mail padrão. Clique para ver ações.",
|
||||
"account_basics_tier_provisioned": "Provisionado"
|
||||
}
|
||||
|
||||
@@ -405,5 +405,6 @@
|
||||
"prefs_appearance_theme_dark": "Тёмная",
|
||||
"prefs_appearance_theme_light": "Светлая",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Пользователя, созданного автоматически, нельзя изменить или удалить",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Автоматически созданный токен нельзя изменить или удалить"
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Автоматически созданный токен нельзя изменить или удалить",
|
||||
"common_refresh": "Обновить"
|
||||
}
|
||||
|
||||
@@ -236,8 +236,8 @@
|
||||
"account_basics_tier_upgrade_button": "Pro'ya yükselt",
|
||||
"account_basics_tier_change_button": "Değiştir",
|
||||
"account_basics_tier_paid_until": "Abonelik {{date}} tarihine kadar ödendi ve otomatik olarak yenilenecek",
|
||||
"account_basics_tier_admin_suffix_with_tier": "({{tier}} seviyesiyle)",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(seviye yok)",
|
||||
"account_basics_tier_admin_suffix_with_tier": "{{tier}} seviyesiyle",
|
||||
"account_basics_tier_admin_suffix_no_tier": "seviye yok",
|
||||
"account_basics_tier_manage_billing_button": "Faturalandırmayı yönet",
|
||||
"account_usage_reservations_title": "Ayırtılan konular",
|
||||
"account_usage_reservations_none": "Bu hesap için ayırtılan konu yok",
|
||||
@@ -405,5 +405,59 @@
|
||||
"web_push_unknown_notification_body": "Web uygulamasını açarak ntfy'yi güncellemeniz gerekebilir",
|
||||
"subscribe_dialog_subscribe_use_another_background_info": "Web uygulaması açık değilken diğer sunuculardan gelen bildirimler alınmayacaktır",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Yetkilendirilmiş kullanıcı düzenlenemez veya silinemez",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Sağlanmış belirteci düzenleyemez veya silemezsiniz"
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Sağlanmış belirteci düzenleyemez veya silemezsiniz",
|
||||
"common_close": "Kapat",
|
||||
"common_refresh": "Yenile",
|
||||
"email_verify_progress_title": "E-posta adresiniz doğrulanıyor...",
|
||||
"email_verify_success_title": "E-posta adresi doğrulandı",
|
||||
"email_verify_success_description": "E-posta adresiniz doğrulandı ve hesabınıza eklendi.",
|
||||
"email_verify_error_title": "Doğrulama başarısız",
|
||||
"email_verify_error_description": "Bu doğrulama bağlantısı geçersiz veya süresi doldu. Hesap ayarlarınızdan yeni bir bağlantı isteyebilirsiniz.",
|
||||
"email_verify_button_account": "Hesaba git",
|
||||
"version_update_available_title": "Yeni sürüm var",
|
||||
"version_update_available_description": "ntfy sunucusu güncellendi. Lütfen sayfayı yenileyin.",
|
||||
"signup_form_email": "E-posta adresi (isteğe bağlı, hesap kurtarma için)",
|
||||
"login_link_forgot_password": "Parolayı unuttum",
|
||||
"reset_password_request_title": "Parola sıfırla",
|
||||
"reset_password_request_description": "Kullanıcı adınızı veya e-posta adresinizi girin. Hesabınız varsa, parolanızı sıfırlamanız için e-posta ile bir bağlantı gönderilecek.",
|
||||
"reset_password_request_primary_required": "Bu, yalnızca birincil e-posta adresinizi eklediyseniz ve doğruladıysanız çalışır.",
|
||||
"reset_password_request_identifier_label": "Kullanıcı adı veya e-posta adresi",
|
||||
"reset_password_request_button_submit": "Sıfırlama bağlantısı gönder",
|
||||
"reset_password_sent_title": "Gelen kutunuza bakın",
|
||||
"reset_password_sent_description": "Hesabınız varsa, parolanızı sıfırlamanız için e-posta ile bir bağlantı gönderildi.",
|
||||
"reset_password_back_to_login": "Oturum açmaya dön",
|
||||
"reset_password_disabled": "Parola sıfırlama devre dışı",
|
||||
"reset_password_title": "Yeni parola belirle",
|
||||
"reset_password_form_password": "Yeni parola",
|
||||
"reset_password_form_confirm": "Yeni parolayı onayla",
|
||||
"reset_password_form_button_submit": "Parola belirle",
|
||||
"reset_password_form_error_invalid": "Bu sıfırlama bağlantısı geçersiz veya süresi doldu. Lütfen yeni bir bağlantı isteyin.",
|
||||
"reset_password_success_title": "Parola değiştirildi",
|
||||
"reset_password_success_description": "Parolanız değiştirildi. Artık yeni parolanızla oturum açabilirsiniz.",
|
||||
"action_bar_reload": "Uygulamayı yeniden yükle",
|
||||
"account_basics_emails_title": "E-posta adresleri",
|
||||
"account_basics_emails_description": "E-posta bildirimleri ve parola sıfırlama için",
|
||||
"account_basics_emails_no_emails_yet": "Henüz e-posta yok",
|
||||
"account_basics_emails_copied_to_clipboard": "E-posta adresi panoya kopyalandı",
|
||||
"account_basics_emails_chip_actions_primary": "Öntanımlı e-posta adresiniz olarak kullanılan birincil adres. Eylemler için tıklayın.",
|
||||
"account_basics_emails_chip_actions_verified": "Bildirimler için kullanılabilir. Eylemler için tıklayın.",
|
||||
"account_basics_emails_chip_actions_unverified": "Adres doğrulanmadı, doğrulamak için gelen kutunuza bakın. Eylemler için tıklayın.",
|
||||
"account_basics_emails_unverified": "doğrulanmadı",
|
||||
"account_basics_emails_set_primary": "Birincil e-posta adresi olarak ayarla",
|
||||
"account_basics_emails_delete": "Adresi kaldır",
|
||||
"account_basics_emails_resend": "Doğrulama e-postasını yeniden gönder",
|
||||
"account_basics_emails_resent": "Doğrulama e-postası gönderildi, gelen kutunuza bakın",
|
||||
"account_basics_emails_primary_elsewhere": "Bu e-posta adresi başka bir hesapta birincil adres olarak kullanılıyor",
|
||||
"account_basics_emails_no_recovery_warning": "Parolanızı kaybettiğinizde hesabınızı kurtarabilmek için en az bir e-posta adresi ekleyin.",
|
||||
"account_basics_emails_no_primary_warning": "Parolanızı kaybettiğinizde hesabınızı kurtarabilmek için birincil e-posta adresi ekleyin.",
|
||||
"account_basics_emails_dialog_title": "E-posta adresi ekle",
|
||||
"account_basics_emails_dialog_description": "Hesabınıza eklemek için bir e-posta adresi girin. Adresin size ait olduğunu doğrulamak için bir doğrulama bağlantısı gönderilecek.",
|
||||
"account_basics_emails_dialog_email_label": "E-posta adresi",
|
||||
"account_basics_emails_dialog_email_placeholder": "örn. user@example.com",
|
||||
"account_basics_emails_dialog_verify_button": "Doğrulama bağlantısı gönder",
|
||||
"account_basics_emails_dialog_check_inbox": "Gelen kutunuza bakın ve bu e-posta adresini onaylamak için doğrulama bağlantısına tıklayın. Onaylayana kadar adresiniz doğrulanmadı olarak görünecek.",
|
||||
"account_basics_tier_provisioned": "Yetkilendirildi",
|
||||
"account_usage_emails_none": "Bu hesapla e-posta bildirimi gönderilemez",
|
||||
"prefs_users_dialog_base_url_invalid": "Geçersiz URL biçimi. http:// veya https:// ile başlamalıdır",
|
||||
"prefs_users_dialog_base_url_exists": "Bu hizmet URL'si için zaten bir kullanıcı var"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user