Gitfed
bastien-mrq/gitfed / internal / federation / resolver.go
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()
}