From 447842f86014e8421831a9ea354fdb4f1dd32a42 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Tue, 22 Sep 2026 22:59:08 -0700 Subject: [PATCH] p1.1: dynamic self-IP deny (DDNS/trace, keep previous, atomic swap) Discover WAN via ddns: then trace: before listen. Persist to self_ip.json. Previous addresses stay denied for keep_previous. Failure keeps the last known set. /healthz reports age and source. --- cmd/sift/serve.go | 55 ++++- dist/sift.toml.example | 6 + internal/api/api.go | 41 ++-- internal/config/config.go | 51 +++++ internal/guard/guard.go | 37 +++ internal/selfip/selfip.go | 397 +++++++++++++++++++++++++++++++++ internal/selfip/selfip_test.go | 168 ++++++++++++++ nix/module.nix | 32 ++- 8 files changed, 767 insertions(+), 20 deletions(-) create mode 100644 internal/config/config.go create mode 100644 internal/selfip/selfip.go create mode 100644 internal/selfip/selfip_test.go diff --git a/cmd/sift/serve.go b/cmd/sift/serve.go index 71b3483..4771bdc 100644 --- a/cmd/sift/serve.go +++ b/cmd/sift/serve.go @@ -1,6 +1,7 @@ package main import ( + "context" "flag" "log/slog" "net" @@ -12,8 +13,10 @@ import ( "git.wntrmute.dev/kyle/sift/internal/api" "git.wntrmute.dev/kyle/sift/internal/auth" + "git.wntrmute.dev/kyle/sift/internal/config" "git.wntrmute.dev/kyle/sift/internal/fetch" "git.wntrmute.dev/kyle/sift/internal/guard" + "git.wntrmute.dev/kyle/sift/internal/selfip" ) type denyFlags []netip.Prefix @@ -54,12 +57,30 @@ func serveMain() { registerTestFlags(fs, g) _ = fs.Parse(os.Args[1:]) - g.DenyPrefixes = deny log := slog.New(slog.NewTextHandler(os.Stdout, nil)) + cfg, err := config.Load(*configPath) + if err != nil { + log.Error("config", "err", err, "path", *configPath) + os.Exit(1) + } + if *listen == "127.0.0.1:8890" && cfg.Listen != "" { + *listen = cfg.Listen + } + if *ua == "sift/"+version && cfg.UserAgent != "" { + *ua = cfg.UserAgent + } + for _, s := range cfg.DenyPrefixes { + p, err := netip.ParsePrefix(s) + if err != nil { + log.Error("deny_prefixes", "err", err, "value", s) + os.Exit(1) + } + deny = append(deny, p) + } + g.DenyPrefixes = deny if n := len(deny); n > 0 { log.Info("deny prefixes loaded", "count", n) } - _ = configPath store, err := auth.Load(*keysPath) if err != nil { @@ -68,9 +89,33 @@ func serveMain() { } fetcher := &fetch.Fetcher{Guard: g, UA: *ua} - srv := api.New(version, g, fetcher, store, log) - hs := srv.HTTPServer(*listen) + parent, stop := context.WithCancel(context.Background()) + defer stop() + srv := api.New(version, g, fetcher, store, log) + if len(cfg.SelfIP.Sources) > 0 { + disc := &selfip.Discoverer{ + Cfg: selfip.Config{ + Sources: cfg.SelfIP.Sources, + Interval: config.ParseDuration(cfg.SelfIP.Interval, selfip.DefaultInterval), + KeepPrevious: config.ParseDuration(cfg.SelfIP.KeepPrevious, selfip.DefaultKeepPrevious), + Path: cfg.SelfIP.Path, + }, + Guard: g, + Log: log, + Get: func(ctx context.Context, u string) ([]byte, error) { + res, err := fetcher.Fetch(ctx, u) + if err != nil { + return nil, err + } + return res.Body, nil + }, + } + disc.Start(parent) + srv.SelfIP = disc + } + + hs := srv.HTTPServer(*listen) ln, err := net.Listen("tcp", *listen) if err != nil { log.Error("listen", "err", err) @@ -96,8 +141,10 @@ func serveMain() { signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) select { case <-sig: + stop() _ = hs.Close() case err := <-errCh: + stop() if err != nil { log.Error("serve", "err", err) os.Exit(1) diff --git a/dist/sift.toml.example b/dist/sift.toml.example index ddc68bc..7d5b9cc 100644 --- a/dist/sift.toml.example +++ b/dist/sift.toml.example @@ -15,6 +15,12 @@ listen = "127.0.0.1:8890" # user_agent = "sift/dev" +# [self_ip] +# sources = ["ddns:example.sn.mynetname.net", "trace:https://1.1.1.1/cdn-cgi/trace"] +# interval = "10m" +# keep_previous = "24h" +# path = "/var/lib/sift/self_ip.json" + # [digest] # model = "ornith-1.5-9b-uncensored" # thinking = false diff --git a/internal/api/api.go b/internal/api/api.go index 91668a7..c6fa740 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -18,6 +18,7 @@ import ( "git.wntrmute.dev/kyle/sift/internal/clean" "git.wntrmute.dev/kyle/sift/internal/fetch" "git.wntrmute.dev/kyle/sift/internal/guard" + "git.wntrmute.dev/kyle/sift/internal/selfip" ) const maxReqBody = 64 << 10 @@ -30,6 +31,7 @@ type Server struct { Auth *auth.Store Mux *http.ServeMux OutCap int + SelfIP *selfip.Discoverer global chan struct{} perMu sync.Mutex @@ -80,30 +82,39 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "version": s.Version}) + out := map[string]any{"status": "ok", "version": s.Version} + if s.SelfIP != nil { + out["self_ip"] = s.SelfIP.Status() + } + _ = json.NewEncoder(w).Encode(out) } func (s *Server) metrics(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") + fails := uint64(0) + if s.SelfIP != nil { + fails = s.SelfIP.Failures.Load() + } _ = json.NewEncoder(w).Encode(map[string]any{ - "requests": s.reqN.Load(), - "blocks": s.blockN.Load(), - "scrapes": s.scrapeN.Load(), + "requests": s.reqN.Load(), + "blocks": s.blockN.Load(), + "scrapes": s.scrapeN.Load(), + "self_ip_failures": fails, }) } type scrapeReq struct { - URL string `json:"url"` - Formats []string `json:"formats"` - OnlyMainContent *bool `json:"onlyMainContent"` - SkipTlsVerification *bool `json:"skipTlsVerification"` - RemoveBase64Images *bool `json:"removeBase64Images"` - FastMode *bool `json:"fastMode"` - BlockAds *bool `json:"blockAds"` - StoreInCache *bool `json:"storeInCache"` - MaxAge *int `json:"maxAge"` - Mobile *bool `json:"mobile"` - Origin string `json:"origin"` + URL string `json:"url"` + Formats []string `json:"formats"` + OnlyMainContent *bool `json:"onlyMainContent"` + SkipTlsVerification *bool `json:"skipTlsVerification"` + RemoveBase64Images *bool `json:"removeBase64Images"` + FastMode *bool `json:"fastMode"` + BlockAds *bool `json:"blockAds"` + StoreInCache *bool `json:"storeInCache"` + MaxAge *int `json:"maxAge"` + Mobile *bool `json:"mobile"` + Origin string `json:"origin"` } type v1Req struct { diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..ee624c8 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,51 @@ +package config + +import ( + "os" + "time" + + "github.com/pelletier/go-toml/v2" +) + +// File is the on-disk /etc/sift/config.toml. +type File struct { + Listen string `toml:"listen"` + UserAgent string `toml:"user_agent"` + DenyPrefixes []string `toml:"deny_prefixes"` + SelfIP SelfIP `toml:"self_ip"` +} + +// SelfIP is the [self_ip] block. +type SelfIP struct { + Sources []string `toml:"sources"` + Interval string `toml:"interval"` + KeepPrevious string `toml:"keep_previous"` + Path string `toml:"path"` +} + +// Load reads path. A missing file returns a zero File and a nil error. +func Load(path string) (File, error) { + var f File + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return f, nil + } + return f, err + } + if err := toml.Unmarshal(b, &f); err != nil { + return File{}, err + } + return f, nil +} + +func ParseDuration(s string, def time.Duration) time.Duration { + if s == "" { + return def + } + d, err := time.ParseDuration(s) + if err != nil { + return def + } + return d +} diff --git a/internal/guard/guard.go b/internal/guard/guard.go index 3ccad98..0e391a7 100644 --- a/internal/guard/guard.go +++ b/internal/guard/guard.go @@ -26,6 +26,7 @@ import ( "net/url" "strconv" "strings" + "sync/atomic" "syscall" "time" ) @@ -45,6 +46,9 @@ type Guard struct { // AllowPrefixes bypasses the predicate. Production config never sets this; // only tests and the sifttest-tagged -test-allow-prefix flag do. AllowPrefixes []netip.Prefix + + // self is the discovered WAN deny set (v4 /32, v6 /64), swapped atomically. + self atomic.Pointer[[]netip.Prefix] } func (g *Guard) allows(addr netip.Addr) bool { @@ -273,6 +277,13 @@ func (g *Guard) CheckAddr(addr netip.Addr) error { return blockedAddr("denied prefix", addr) } } + if extras := g.self.Load(); extras != nil { + for _, p := range *extras { + if p.Contains(addr) { + return blockedAddr("denied prefix", addr) + } + } + } } switch { @@ -436,3 +447,29 @@ func (g *Guard) Client() *http.Client { Timeout: 20 * time.Second, } } + +// SetSelfDeny atomically replaces the discovered WAN deny prefixes. +// A nil or empty list clears the extra set; static DenyPrefixes are unchanged. +func (g *Guard) SetSelfDeny(prefixes []netip.Prefix) { + if g == nil { + return + } + if len(prefixes) == 0 { + g.self.Store(nil) + return + } + cp := append([]netip.Prefix(nil), prefixes...) + g.self.Store(&cp) +} + +// SelfDeny returns a copy of the current discovered deny prefixes. +func (g *Guard) SelfDeny() []netip.Prefix { + if g == nil { + return nil + } + p := g.self.Load() + if p == nil { + return nil + } + return append([]netip.Prefix(nil), *p...) +} diff --git a/internal/selfip/selfip.go b/internal/selfip/selfip.go new file mode 100644 index 0000000..f89932c --- /dev/null +++ b/internal/selfip/selfip.go @@ -0,0 +1,397 @@ +// Package selfip discovers the host's current WAN address so the SSRF +// guard can deny hairpin-NAT fetches of the residential IP. +package selfip + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net" + "net/netip" + "os" + "strings" + "sync" + "sync/atomic" + "time" + + "git.wntrmute.dev/kyle/sift/internal/guard" +) + +const ( + DefaultInterval = 10 * time.Minute + DefaultKeepPrevious = 24 * time.Hour + DefaultPath = "/var/lib/sift/self_ip.json" + DefaultStartupWait = 10 * time.Second + DefaultTrace = "trace:https://1.1.1.1/cdn-cgi/trace" +) + +// LookupIP resolves a hostname. Tests inject this. +type LookupIP = guard.LookupIP + +// GetFunc GETs a URL (must go through the guarded fetcher) and returns the body. +type GetFunc func(ctx context.Context, rawURL string) ([]byte, error) + +// Entry is one observed self address. +type Entry struct { + Addr string `json:"addr"` + Source string `json:"source"` + Seen time.Time `json:"seen"` +} + +type file struct { + Entries []Entry `json:"entries"` +} + +// Status is the /healthz self_ip object. +type Status struct { + Addrs []string `json:"addrs"` + Age string `json:"age"` + Source string `json:"source"` + At time.Time `json:"at,omitempty"` +} + +// Config is the [self_ip] block. +type Config struct { + Sources []string + Interval time.Duration + KeepPrevious time.Duration + Path string + StartupWait time.Duration +} + +// Discoverer updates Guard.SetSelfDeny from DDNS and/or a Cloudflare-style trace. +type Discoverer struct { + Cfg Config + Lookup LookupIP + Get GetFunc + Log *slog.Logger + Guard *guard.Guard + + Failures atomic.Uint64 + now func() time.Time + + mu sync.Mutex + entries []Entry + lastOK time.Time + lastSource string +} + +func (d *Discoverer) clock() time.Time { + if d != nil && d.now != nil { + return d.now() + } + return time.Now() +} + +func (d *Discoverer) log() *slog.Logger { + if d != nil && d.Log != nil { + return d.Log + } + return slog.Default() +} + +func (d *Discoverer) interval() time.Duration { + if d.Cfg.Interval > 0 { + return d.Cfg.Interval + } + return DefaultInterval +} + +func (d *Discoverer) keep() time.Duration { + if d.Cfg.KeepPrevious > 0 { + return d.Cfg.KeepPrevious + } + return DefaultKeepPrevious +} + +func (d *Discoverer) path() string { + if d.Cfg.Path != "" { + return d.Cfg.Path + } + return DefaultPath +} + +func (d *Discoverer) sources() []string { + if len(d.Cfg.Sources) > 0 { + return d.Cfg.Sources + } + return []string{DefaultTrace} +} + +// Load reads the persist file. Missing file is not an error. +func (d *Discoverer) Load() error { + b, err := os.ReadFile(d.path()) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + var f file + if err := json.Unmarshal(b, &f); err != nil { + return err + } + d.mu.Lock() + d.entries = f.Entries + if len(d.entries) > 0 { + d.lastOK = d.entries[len(d.entries)-1].Seen + d.lastSource = d.entries[len(d.entries)-1].Source + } + d.mu.Unlock() + d.applyLocked() + return nil +} + +// Persist writes the current entries. +func (d *Discoverer) Persist() error { + d.mu.Lock() + ents := append([]Entry(nil), d.entries...) + d.mu.Unlock() + b, err := json.MarshalIndent(file{Entries: ents}, "", " ") + if err != nil { + return err + } + tmp := d.path() + ".tmp" + if err := os.WriteFile(tmp, b, 0o640); err != nil { + return err + } + return os.Rename(tmp, d.path()) +} + +// Status is safe for /healthz. +func (d *Discoverer) Status() Status { + if d == nil { + return Status{} + } + d.mu.Lock() + defer d.mu.Unlock() + st := Status{Source: d.lastSource, At: d.lastOK} + seen := map[string]struct{}{} + for _, e := range d.entries { + p := prefixOf(e.Addr) + if p == "" { + continue + } + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + st.Addrs = append(st.Addrs, p) + } + if !d.lastOK.IsZero() { + st.Age = d.clock().Sub(d.lastOK).Truncate(time.Second).String() + } + return st +} + +// RunOnce tries each source in order and unions successful global-unicast results. +// On total failure it leaves the existing set in place, counts a failure, and returns the error. +func (d *Discoverer) RunOnce(ctx context.Context) error { + now := d.clock() + var got []Entry + var first string + var lastErr error + for _, src := range d.sources() { + addrs, err := d.resolve(ctx, src) + if err != nil { + lastErr = err + continue + } + for _, a := range addrs { + if !usable(a) { + continue + } + e := Entry{Addr: a.Unmap().String(), Source: src, Seen: now} + got = append(got, e) + if first == "" { + first = src + } + } + } + if len(got) == 0 { + d.Failures.Add(1) + d.log().Warn("self_ip discovery failed", "err", lastErr) + d.applyLocked() + return fmt.Errorf("self_ip: no address discovered: %v", lastErr) + } + d.mu.Lock() + d.entries = merge(d.entries, got, now, d.keep()) + d.lastOK = now + d.lastSource = first + d.mu.Unlock() + d.applyLocked() + return nil +} + +// Start loads persist, runs one discovery with a bounded wait, applies, then ticks. +// The listener must not open until Start returns (the first RunOnce). +func (d *Discoverer) Start(parent context.Context) { + if err := d.Load(); err != nil { + d.log().Warn("self_ip load", "err", err) + } else { + d.applyLocked() + } + wait := d.Cfg.StartupWait + if wait <= 0 { + wait = DefaultStartupWait + } + ctx, cancel := context.WithTimeout(parent, wait) + err := d.RunOnce(ctx) + cancel() + if err != nil { + d.log().Warn("self_ip startup", "err", err) + } + if err := d.Persist(); err != nil { + d.log().Warn("self_ip persist", "err", err) + } + go d.loop(parent) +} + +func (d *Discoverer) loop(ctx context.Context) { + t := time.NewTicker(d.interval()) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + c, cancel := context.WithTimeout(ctx, DefaultStartupWait) + err := d.RunOnce(c) + cancel() + if err != nil { + continue + } + if err := d.Persist(); err != nil { + d.log().Warn("self_ip persist", "err", err) + } + } + } +} + +func (d *Discoverer) applyLocked() { + if d.Guard == nil { + return + } + d.mu.Lock() + ents := append([]Entry(nil), d.entries...) + d.mu.Unlock() + var prefixes []netip.Prefix + seen := map[string]struct{}{} + for _, e := range ents { + p, err := netip.ParsePrefix(prefixOf(e.Addr)) + if err != nil { + continue + } + s := p.String() + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + prefixes = append(prefixes, p) + } + d.Guard.SetSelfDeny(prefixes) +} + +func (d *Discoverer) resolve(ctx context.Context, src string) ([]netip.Addr, error) { + switch { + case strings.HasPrefix(src, "ddns:"): + host := strings.TrimPrefix(src, "ddns:") + if host == "" { + return nil, fmt.Errorf("empty ddns host") + } + lookup := d.Lookup + if lookup == nil { + lookup = func(ctx context.Context, h string) ([]netip.Addr, error) { + return net.DefaultResolver.LookupNetIP(ctx, "ip", h) + } + } + return lookup(ctx, host) + case strings.HasPrefix(src, "trace:"): + raw := strings.TrimPrefix(src, "trace:") + if d.Get == nil { + return nil, fmt.Errorf("no trace getter") + } + body, err := d.Get(ctx, raw) + if err != nil { + return nil, err + } + a, err := parseTrace(string(body)) + if err != nil { + return nil, err + } + return []netip.Addr{a}, nil + default: + return nil, fmt.Errorf("unknown self_ip source %q", src) + } +} + +func parseTrace(body string) (netip.Addr, error) { + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + k, v, ok := strings.Cut(line, "=") + if !ok || k != "ip" { + continue + } + a, err := netip.ParseAddr(strings.TrimSpace(v)) + if err != nil { + return netip.Addr{}, fmt.Errorf("trace ip=: %w", err) + } + return a.Unmap(), nil + } + return netip.Addr{}, fmt.Errorf("trace: no ip= line") +} + +func usable(a netip.Addr) bool { + if !a.IsValid() { + return false + } + a = a.Unmap() + if !a.IsGlobalUnicast() || a.IsPrivate() { + return false + } + return true +} + +func prefixOf(addr string) string { + a, err := netip.ParseAddr(addr) + if err != nil { + return "" + } + a = a.Unmap() + bits := 32 + if a.Is6() { + bits = 64 + } + p, err := a.Prefix(bits) + if err != nil { + return "" + } + return p.String() +} + +func prune(ents []Entry, now time.Time, keep time.Duration) []Entry { + out := ents[:0] + for _, e := range ents { + if keep > 0 && now.Sub(e.Seen) > keep { + continue + } + out = append(out, e) + } + return append([]Entry(nil), out...) +} + +func merge(old, got []Entry, now time.Time, keep time.Duration) []Entry { + byAddr := map[string]Entry{} + for _, e := range prune(old, now, keep) { + byAddr[e.Addr] = e + } + for _, e := range got { + byAddr[e.Addr] = e + } + out := make([]Entry, 0, len(byAddr)) + for _, e := range byAddr { + out = append(out, e) + } + return out +} diff --git a/internal/selfip/selfip_test.go b/internal/selfip/selfip_test.go new file mode 100644 index 0000000..0fc0b86 --- /dev/null +++ b/internal/selfip/selfip_test.go @@ -0,0 +1,168 @@ +package selfip + +import ( + "context" + "encoding/json" + "errors" + "net/netip" + "os" + "path/filepath" + "testing" + "time" + + "git.wntrmute.dev/kyle/sift/internal/guard" +) + +func TestAThenBBothDeniedUntilKeepExpires(t *testing.T) { + g := &guard.Guard{} + now := time.Date(2026, 9, 22, 12, 0, 0, 0, time.UTC) + dir := t.TempDir() + a := netip.MustParseAddr("8.8.8.8") + b := netip.MustParseAddr("1.1.1.1") + cur := a + d := &Discoverer{ + Cfg: Config{ + Sources: []string{"ddns:wan.example"}, + KeepPrevious: 24 * time.Hour, + Path: filepath.Join(dir, "self_ip.json"), + StartupWait: time.Second, + }, + Guard: g, + Lookup: func(ctx context.Context, host string) ([]netip.Addr, error) { + return []netip.Addr{cur}, nil + }, + } + d.now = func() time.Time { return now } + + if err := d.RunOnce(context.Background()); err != nil { + t.Fatal(err) + } + if err := g.CheckAddr(a); err == nil { + t.Fatal("A should be denied") + } + if err := g.CheckAddr(b); err != nil { + t.Fatalf("B should still pass: %v", err) + } + + now = now.Add(time.Minute) + cur = b + if err := d.RunOnce(context.Background()); err != nil { + t.Fatal(err) + } + if err := g.CheckAddr(a); err == nil { + t.Fatal("A should still be denied during keep_previous") + } + if err := g.CheckAddr(b); err == nil { + t.Fatal("B should be denied") + } + + now = now.Add(25 * time.Hour) + if err := d.RunOnce(context.Background()); err != nil { + t.Fatal(err) + } + if err := g.CheckAddr(a); err != nil { + t.Fatalf("A should have aged out: %v", err) + } + if err := g.CheckAddr(b); err == nil { + t.Fatal("B should still be denied") + } +} + +func TestNonGlobalIgnored(t *testing.T) { + g := &guard.Guard{} + d := &Discoverer{ + Cfg: Config{Sources: []string{"ddns:wan.example"}, KeepPrevious: time.Hour}, + Guard: g, + Lookup: func(ctx context.Context, host string) ([]netip.Addr, error) { + return []netip.Addr{ + netip.MustParseAddr("10.0.0.1"), + netip.MustParseAddr("127.0.0.1"), + netip.MustParseAddr("169.254.1.1"), + }, nil + }, + } + d.now = time.Now + if err := d.RunOnce(context.Background()); err == nil { + t.Fatal("want failure when every answer is non-global") + } + if p := g.SelfDeny(); len(p) != 0 { + t.Fatalf("self deny = %v, want empty", p) + } +} + +func TestFailureKeepsPersisted(t *testing.T) { + g := &guard.Guard{} + dir := t.TempDir() + path := filepath.Join(dir, "self_ip.json") + now := time.Date(2026, 9, 22, 12, 0, 0, 0, time.UTC) + persist := file{Entries: []Entry{{ + Addr: "9.9.9.9", + Source: "ddns:wan.example", + Seen: now, + }}} + b, _ := json.Marshal(persist) + if err := os.WriteFile(path, b, 0o640); err != nil { + t.Fatal(err) + } + d := &Discoverer{ + Cfg: Config{ + Sources: []string{"ddns:wan.example"}, + KeepPrevious: 24 * time.Hour, + Path: path, + }, + Guard: g, + Lookup: func(ctx context.Context, host string) ([]netip.Addr, error) { + return nil, errors.New("nxdomain") + }, + } + d.now = func() time.Time { return now } + if err := d.Load(); err != nil { + t.Fatal(err) + } + if err := g.CheckAddr(netip.MustParseAddr("9.9.9.9")); err == nil { + t.Fatal("persisted addr should be denied after load") + } + if err := d.RunOnce(context.Background()); err == nil { + t.Fatal("want discovery error") + } + if d.Failures.Load() == 0 { + t.Fatal("want a failure count") + } + if err := g.CheckAddr(netip.MustParseAddr("9.9.9.9")); err == nil { + t.Fatal("persisted addr must survive a failed refresh") + } +} + +func TestParseTrace(t *testing.T) { + a, err := parseTrace("fl=x\nip=1.1.1.1\nts=1\n") + if err != nil { + t.Fatal(err) + } + if a.String() != "1.1.1.1" { + t.Fatalf("got %s", a) + } + if _, err := parseTrace("nope"); err == nil { + t.Fatal("want error") + } +} + +func TestTraceGetter(t *testing.T) { + g := &guard.Guard{} + d := &Discoverer{ + Cfg: Config{Sources: []string{"trace:https://1.1.1.1/cdn-cgi/trace"}, KeepPrevious: time.Hour}, + Guard: g, + Get: func(ctx context.Context, rawURL string) ([]byte, error) { + if rawURL != "https://1.1.1.1/cdn-cgi/trace" { + t.Fatalf("url %s", rawURL) + } + return []byte("ip=8.8.4.4\n"), nil + }, + } + d.now = time.Now + if err := d.RunOnce(context.Background()); err != nil { + t.Fatal(err) + } + if err := g.CheckAddr(netip.MustParseAddr("8.8.4.4")); err == nil { + t.Fatal("want denied") + } +} diff --git a/nix/module.nix b/nix/module.nix index ec9c510..e050f93 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -40,6 +40,27 @@ in ''; }; + selfIP = { + sources = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ "trace:https://1.1.1.1/cdn-cgi/trace" ]; + description = '' + Discovery sources in order: ddns: and/or + trace:. Results that are not global unicast are ignored. + ''; + }; + interval = lib.mkOption { + type = lib.types.str; + default = "10m"; + description = "How often to re-discover the WAN address."; + }; + keepPrevious = lib.mkOption { + type = lib.types.str; + default = "24h"; + description = "How long to keep a previous WAN address in the deny set."; + }; + }; + settings = lib.mkOption { type = settingsFormat.type; default = { }; @@ -63,7 +84,16 @@ in users.groups.sift = { }; environment.etc."sift/config.toml".source = settingsFormat.generate "sift.toml" ( - { listen = cfg.listen; } // cfg.settings + { + listen = cfg.listen; + self_ip = { + sources = cfg.selfIP.sources; + interval = cfg.selfIP.interval; + keep_previous = cfg.selfIP.keepPrevious; + path = "/var/lib/sift/self_ip.json"; + }; + } + // cfg.settings ); # Keys and env secrets are created on-box, never in the store.