403 lines
8.7 KiB
Go
403 lines
8.7 KiB
Go
// Package selfip discovers the host's current WAN address so the SSRF
|
|
// guard can deny hairpin-NAT fetches of the residential IP.
|
|
//
|
|
// Assumption: the LAN is IPv4-only. A 1.1.1.1 trace only learns v4. If the
|
|
// ISP ever delegates global v6, add interface-derived self prefixes
|
|
// (net.InterfaceAddrs, deny the delegated prefix, default /56) — v6 LAN
|
|
// hosts are then directly reachable with no hairpin NAT.
|
|
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
|
|
}
|