p1.1: dynamic self-IP deny (DDNS/trace, keep previous, atomic swap)

Discover WAN via ddns:<host> then trace:<url> 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.
This commit is contained in:
2026-09-22 22:59:08 -07:00
parent dca635fb62
commit 447842f860
8 changed files with 767 additions and 20 deletions
+51 -4
View File
@@ -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)
+6
View File
@@ -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
+26 -15
View File
@@ -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 {
+51
View File
@@ -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
}
+37
View File
@@ -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...)
}
+397
View File
@@ -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
}
+168
View File
@@ -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")
}
}
+31 -1
View File
@@ -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:<hostname> and/or
trace:<url>. 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.