Gitfed
bastien-mrq/gitfed / internal / federation / notify.go
package federation

import (
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"net"
	"net/http"
	"strings"
	"sync"
	"time"

	"golang.org/x/crypto/ssh"

	"git.neuromancer.ovh/bastien-mrq/gitfed/internal/ca"
	"git.neuromancer.ovh/bastien-mrq/gitfed/internal/store"
)

// This file implements a small, deliberately non-authoritative federated
// notification: when a repo owner grants a collaborator on another
// instance, that instance is told "one of your users was just granted
// access to a repo here." The notification is advisory only — it never
// grants anything by itself. The actual access is (or isn't) real entirely
// independently of whether the notification ever arrives, is verified, or
// is even read: it's enforced by the granting instance's own ACL check
// whenever the repo is actually used over SSH. Accepting a notification on
// the receiving side just adds a bookmark (see store.PinnedRepo) to make
// finding the repo easier.
//
// Because of that, this deliberately doesn't require the two instances to
// have any persisted trust relationship first (unlike certificate trust,
// which gates real access and so needs admin approval under the whitelist
// policy). Verifying a notification just means checking a fresh signature
// against the sender's current published CA key — the same one-time
// lookup Fetch already does for certificate trust, just not persisted here
// since nothing sensitive is being decided.

// NotifyPayload is the signed content of a notification.
type NotifyPayload struct {
	FromDomain string `json:"from_domain"`
	Principal  string `json:"principal"` // recipient, "<user>@<recipient-domain>"
	Repo       string `json:"repo"`
	Role       string `json:"role"`
	Actor      string `json:"actor"` // who granted it, "<user>@<from_domain>"
	IssuedAt   int64  `json:"issued_at"`
}

// notifyEnvelope is the wire format POSTed to /.well-known/gitfed-notify.
// Payload is kept as raw bytes so the signature is verified against
// exactly what was transmitted, never a re-marshaled (and therefore
// possibly different) copy.
type notifyEnvelope struct {
	Payload   json.RawMessage `json:"payload"`
	SigFormat string          `json:"sig_format"`
	SigBlob   string          `json:"sig_blob"` // base64
}

const (
	maxNotifyBytes        = 16 << 10 // this payload is a few hundred bytes; anything bigger isn't legitimate
	notifyFreshnessWindow = 5 * time.Minute
)

// SendNotify signs payload (filling in FromDomain and IssuedAt) and POSTs it
// to toDomain. Errors are always non-fatal to the caller — see the one call
// site, admin.Admin.GrantCollaborator, for why a failed notification must
// never fail the grant itself.
func SendNotify(localCA *ca.CA, fromDomain, toDomain string, payload NotifyPayload, insecure bool) error {
	payload.FromDomain = fromDomain
	payload.IssuedAt = time.Now().Unix()

	payloadBytes, err := json.Marshal(payload)
	if err != nil {
		return err
	}
	sig, err := localCA.SignBytes(payloadBytes)
	if err != nil {
		return err
	}
	body, err := json.Marshal(notifyEnvelope{
		Payload:   payloadBytes,
		SigFormat: sig.Format,
		SigBlob:   base64.StdEncoding.EncodeToString(sig.Blob),
	})
	if err != nil {
		return err
	}

	scheme, client := "https", secureClient
	if insecure {
		scheme, client = "http", insecureClient
	} else if err := ValidatePublicDomain(toDomain); err != nil {
		return fmt.Errorf("federation: refusing to notify %q: %w", toDomain, err)
	}

	url := fmt.Sprintf("%s://%s/.well-known/gitfed-notify", scheme, toDomain)
	resp, err := client.Post(url, "application/json", strings.NewReader(string(body)))
	if err != nil {
		return fmt.Errorf("federation: notify %s: %w", url, err)
	}
	defer resp.Body.Close()
	if resp.StatusCode/100 != 2 {
		return fmt.Errorf("federation: notify %s: status %s", url, resp.Status)
	}
	return nil
}

// notifyLimiter bounds inbound notification POSTs per source IP. Every
// request that passes the cheap local checks triggers an outbound
// well-known fetch to verify the claimed sender, so without a limit here a
// flood of bogus notifications would be an amplification/DoS vector even
// though no single request is expensive on its own.
type notifyLimiter struct {
	mu   sync.Mutex
	hits map[string][]time.Time
}

const (
	maxNotifiesPerIP = 20
	notifyWindow     = time.Minute
)

func (l *notifyLimiter) allow(ip string) bool {
	l.mu.Lock()
	defer l.mu.Unlock()
	if l.hits == nil {
		l.hits = make(map[string][]time.Time)
	}
	cutoff := time.Now().Add(-notifyWindow)
	kept := l.hits[ip][:0]
	for _, t := range l.hits[ip] {
		if t.After(cutoff) {
			kept = append(kept, t)
		}
	}
	if len(kept) >= maxNotifiesPerIP {
		l.hits[ip] = kept
		return false
	}
	l.hits[ip] = append(kept, time.Now())
	return true
}

// NotifyHandler serves POST /.well-known/gitfed-notify.
func NotifyHandler(st *store.Store, localDomain string, insecure bool) http.Handler {
	limiter := &notifyLimiter{}
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !limiter.allow(requestIP(r)) {
			http.Error(w, "rate limited", http.StatusTooManyRequests)
			return
		}

		body, err := io.ReadAll(io.LimitReader(r.Body, maxNotifyBytes))
		if err != nil {
			http.Error(w, "bad request", http.StatusBadRequest)
			return
		}
		var env notifyEnvelope
		if err := json.Unmarshal(body, &env); err != nil {
			http.Error(w, "bad request", http.StatusBadRequest)
			return
		}
		var payload NotifyPayload
		if err := json.Unmarshal(env.Payload, &payload); err != nil {
			http.Error(w, "bad request", http.StatusBadRequest)
			return
		}

		// Cheap local checks before any network call: the recipient must be
		// a real local user (not just domain-shaped — otherwise anyone
		// could bloat the store by notifying made-up usernames, since
		// nothing about receiving one requires a real relationship to
		// exist first), and the claimed sender must look like a real
		// public hostname.
		username, principalDomain, ok := strings.Cut(payload.Principal, "@")
		if !ok || principalDomain != localDomain {
			http.NotFound(w, r)
			return
		}
		if _, err := st.GetUser(username); err != nil {
			http.NotFound(w, r)
			return
		}
		if !insecure {
			if err := ValidatePublicDomain(payload.FromDomain); err != nil {
				http.Error(w, "invalid from_domain", http.StatusBadRequest)
				return
			}
		}
		age := time.Since(time.Unix(payload.IssuedAt, 0))
		if age < -time.Minute || age > notifyFreshnessWindow {
			http.Error(w, "stale notification", http.StatusBadRequest)
			return
		}

		sigBlob, err := base64.StdEncoding.DecodeString(env.SigBlob)
		if err != nil {
			http.Error(w, "bad signature encoding", http.StatusBadRequest)
			return
		}

		// The one network call: fetch the claimed sender's current CA key
		// fresh, same as certificate-trust discovery does, but never
		// persisted here — this notification being advisory-only means it
		// doesn't need (and per the whitelist policy, shouldn't get) the
		// same admin-approval gate that granting real access does.
		doc, err := Fetch(payload.FromDomain, insecure)
		if err != nil {
			http.Error(w, "could not verify sender", http.StatusBadGateway)
			return
		}
		pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(doc.CAPublicKey))
		if err != nil {
			http.Error(w, "sender has no usable CA key", http.StatusBadGateway)
			return
		}
		if err := pub.Verify(env.Payload, &ssh.Signature{Format: env.SigFormat, Blob: sigBlob}); err != nil {
			http.Error(w, "invalid signature", http.StatusForbidden)
			return
		}

		_ = st.CreateNotification(store.Notification{
			Principal:  payload.Principal,
			FromDomain: payload.FromDomain,
			Repo:       payload.Repo,
			Role:       payload.Role,
			Actor:      payload.Actor,
		})
		w.WriteHeader(http.StatusNoContent)
	})
}

// requestIP mirrors cmd/gitfed-web's clientIP: behind the same Traefik
// ingress, the real client address arrives in X-Real-IP or as the last
// (proxy-appended, so unspoofable) hop of X-Forwarded-For.
func requestIP(r *http.Request) string {
	if xr := strings.TrimSpace(r.Header.Get("X-Real-IP")); xr != "" {
		return xr
	}
	if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
		parts := strings.Split(xff, ",")
		if last := strings.TrimSpace(parts[len(parts)-1]); last != "" {
			return last
		}
	}
	host, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		return r.RemoteAddr
	}
	return host
}