Files
benvin d52c3ee76e
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Add central ASN address-group expander
Expand asn address groups to concrete prefixes centrally (one iplocate key,
consistent fleet-wide) and refresh them on a per-group TTL (default 24h). A
background Refresher scans for due groups, unions each group's ASNs to a deduped
prefix set, and writes them to a new resolved/resolved_at column (migration
0002). Fail-safe: a lookup error or empty expansion keeps the last-good set,
never emptying it. The compiler folds resolved prefixes into the rendered set
members; membership churn bumps the generation but never rewrites rules. The
iplocate client is endpoint-configurable and response-tolerant, documented as
needing endpoint/key confirmation. Unit tests cover TTL parsing, due-checks, and
union/dedup/error propagation with a fake expander.
2026-07-19 21:44:30 +10:00

127 lines
3.4 KiB
Go

// Package asnexpand centrally expands ASN address groups into concrete prefixes
// and refreshes them on a per-group TTL. Expansion is centralized (one API key,
// consistent fleet-wide results); devices never call the upstream provider.
package asnexpand
import (
"context"
"log/slog"
"time"
"git.unkin.net/unkin/tomswallapi/internal/model"
"git.unkin.net/unkin/tomswallapi/internal/store"
)
// DefaultTTL is used when an ASN group specifies no refresh interval.
const DefaultTTL = 24 * time.Hour
// Expander resolves an ASN (e.g. "13335") to its announced prefixes.
type Expander interface {
Prefixes(ctx context.Context, asn string) ([]string, error)
}
// ParseTTL parses a group's refresh string (e.g. "24h", "6h"); invalid or empty
// values fall back to DefaultTTL.
func ParseTTL(s string) time.Duration {
if s == "" {
return DefaultTTL
}
d, err := time.ParseDuration(s)
if err != nil || d <= 0 {
return DefaultTTL
}
return d
}
// due reports whether a group needs re-expansion given its last resolution time.
func due(g model.AddressGroup, now time.Time) bool {
if g.ResolvedAt == nil {
return true
}
return now.Sub(*g.ResolvedAt) >= ParseTTL(g.Refresh)
}
// Refresher periodically expands ASN groups whose TTL has elapsed and writes the
// resulting prefixes back to the store.
type Refresher struct {
Store *store.Store
Expander Expander
Interval time.Duration // how often to scan for due groups
Now func() time.Time
}
// Run scans on Interval until ctx is cancelled. One immediate scan runs first.
func (r *Refresher) Run(ctx context.Context) {
if r.Interval <= 0 {
r.Interval = 5 * time.Minute
}
if r.Now == nil {
r.Now = time.Now
}
r.scan(ctx)
t := time.NewTicker(r.Interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
r.scan(ctx)
}
}
}
// scan expands every due ASN group once. Failures are logged and the prior
// resolution is kept — a provider outage must never empty a set (fail-safe).
func (r *Refresher) scan(ctx context.Context) {
groups, err := r.Store.ListAddressGroups(ctx)
if err != nil {
slog.Error("asnexpand: list groups", "err", err)
return
}
now := r.Now()
for _, g := range groups {
if g.Type != model.GroupASN || !due(g, now) {
continue
}
prefixes, err := r.expand(ctx, g)
if err != nil {
slog.Warn("asnexpand: expansion failed, keeping last-good",
"group", g.Name, "err", err)
continue
}
if len(prefixes) == 0 {
// A genuinely-empty expansion is suspicious; do not clobber last-good.
slog.Warn("asnexpand: empty expansion, keeping last-good", "group", g.Name)
continue
}
if err := r.Store.UpdateResolvedPrefixes(ctx, g.Name, prefixes); err != nil {
slog.Error("asnexpand: store prefixes", "group", g.Name, "err", err)
continue
}
slog.Info("asnexpand: refreshed", "group", g.Name, "prefixes", len(prefixes))
}
}
// expand unions the prefixes of every ASN in the group. If any single ASN lookup
// fails, the whole expansion fails so we keep the last-good set rather than
// publishing a partial one.
func (r *Refresher) expand(ctx context.Context, g model.AddressGroup) ([]string, error) {
seen := map[string]struct{}{}
var out []string
for _, asn := range g.Members {
prefixes, err := r.Expander.Prefixes(ctx, asn)
if err != nil {
return nil, err
}
for _, p := range prefixes {
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
}
return out, nil
}