// Package federation implements cross-instance identity discovery and the
// remote-CA trust store, per DESIGN.md §5.2.
package federation
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"regexp"
"strings"
"time"
"git.neuromancer.ovh/bastien-mrq/gitfed/internal/ca"
)
// WellKnown is the document served at /.well-known/gitfed.json.
type WellKnown struct {
Version int `json:"version"`
Domain string `json:"domain"`
CAPublicKey string `json:"ca_public_key"`
Software string `json:"software"`
Contact string `json:"contact,omitempty"`
}
// Handler returns an http.Handler serving this instance's well-known
// federation document.
func Handler(domain, contact, softwareVersion string, localCA *ca.CA) http.Handler {
doc := WellKnown{
Version: 1,
Domain: domain,
CAPublicKey: localCA.PublicKeyAuthorized(),
Software: "git.neuromancer.ovh/bastien-mrq/gitfed/" + softwareVersion,
Contact: contact,
}
body, _ := json.MarshalIndent(doc, "", " ")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
})
}
// maxWellKnownBytes caps how much of a remote well-known response we read, so
// a hostile or broken peer can't exhaust memory during discovery.
const maxWellKnownBytes = 1 << 20 // 1 MiB
// insecureClient is used only in dev/testing (insecure=true), where the peer
// is a loopback instance with no TLS and possibly a private address — so it
// deliberately does not apply the anti-SSRF dial guard.
var insecureClient = &http.Client{Timeout: 10 * time.Second}
// secureClient is used for real remote discovery. Its dialer refuses to
// connect to non-public addresses and pins the connection to the exact IP it
// validated, closing the DNS-rebinding window between check and dial.
var secureClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: guardedDial,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
},
}
// hostnameRe matches a conventional public hostname (at least two dot-joined
// labels, alphabetic TLD). It rejects bare hostnames, IP literals and
// anything carrying a port, path, userinfo or scheme.
var hostnameRe = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$`)
// ValidatePublicDomain reports whether domain is safe to make an outbound
// federation request to: a syntactically valid public hostname, not an IP
// literal and with no embedded port/path/credentials. The IP-level block is
// enforced again at dial time (guardedDial); this is the cheap early gate.
func ValidatePublicDomain(domain string) error {
if domain == "" {
return fmt.Errorf("empty domain")
}
if strings.ContainsAny(domain, ":/?#@\\ ") {
return fmt.Errorf("domain %q must be a bare hostname (no scheme, port, path or credentials)", domain)
}
if net.ParseIP(domain) != nil {
return fmt.Errorf("domain %q must be a hostname, not an IP address", domain)
}
if !hostnameRe.MatchString(domain) {
return fmt.Errorf("domain %q is not a valid public hostname", domain)
}
return nil
}
// CheckPublicHost validates that host is safe to make an outbound
// connection to, for callers other than federation discovery that also dial
// a user-supplied hostname (currently: one-shot repo import, see
// ROADMAP.md §3): a syntactically valid public hostname (ValidatePublicDomain)
// that currently resolves only to public addresses. Unlike guardedDial, this
// can't pin the resolved IP for the caller's own connection — the caller
// (e.g. a `git clone` subprocess) does its own DNS resolution afterwards, so
// this is a best-effort pre-flight gate against the DNS-rebinding window,
// not a hard guarantee the way guardedDial is for the federation HTTP client.
func CheckPublicHost(ctx context.Context, host string) error {
if err := ValidatePublicDomain(host); err != nil {
return err
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return fmt.Errorf("resolve %q: %w", host, err)
}
for _, ip := range ips {
if isDisallowedIP(ip.IP) {
return fmt.Errorf("refusing to connect to non-public address %s (for %s)", ip.IP, host)
}
}
return nil
}
// guardedDial resolves the target host, refuses any non-public address, then
// dials the validated IP directly.
func guardedDial(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
d := &net.Dialer{Timeout: 10 * time.Second}
var lastErr error
for _, ip := range ips {
if isDisallowedIP(ip.IP) {
lastErr = fmt.Errorf("refusing to connect to non-public address %s (for %s)", ip.IP, host)
continue
}
conn, err := d.DialContext(ctx, network, net.JoinHostPort(ip.IP.String(), port))
if err == nil {
return conn, nil
}
lastErr = err
}
if lastErr == nil {
lastErr = fmt.Errorf("no address to dial for %s", host)
}
return nil, lastErr
}
// isDisallowedIP is true for any address that must never be the target of a
// federation fetch: loopback, RFC1918/ULA private, link-local (covers the
// 169.254.169.254 cloud-metadata endpoint), CGNAT, unspecified and multicast.
func isDisallowedIP(ip net.IP) bool {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() {
return true
}
// Carrier-grade NAT 100.64.0.0/10, not covered by IsPrivate.
if ip4 := ip.To4(); ip4 != nil && ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 {
return true
}
return false
}
// Fetch retrieves and parses the well-known document for domain over HTTPS.
// insecure switches to plain HTTP and disables the anti-SSRF guards, for
// local development/testing only where there's no real DNS name or TLS cert
// (e.g. federating two instances both bound to 127.0.0.1) — never use it
// against a real remote instance.
func Fetch(domain string, insecure bool) (*WellKnown, error) {
scheme, client := "https", secureClient
if insecure {
scheme, client = "http", insecureClient
} else if err := ValidatePublicDomain(domain); err != nil {
return nil, fmt.Errorf("federation: refusing to fetch %q: %w", domain, err)
}
url := fmt.Sprintf("%s://%s/.well-known/gitfed.json", scheme, domain)
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("federation: fetch %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("federation: fetch %s: status %s", url, resp.Status)
}
var doc WellKnown
if err := json.NewDecoder(io.LimitReader(resp.Body, maxWellKnownBytes)).Decode(&doc); err != nil {
return nil, fmt.Errorf("federation: decode %s: %w", url, err)
}
if doc.Domain != domain {
return nil, fmt.Errorf("federation: %s declares domain %q, expected %q", url, doc.Domain, domain)
}
if doc.CAPublicKey == "" {
return nil, fmt.Errorf("federation: %s missing ca_public_key", url)
}
return &doc, nil
}