package federation
import (
"fmt"
"sync"
"time"
"git.neuromancer.ovh/bastien-mrq/gitfed/internal/store"
)
// Resolver discovers and tracks trust in remote instances' CAs, per
// DESIGN.md §5.2.
type Resolver struct {
store *store.Store
localDomain string
insecure bool // dev-only: fetch well-known over plain HTTP, no TLS
mu sync.Mutex
recentDiscoveries []time.Time // attempts to discover a never-seen domain, for rate limiting
}
func NewResolver(s *store.Store, localDomain string, insecureHTTP bool) *Resolver {
return &Resolver{store: s, localDomain: localDomain, insecure: insecureHTTP}
}
// Insecure reports whether this resolver was configured for dev-only plain
// HTTP federation — callers outside this package that also make their own
// federation HTTP calls (currently just SendNotify) need this to match the
// same scheme/guard behavior as Fetch.
func (r *Resolver) Insecure() bool { return r.insecure }
// Discovering a domain we've never seen means an outbound HTTP request to a
// host named by whoever is granting the collaborator — capping how often
// that can happen bounds the abuse/SSRF-probing surface, per DESIGN.md §9
// Phase 3 ("Rate limiting / anti-abus sur la découverte de domaines
// inconnus"). Re-verifying an already-known domain never hits the network
// (§5.2) so it isn't rate limited.
const (
maxNewDomainDiscoveries = 10
discoveryWindow = time.Minute
)
// allowDiscovery reports whether a new-domain discovery attempt may proceed
// right now, recording it if so.
func (r *Resolver) allowDiscovery() bool {
r.mu.Lock()
defer r.mu.Unlock()
cutoff := time.Now().Add(-discoveryWindow)
kept := r.recentDiscoveries[:0]
for _, t := range r.recentDiscoveries {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if len(kept) >= maxNewDomainDiscoveries {
r.recentDiscoveries = kept
return false
}
r.recentDiscoveries = append(kept, time.Now())
return true
}
// EnsureTrust makes sure domain's CA is known to the trust store, fetching
// its well-known document if this is the first time we've seen it. It never
// re-fetches for a domain we already have a record for (trusted or
// pending) — verification of certificates from a known CA is always local,
// per DESIGN.md §5.2.
func (r *Resolver) EnsureTrust(domain string) (store.TrustedCA, error) {
if domain == r.localDomain {
return store.TrustedCA{}, fmt.Errorf("federation: %q is the local domain, not federated", domain)
}
existing, err := r.store.GetTrustedCA(domain)
if err == nil {
return existing, nil
}
if err != store.ErrNotFound {
return store.TrustedCA{}, err
}
if !r.allowDiscovery() {
r.auditDiscovery(domain, "rate limited", false)
return store.TrustedCA{}, fmt.Errorf("federation: too many new-domain discovery attempts, try again in a minute")
}
doc, err := Fetch(domain, r.insecure)
if err != nil {
r.auditDiscovery(domain, err.Error(), false)
return store.TrustedCA{}, fmt.Errorf("federation: discover %s: %w", domain, err)
}
meta, err := r.store.GetInstanceMeta()
if err != nil {
return store.TrustedCA{}, fmt.Errorf("federation: load instance policy: %w", err)
}
status := store.TrustPending
var approvedAt *time.Time
if meta.TrustPolicy == store.TrustPolicyAutoTrust {
now := time.Now().UTC()
status = store.TrustTrusted
approvedAt = &now
}
t := store.TrustedCA{
Domain: domain,
CAPublicKey: doc.CAPublicKey,
Status: status,
FirstSeenAt: time.Now().UTC(),
ApprovedAt: approvedAt,
}
if err := r.store.PutTrustedCA(t); err != nil {
return store.TrustedCA{}, err
}
r.auditDiscovery(domain, string(status), true)
return t, nil
}
// Approve manually approves a pending domain (whitelist policy).
func (r *Resolver) Approve(domain string) error {
err := r.store.ApproveTrustedCA(domain)
_ = r.store.AppendAudit(store.AuditEvent{
Action: "trust-approve",
Domain: domain,
Allowed: err == nil,
Detail: errString(err),
})
return err
}
func (r *Resolver) auditDiscovery(domain, detail string, allowed bool) {
_ = r.store.AppendAudit(store.AuditEvent{
Action: "trust-discover",
Domain: domain,
Allowed: allowed,
Detail: detail,
})
}
func errString(err error) string {
if err == nil {
return ""
}
return err.Error()
}