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 := ¬ifyLimiter{}
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
}