d52c3ee76e
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.
127 lines
3.4 KiB
Go
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
|
|
}
|