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:
+51
-4
@@ -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)
|
||||
|
||||
Vendored
+6
@@ -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
|
||||
|
||||
+12
-1
@@ -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,15 +82,24 @@ 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(),
|
||||
"self_ip_failures": fails,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user