Initial implementation of gitfed
Federated git server per DESIGN.md: SSH server with local CA and certificate-based cross-instance identity, ACL model with public/private repos, federation trust store, admin TUI/web UI, public read-only repo browser, and Kubernetes deployment manifests.
57 files changed
+5273 −0
A
.gitignore
+5 −0
A
DESIGN.md
+198 −0
A
cmd/gitfed-renew-cert/main.go
+123 −0
A
cmd/gitfed-server/main.go
+132 −0
A
cmd/gitfed-site/handlers.go
+169 −0
A
cmd/gitfed-site/main.go
+64 −0
A
cmd/gitfed-site/render.go
+66 −0
A
cmd/gitfed-tui/actions.go
+201 −0
A
cmd/gitfed-tui/form.go
+84 −0
A
cmd/gitfed-tui/items.go
+76 −0
A
cmd/gitfed-tui/main.go
+45 −0
A
cmd/gitfed-tui/model.go
+291 −0
A
cmd/gitfed-web/handlers_acl.go
+143 −0
A
cmd/gitfed-web/handlers_audit.go
+41 −0
A
cmd/gitfed-web/handlers_home.go
+48 −0
A
cmd/gitfed-web/handlers_repos.go
+74 −0
A
cmd/gitfed-web/handlers_trust.go
+57 −0
A
cmd/gitfed-web/handlers_users.go
+72 −0
A
cmd/gitfed-web/main.go
+62 −0
A
cmd/gitfed-web/render.go
+97 −0
A
cmd/gitfed-web/routes.go
+25 −0
A
deploy/docker/Dockerfile
+32 −0
A
deploy/k8s/README.md
+138 −0
A
deploy/k8s/configmap.yaml
+26 −0
A
deploy/k8s/deployment.yaml
+130 −0
A
deploy/k8s/ingress.yaml
+33 −0
A
deploy/k8s/namespace.yaml
+4 −0
A
deploy/k8s/pvc.yaml
+13 −0
A
deploy/k8s/service.yaml
+27 −0
A
go.mod
+37 −0
A
go.sum
+78 −0
A
internal/acl/acl.go
+67 −0
A
internal/acl/acl_test.go
+74 −0
A
internal/admin/admin.go
+256 −0
A
internal/admin/admin_test.go
+57 −0
A
internal/adminrpc/client.go
+160 −0
A
internal/adminrpc/protocol.go
+114 −0
A
internal/adminrpc/server.go
+200 −0
A
internal/ca/ca.go
+139 −0
A
internal/config/config.go
+69 −0
A
internal/federation/resolver.go
+140 −0
A
internal/federation/resolver_test.go
+99 −0
A
internal/federation/wellknown.go
+74 −0
A
internal/gitexec/gitexec.go
+149 −0
A
internal/gitexec/gitexec_test.go
+94 −0
A
internal/opsconnect/connect.go
+35 −0
A
internal/ssh/identity.go
+30 −0
A
internal/ssh/server.go
+228 −0
A
internal/ssh/session.go
+132 −0
A
internal/store/acl.go
+98 −0
A
internal/store/audit.go
+58 −0
A
internal/store/audit_test.go
+43 −0
A
internal/store/meta.go
+37 −0
A
internal/store/repos.go
+84 −0
A
internal/store/store.go
+86 −0
A
internal/store/trust.go
+69 −0
A
internal/store/users.go
+90 −0
.gitignore
@@ -0,0 +1,5 @@
+.DS_Store
+/bin/
+/data/
+/demo/
+*.db
DESIGN.md
@@ -0,0 +1,198 @@
+# GitFed — Serveur Git fédéré (nom de code)
+
+## 1. Résumé
+
+Serveur git auto-hébergé, écrit en Go, accessible en SSH (HTTP non prioritaire),
+capable de **fédérer l'identité et les autorisations** entre instances indépendantes.
+Un utilisateur créé sur une instance peut pousser/tirer sur un repo hébergé par
+une autre instance, sans y recréer de compte, via un système de **certificats SSH
+signés par l'instance d'origine**.
+
+La donnée d'un repo ne vit qu'à un seul endroit (pas de réplication). La
+fédération ne porte que sur l'**identité** et l'**autorisation**, pas sur le
+transfert d'objets git (qui reste le protocole git standard).
+
+## 2. Objectifs
+
+- Auth SSH obligatoire pour push (clé/certificat). HTTP: lecture seule, optionnel, non prioritaire.
+- Fédération d'identité inter-instances via un protocole custom (pas ForgeFed/ActivityPub).
+- Gestion des repos/utilisateurs/ACL via TUI dans un premier temps.
+- Pas de réécriture du protocole git : on wrappe `git-receive-pack` / `git-upload-pack`.
+- UI web plus tard, branchée sur le même noyau identité/ACL.
+
+## 3. Non-objectifs (pour l'instant)
+
+- Pas de réplication/mirroring automatique de repos entre instances.
+- Pas de fork fédéré façon Forgejo/ActivityPub.
+- Pas de gestion fine de webhooks/CI dans le MVP.
+- Pas de HTTP smart-protocol complet (juste lecture simple si besoin).
+
+## 4. Vue d'ensemble de l'architecture
+
+```
+┌─────────────────────────────┐ ┌─────────────────────────────┐
+│ Instance A │ │ Instance B │
+│ ┌───────────────────────┐ │ │ ┌───────────────────────┐ │
+│ │ CA locale (ed25519) │ │ │ │ CA locale (ed25519) │ │
+│ │ signe les certs users │ │ │ │ signe les certs users │ │
+│ └───────────────────────┘ │ │ └───────────────────────┘ │
+│ ┌───────────────────────┐ │ HTTPS │ ┌───────────────────────┐ │
+│ │ /.well-known/gitfed.json│◄┼────────┼─►│/.well-known/gitfed.json│ │
+│ └───────────────────────┘ │ (trust)│ └───────────────────────┘ │
+│ ┌───────────────────────┐ │ │ │
+│ │ Serveur SSH │◄─────────┼── alice@instanceB.example │
+│ │ - vérifie cert │ │ SSH │ (push avec certificat) │
+│ │ - résout principal │ │ direct │ │
+│ │ - check ACL repo │ │ │ │
+│ │ - exec git-receive-pack│ │ │ │
+│ └───────────────────────┘ │ │ │
+│ ┌───────────────────────┐ │ │ │
+│ │ Store: repos + ACL + │ │ │ │
+│ │ trust store CA distantes│ │ │ │
+│ └───────────────────────┘ │ │ │
+└─────────────────────────────┘ └─────────────────────────────┘
+```
+
+## 5. Modèle d'identité et de confiance
+
+### 5.1 Principe
+
+- Chaque instance possède une paire de clés **CA** (ed25519), générée au setup.
+- Chaque utilisateur local reçoit, à la connexion/login initial, un **certificat
+ SSH** (format OpenSSH `ssh-keygen -s`) signé par la CA de son instance.
+ - `principal` = `<username>@<domaine-instance>` (ex: `alice@instanceb.example`)
+ - `valid_after` / `valid_before` : TTL court (24–72h, configurable)
+ - `key_id` : identifiant unique du certificat (pour logs/audit)
+- Le client présente ce certificat (pas la clé brute) à toute instance à laquelle
+ il se connecte.
+
+### 5.2 Découverte et confiance inter-instances
+
+- Chaque instance expose un endpoint HTTPS statique :
+ `https://<domaine>/.well-known/gitfed.json`
+ ```json
+ {
+ "version": 1,
+ "domain": "instanceb.example",
+ "ca_public_key": "ssh-ed25519 AAAA...",
+ "software": "gitfed/0.1.0",
+ "contact": "admin@instanceb.example"
+ }
+ ```
+- Quand instance A voit apparaître un principal `xxx@instanceb.example` (ex :
+ ajouté comme collaborateur sur un repo), elle va chercher ce endpoint pour
+ récupérer la CA publique de `instanceb.example`, **si elle ne la connaît pas déjà**.
+- Politique de confiance configurable par l'admin de l'instance :
+ - `whitelist` (par défaut) : l'admin doit approuver manuellement chaque nouveau
+ domaine avant que la CA soit ajoutée au trust store.
+ - `auto-trust` (opt-in) : la CA est ajoutée automatiquement dès la première
+ rencontre (TOFU — trust on first use).
+- Une fois la CA en trust store, la vérification d'un certificat est **100%
+ locale** (pas d'appel réseau à chaque connexion SSH) : robustesse et rapidité.
+- Révocation : gérée par le TTL court du certificat + renouvellement périodique
+ côté client auprès de son instance d'origine. Pas de CRL dans le MVP.
+
+### 5.3 Pourquoi ce choix
+
+- Évite un appel réseau "live" à chaque push (contrairement à une vérification
+ à la volée), donc pas de dépendance de disponibilité de l'instance d'origine
+ à chaque opération git.
+- Décentralisé : pas de registre central unique, chaque instance décide qui
+ elle trust.
+- Réutilise un mécanisme SSH standard et éprouvé (certificats OpenSSH), pas de
+ crypto maison.
+
+## 6. Modèle d'autorisation (ACL)
+
+- Chaque repo a une ACL locale à l'instance qui l'héberge :
+ ```json
+ {
+ "repo": "alice/mon-projet",
+ "owner": "alice@instancea.example",
+ "collaborators": [
+ { "principal": "bob@instanceb.example", "role": "write" },
+ { "principal": "carol@instancec.example", "role": "read" }
+ ]
+ }
+ ```
+- `role` : `read` | `write` | `admin`.
+- Ajouter un collaborateur distant déclenche la résolution de confiance (§5.2)
+ si le domaine n'est pas encore connu.
+
+## 7. Flux d'une opération git distante (push)
+
+1. Bob (compte sur `instanceb.example`) est ajouté comme collaborateur `write`
+ sur `alice/mon-projet` hébergé sur `instancea.example`.
+2. `instancea.example` résout la confiance vers `instanceb.example` (si pas déjà fait).
+3. Bob se connecte en SSH à `instancea.example`, présente son certificat
+ signé par la CA de `instanceb.example`.
+4. `instancea.example` :
+ - vérifie la signature du certificat contre la CA connue de `instanceb.example`
+ - vérifie le TTL du certificat
+ - extrait le principal `bob@instanceb.example`
+ - vérifie l'ACL du repo demandé
+5. Si autorisé → exec direct de `git-receive-pack` (ou `git-upload-pack` pour un
+ pull) comme pour un utilisateur local. Aucune réplication, la donnée reste
+ sur `instancea.example`.
+
+## 8. Structure du projet (Go)
+
+```
+gitfed/
+├── cmd/
+│ ├── gitfed-server/ # binaire serveur (SSH + endpoint well-known)
+│ └── gitfed-tui/ # TUI admin (users, repos, ACL, trust store)
+├── internal/
+│ ├── ca/ # génération/signature de certificats
+│ ├── ssh/ # serveur SSH (golang.org/x/crypto/ssh), handlers
+│ ├── federation/ # découverte, trust store, client well-known
+│ ├── acl/ # modèle ACL + persistance
+│ ├── gitexec/ # wrapping git-receive-pack / git-upload-pack
+│ ├── store/ # stockage repos bare + métadonnées (bbolt ou sqlite)
+│ └── tui/ # composants Bubble Tea
+├── DESIGN.md
+└── go.mod
+```
+
+Dépendances clés pressenties :
+- `golang.org/x/crypto/ssh` — serveur SSH + certificats natifs
+- `charmbracelet/wish` — framework SSH-app (bâti sur x/crypto/ssh)
+- `charmbracelet/bubbletea` + `lipgloss` — TUI
+- `modernc.org/sqlite` ou `etcd-io/bbolt` — stockage users/ACL/trust store
+- git : appelé en sous-processus (`os/exec`), pas de réimplémentation du protocole
+
+## 9. Roadmap par phases
+
+**Phase 0 — Squelette**
+- Serveur SSH minimal, auth par clé publique brute (sans certificat encore)
+- Stockage bare repos sur disque, exec direct de git
+
+**Phase 1 — Instance locale complète**
+- CA locale : génération + émission de certificats utilisateurs
+- Gestion users/clés/repos via TUI
+- ACL locale par repo (read/write/admin)
+
+**Phase 2 — Fédération v0**
+- Endpoint `/.well-known/gitfed.json`
+- Trust store des CA distantes + politique whitelist/auto-trust
+- ACL étendue aux principals distants
+- Commande TUI : ajouter un collaborateur fédéré, approuver un domaine
+
+**Phase 3 — Durcissement**
+- Renouvellement de certificat (TTL court côté client)
+- Audit log des accès cross-instance
+- Rate limiting / anti-abus sur la découverte de domaines inconnus
+
+**Phase 4 — UI web**
+- Réutilise le même noyau identité/ACL/fédération
+
+## 10. Questions ouvertes / à trancher plus tard
+
+- Faut-il un mécanisme de révocation explicite (CRL-like) au-delà du TTL court ?
+- Comment gérer le renouvellement de certificat si l'instance d'origine de
+ l'utilisateur est temporairement indisponible ?
+- Format exact de nommage des repos distants côté client (`git clone
+ gitfed://instancea.example/alice/mon-projet` ?).
+- Politique par défaut : whitelist ou auto-trust au premier lancement ?
+- Multi-clé par utilisateur (plusieurs devices) : un certificat par device ou
+ un certificat "identité" réutilisé ?
cmd/gitfed-renew-cert/main.go
@@ -0,0 +1,123 @@
+// gitfed-renew-cert is a client-side helper, meant to run from cron/a
+// systemd timer on a user's machine: it checks whether the user's gitfed
+// certificate is close to expiry and, if so, requests a fresh one from the
+// user's home instance over SSH (the "gitfed-cert" exec command implemented
+// in internal/ssh/session.go). This is the client half of DESIGN.md §9
+// Phase 3 "Renouvellement de certificat (TTL court côté client)".
+package main
+
+import (
+ "bytes"
+ "flag"
+ "fmt"
+ "os"
+ "time"
+
+ gossh "golang.org/x/crypto/ssh"
+)
+
+func main() {
+ host := flag.String("host", "", "home instance SSH address, e.g. instancea.example:22")
+ keyPath := flag.String("key", "", "path to the private key to authenticate with")
+ certPath := flag.String("cert", "", "path to read/write the certificate (default: <key>-cert.pub)")
+ hostKey := flag.String("host-key", "", "expected server host key (authorized_keys format); if empty, the host key is NOT verified")
+ minTTL := flag.Duration("min-ttl", 6*time.Hour, "renew if less than this much validity remains")
+ force := flag.Bool("force", false, "renew even if the current certificate is still comfortably valid")
+ flag.Parse()
+
+ if *host == "" || *keyPath == "" {
+ fmt.Fprintln(os.Stderr, "usage: gitfed-renew-cert -host <instance:port> -key <private-key-path> [-cert <path>] [-host-key <authorized_keys line>] [-min-ttl 6h]")
+ os.Exit(2)
+ }
+ if *certPath == "" {
+ *certPath = *keyPath + "-cert.pub"
+ }
+
+ if !*force {
+ if remaining, ok := certRemainingTTL(*certPath); ok {
+ if remaining > *minTTL {
+ fmt.Printf("certificate still valid for %s, no renewal needed\n", remaining.Round(time.Second))
+ return
+ }
+ fmt.Printf("certificate expires in %s, renewing\n", remaining.Round(time.Second))
+ } else {
+ fmt.Println("no usable certificate found, requesting one")
+ }
+ }
+
+ if err := renew(*host, *keyPath, *certPath, *hostKey); err != nil {
+ fmt.Fprintln(os.Stderr, "gitfed-renew-cert:", err)
+ os.Exit(1)
+ }
+ fmt.Println("wrote", *certPath)
+}
+
+// certRemainingTTL returns how much longer the certificate at path remains
+// valid. ok is false if the file is missing or unparseable.
+func certRemainingTTL(path string) (time.Duration, bool) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return 0, false
+ }
+ pub, _, _, _, err := gossh.ParseAuthorizedKey(data)
+ if err != nil {
+ return 0, false
+ }
+ cert, ok := pub.(*gossh.Certificate)
+ if !ok {
+ return 0, false
+ }
+ validBefore := time.Unix(int64(cert.ValidBefore), 0)
+ return time.Until(validBefore), true
+}
+
+func renew(host, keyPath, certPath, hostKeyAuthorized string) error {
+ keyData, err := os.ReadFile(keyPath)
+ if err != nil {
+ return fmt.Errorf("read private key: %w", err)
+ }
+ signer, err := gossh.ParsePrivateKey(keyData)
+ if err != nil {
+ return fmt.Errorf("parse private key: %w", err)
+ }
+
+ hostKeyCallback := gossh.InsecureIgnoreHostKey()
+ if hostKeyAuthorized != "" {
+ expected, _, _, _, err := gossh.ParseAuthorizedKey([]byte(hostKeyAuthorized))
+ if err != nil {
+ return fmt.Errorf("parse -host-key: %w", err)
+ }
+ hostKeyCallback = gossh.FixedHostKey(expected)
+ } else {
+ fmt.Fprintln(os.Stderr, "warning: -host-key not set, the server's host key will not be verified")
+ }
+
+ client, err := gossh.Dial("tcp", host, &gossh.ClientConfig{
+ User: "git",
+ Auth: []gossh.AuthMethod{gossh.PublicKeys(signer)},
+ HostKeyCallback: hostKeyCallback,
+ Timeout: 10 * time.Second,
+ })
+ if err != nil {
+ return fmt.Errorf("dial %s: %w", host, err)
+ }
+ defer client.Close()
+
+ session, err := client.NewSession()
+ if err != nil {
+ return fmt.Errorf("open session: %w", err)
+ }
+ defer session.Close()
+
+ var stdout, stderr bytes.Buffer
+ session.Stdout = &stdout
+ session.Stderr = &stderr
+ if err := session.Run("gitfed-cert"); err != nil {
+ return fmt.Errorf("gitfed-cert: %w (%s)", err, stderr.String())
+ }
+
+ if err := os.WriteFile(certPath, stdout.Bytes(), 0644); err != nil {
+ return fmt.Errorf("write %s: %w", certPath, err)
+ }
+ return nil
+}
cmd/gitfed-server/main.go
@@ -0,0 +1,132 @@
+// gitfed-server is the gitfed daemon: SSH git server + federation
+// well-known HTTP endpoint, per DESIGN.md §4/§8.
+package main
+
+import (
+ "errors"
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+
+ "gitfed/internal/admin"
+ "gitfed/internal/adminrpc"
+ "gitfed/internal/ca"
+ "gitfed/internal/config"
+ "gitfed/internal/federation"
+ "gitfed/internal/ssh"
+ "gitfed/internal/store"
+)
+
+const version = "0.1.0"
+
+func main() {
+ configPath := flag.String("config", "gitfed.json", "path to instance config file")
+ initDomain := flag.String("init", "", "initialize a new instance for this domain and exit")
+ dataDir := flag.String("data-dir", "data", "data directory to use with -init")
+ flag.Parse()
+
+ if *initDomain != "" {
+ if err := initInstance(*configPath, *initDomain, *dataDir); err != nil {
+ log.Fatalf("init: %v", err)
+ }
+ return
+ }
+
+ if err := run(*configPath); err != nil {
+ log.Fatalf("gitfed-server: %v", err)
+ }
+}
+
+func initInstance(configPath, domain, dataDir string) error {
+ if _, err := os.Stat(configPath); err == nil {
+ return fmt.Errorf("%s already exists", configPath)
+ }
+
+ cfg := config.Default(domain, dataDir)
+ if err := os.MkdirAll(cfg.ReposDir, 0755); err != nil {
+ return err
+ }
+ if _, err := ca.LoadOrCreate(cfg.CADir()); err != nil {
+ return err
+ }
+ st, err := store.Open(cfg.DBPath())
+ if err != nil {
+ return err
+ }
+ defer st.Close()
+ if err := st.PutInstanceMeta(store.InstanceMeta{
+ Domain: cfg.Domain,
+ TrustPolicy: cfg.TrustPolicy,
+ CertTTLHours: cfg.CertTTLHours,
+ }); err != nil {
+ return err
+ }
+ if err := config.Save(configPath, cfg); err != nil {
+ return err
+ }
+ fmt.Printf("gitfed instance initialized for domain %q\n", domain)
+ fmt.Printf("config: %s\n", configPath)
+ fmt.Printf("data dir: %s\n", cfg.DataDir)
+ fmt.Println("edit the config file to adjust listen addresses, then run: gitfed-server -config " + configPath)
+ return nil
+}
+
+func run(configPath string) error {
+ cfg, err := config.Load(configPath)
+ if err != nil {
+ return fmt.Errorf("load config (did you run -init first?): %w", err)
+ }
+
+ st, err := store.Open(cfg.DBPath())
+ if err != nil {
+ return err
+ }
+ defer st.Close()
+
+ localCA, err := ca.LoadOrCreate(cfg.CADir())
+ if err != nil {
+ return err
+ }
+
+ // Instance metadata in the store is the live source of truth for trust
+ // policy (editable from the TUI); seed it from the config on first run.
+ if _, err := st.GetInstanceMeta(); errors.Is(err, store.ErrNotFound) {
+ if err := st.PutInstanceMeta(store.InstanceMeta{
+ Domain: cfg.Domain,
+ TrustPolicy: cfg.TrustPolicy,
+ CertTTLHours: cfg.CertTTLHours,
+ }); err != nil {
+ return err
+ }
+ }
+
+ resolver := federation.NewResolver(st, cfg.Domain, cfg.InsecureFederation)
+
+ adminOps := admin.New(st, resolver, cfg.Domain, cfg.ReposDir)
+ go func() {
+ rpcServer := adminrpc.NewServer(adminOps, cfg.AdminSocketPath())
+ if err := rpcServer.ListenAndServe(); err != nil {
+ log.Printf("admin socket server: %v", err)
+ }
+ }()
+
+ if cfg.ListenHTTP != "" {
+ go func() {
+ handler := federation.Handler(cfg.Domain, cfg.Contact, version, localCA)
+ mux := http.NewServeMux()
+ mux.Handle("/.well-known/gitfed.json", handler)
+ log.Printf("gitfed well-known endpoint listening on %s", cfg.ListenHTTP)
+ if err := http.ListenAndServe(cfg.ListenHTTP, mux); err != nil {
+ log.Printf("well-known http server: %v", err)
+ }
+ }()
+ }
+
+ srv, err := ssh.New(cfg, st, localCA, resolver)
+ if err != nil {
+ return err
+ }
+ return srv.ListenAndServe()
+}
cmd/gitfed-site/handlers.go
@@ -0,0 +1,169 @@
+package main
+
+import (
+ "bytes"
+ "html/template"
+ "net/http"
+ "strings"
+
+ "gitfed/internal/store"
+)
+
+var homeTpl = template.Must(template.New("home").Parse(`
+<h1>Public repositories</h1>
+{{if .Topic}}<p class="muted">Filtered by topic <span class="badge">{{.Topic}}</span> — <a href="/">clear</a></p>{{end}}
+<table>
+<tr><th>Repo</th><th>Owner</th><th>Topics</th></tr>
+{{range .Repos}}
+<tr>
+ <td><a href="/r/{{.Name}}">{{.Name}}</a></td>
+ <td class="muted">{{.Owner}}</td>
+ <td>{{range .Topics}}<a class="badge" href="/?topic={{.}}">{{.}}</a>{{end}}</td>
+</tr>
+{{else}}
+<tr><td colspan="3" class="muted">No public repositories yet.</td></tr>
+{{end}}
+</table>
+`))
+
+func (s *server) handleHome(w http.ResponseWriter, r *http.Request) {
+ topic := r.URL.Query().Get("topic")
+
+ repos, err := s.ops.ListRepos()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ var visible []store.Repo
+ for _, repo := range repos {
+ if !repo.Public {
+ continue
+ }
+ if topic != "" && !containsTopic(repo.Topics, topic) {
+ continue
+ }
+ visible = append(visible, repo)
+ }
+
+ var buf bytes.Buffer
+ _ = homeTpl.Execute(&buf, struct {
+ Repos []store.Repo
+ Topic string
+ }{visible, topic})
+ s.render(w, "Public repositories", template.HTML(buf.String()))
+}
+
+func containsTopic(topics []string, topic string) bool {
+ for _, t := range topics {
+ if t == topic {
+ return true
+ }
+ }
+ return false
+}
+
+var repoTpl = template.Must(template.New("repo").Parse(`
+<p><a href="/">← All repos</a></p>
+<h1>{{.Repo.Name}}</h1>
+<p class="muted">owner: {{.Repo.Owner}}
+{{range .Repo.Topics}}<span class="badge">{{.}}</span>{{end}}</p>
+
+<section>
+<code>ssh://git@{{.Domain}}/{{.Repo.Name}}.git</code>
+<p class="muted">Cloning requires a gitfed SSH identity — public means readable by any authenticated principal, not anonymous (DESIGN.md §5.2).</p>
+</section>
+
+{{if .Tags}}
+<section>
+<h3>Tags</h3>
+{{range .Tags}}<span class="badge">{{.}}</span> {{end}}
+</section>
+{{end}}
+
+{{if .ReadmeHTML}}
+<section class="markdown-body">
+<h3>README</h3>
+{{.ReadmeHTML}}
+</section>
+{{end}}
+
+{{if .LicenseHTML}}
+<section class="markdown-body">
+<h3>License ({{.LicenseFile}})</h3>
+{{.LicenseHTML}}
+</section>
+{{end}}
+`))
+
+func (s *server) handleRepo(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("repo")
+
+ repo, err := s.ops.GetRepo(name)
+ if err != nil || !repo.Public {
+ // Same response whether the repo doesn't exist or is private —
+ // don't let this endpoint be used to enumerate private repo names.
+ http.NotFound(w, r)
+ return
+ }
+
+ tags, err := s.ops.ListRepoTags(name)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ readmeHTML, err := s.renderRepoFile(name, s.ops.GetRepoReadme)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ licenseContent, licenseFile, licenseFound, err := s.ops.GetRepoLicense(name)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ var licenseHTML template.HTML
+ if licenseFound {
+ licenseHTML, err = renderFileContent(licenseFile, licenseContent)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ }
+
+ var buf bytes.Buffer
+ _ = repoTpl.Execute(&buf, struct {
+ Repo store.Repo
+ Domain string
+ Tags []string
+ ReadmeHTML template.HTML
+ LicenseHTML template.HTML
+ LicenseFile string
+ }{repo, s.domain, tags, readmeHTML, licenseHTML, licenseFile})
+ s.render(w, name, template.HTML(buf.String()))
+}
+
+func (s *server) renderRepoFile(name string, get func(string) (string, bool, error)) (template.HTML, error) {
+ content, found, err := get(name)
+ if err != nil || !found {
+ return "", err
+ }
+ return renderFileContent("README.md", content)
+}
+
+// renderFileContent renders markdown files as HTML and shows anything else
+// as preformatted, HTML-escaped text (html/template escapes {{.}} inside
+// the <pre> automatically).
+func renderFileContent(filename, content string) (template.HTML, error) {
+ if strings.HasSuffix(strings.ToLower(filename), ".md") || strings.HasSuffix(strings.ToLower(filename), ".markdown") {
+ return renderMarkdown(content)
+ }
+ var buf bytes.Buffer
+ if err := plainTpl.Execute(&buf, content); err != nil {
+ return "", err
+ }
+ return template.HTML(buf.String()), nil
+}
+
+var plainTpl = template.Must(template.New("plain").Parse(`<pre>{{.}}</pre>`))
cmd/gitfed-site/main.go
@@ -0,0 +1,64 @@
+// gitfed-site is the public, read-only repo browser: a homepage listing
+// public repos and a per-repo page rendering README.md, LICENSE, git tags
+// and topics. This is DESIGN.md §9 Phase 4 ("UI web plus tard, branchée sur
+// le même noyau identité/ACL") — it drives the same admin.Ops interface as
+// gitfed-tui/gitfed-web (via internal/opsconnect) rather than a second
+// implementation of the repo/ACL logic.
+//
+// Unlike gitfed-web (which grants full admin control to anyone who can
+// reach it), this binary only ever reads repos with Public == true and
+// never mutates anything — it's meant to be reachable beyond localhost.
+// Actual git clone/push still always goes over SSH with a certificate;
+// this only serves human-readable metadata. Binding still defaults to
+// 127.0.0.1: put a reverse proxy (nginx, Caddy, ...) in front for TLS and
+// the public hostname when actually deploying it, rather than binding
+// this process directly to a public interface.
+package main
+
+import (
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+
+ "gitfed/internal/admin"
+ "gitfed/internal/config"
+ "gitfed/internal/opsconnect"
+)
+
+type server struct {
+ ops admin.Ops
+ domain string
+}
+
+func main() {
+ configPath := flag.String("config", "gitfed.json", "path to instance config file")
+ listen := flag.String("listen", "127.0.0.1:8090", "address to serve the public site on")
+ flag.Parse()
+
+ cfg, err := config.Load(*configPath)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "gitfed-site: load config: %v\n", err)
+ os.Exit(1)
+ }
+
+ ops, mode, closeFn, err := opsconnect.Connect(cfg)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "gitfed-site: %v\n", err)
+ os.Exit(1)
+ }
+ defer closeFn()
+
+ s := &server{ops: ops, domain: cfg.Domain}
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /{$}", s.handleHome)
+ mux.HandleFunc("GET /r/{repo...}", s.handleRepo)
+
+ log.Printf("gitfed-site serving on http://%s (mode: %s)", *listen, mode)
+ if err := http.ListenAndServe(*listen, mux); err != nil {
+ fmt.Fprintln(os.Stderr, "gitfed-site:", err)
+ os.Exit(1)
+ }
+}
cmd/gitfed-site/render.go
@@ -0,0 +1,66 @@
+package main
+
+import (
+ "bytes"
+ "html/template"
+ "net/http"
+
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/extension"
+)
+
+var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM))
+
+// renderMarkdown converts src to HTML. goldmark escapes any raw HTML found
+// in the source by default (we never enable html.WithUnsafe) — README/
+// LICENSE content comes from whoever can push to the repo, not necessarily
+// someone the site's visitors trust, so treat it as untrusted input.
+func renderMarkdown(src string) (template.HTML, error) {
+ var buf bytes.Buffer
+ if err := markdown.Convert([]byte(src), &buf); err != nil {
+ return "", err
+ }
+ return template.HTML(buf.String()), nil
+}
+
+const shellSrc = `<!doctype html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>{{.Title}} — gitfed</title>
+<style>
+ body { font-family: -apple-system, system-ui, sans-serif; margin: 0; background: #0f1115; color: #e6e6e6; }
+ header { background: #171a21; padding: 0.75rem 1.5rem; border-bottom: 1px solid #2a2f3a; }
+ header a { color: #8ab4f8; text-decoration: none; font-weight: bold; }
+ main { padding: 1.5rem; max-width: 800px; margin: 0 auto; }
+ table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
+ th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid #2a2f3a; font-size: 0.9rem; }
+ th { color: #9aa1ac; font-weight: 600; }
+ .badge { display: inline-block; padding: 0.1rem 0.5rem; border-radius: 10px; font-size: 0.75rem; background: #1e3a2a; color: #9ae6b4; margin-right: 0.3rem; }
+ .muted { color: #9aa1ac; font-size: 0.85rem; }
+ code, pre { background: #171a21; border-radius: 4px; }
+ code { padding: 0.1rem 0.3rem; }
+ pre { padding: 1rem; overflow-x: auto; border: 1px solid #2a2f3a; }
+ .markdown-body h1, .markdown-body h2, .markdown-body h3 { border-bottom: 1px solid #2a2f3a; padding-bottom: 0.3rem; }
+ .markdown-body img { max-width: 100%; }
+ .markdown-body table { display: block; overflow-x: auto; }
+ section { margin-bottom: 2rem; }
+</style>
+</head>
+<body>
+<header><a href="/">gitfed — {{.Domain}}</a></header>
+<main>
+{{.Body}}
+</main>
+</body>
+</html>`
+
+var shellTpl = template.Must(template.New("shell").Parse(shellSrc))
+
+func (s *server) render(w http.ResponseWriter, title string, body template.HTML) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _ = shellTpl.Execute(w, struct {
+ Title, Domain string
+ Body template.HTML
+ }{title, s.domain, body})
+}
cmd/gitfed-tui/actions.go
@@ -0,0 +1,201 @@
+package main
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+
+ "gitfed/internal/store"
+)
+
+func (m *model) refreshUsers() {
+ users, _ := m.ops.ListUsers()
+ items := make([]list.Item, len(users))
+ for i, u := range users {
+ items[i] = userItem{u}
+ }
+ m.usersList.SetItems(items)
+}
+
+func (m *model) refreshRepos() {
+ repos, _ := m.ops.ListRepos()
+ items := make([]list.Item, len(repos))
+ for i, r := range repos {
+ items[i] = repoItem{r}
+ }
+ m.reposList.SetItems(items)
+}
+
+func (m *model) refreshACL() {
+ repo, err := m.ops.GetRepo(m.selectedRepo)
+ if err != nil {
+ m.aclList.SetItems(nil)
+ return
+ }
+ items := []list.Item{
+ collaboratorItem{c: store.Collaborator{Principal: repo.Owner, Role: store.RoleAdmin}, isOwner: true},
+ }
+ a, err := m.ops.GetACL(m.selectedRepo)
+ if err == nil {
+ for _, c := range a.Collaborators {
+ items = append(items, collaboratorItem{c: c})
+ }
+ }
+ m.aclList.SetItems(items)
+}
+
+func (m *model) refreshTrust() {
+ trusted, _ := m.ops.ListTrustedCAs()
+ items := make([]list.Item, len(trusted))
+ for i, t := range trusted {
+ items[i] = trustItem{t}
+ }
+ m.trustList.SetItems(items)
+}
+
+func (m *model) refreshAudit() {
+ events, _ := m.ops.ListAudit(200)
+ items := make([]list.Item, len(events))
+ for i, e := range events {
+ items[i] = auditItem{e}
+ }
+ m.auditList.SetItems(items)
+}
+
+func (m model) openAddUserForm() (tea.Model, tea.Cmd) {
+ a := m.ops
+ m.activeForm = newForm("Add user",
+ []string{"Username", "Public key"},
+ []string{"alice", "ssh-ed25519 AAAA... comment"},
+ func(values []string) tea.Msg {
+ username := strings.TrimSpace(values[0])
+ if username == "" {
+ return statusMsg{err: fmt.Errorf("username is required")}
+ }
+ if err := a.CreateUser(username, values[1]); err != nil {
+ return statusMsg{err: err}
+ }
+ return statusMsg{text: "user " + username + " created"}
+ },
+ func() tea.Msg { return formCancelMsg{} },
+ )
+ return m, nil
+}
+
+func (m model) deleteSelectedUser() (tea.Model, tea.Cmd) {
+ item, ok := m.usersList.SelectedItem().(userItem)
+ if !ok {
+ return m, nil
+ }
+ if err := m.ops.DeleteUser(item.u.Username); err != nil {
+ m.err, m.status = err, ""
+ } else {
+ m.status, m.err = "deleted user "+item.u.Username, nil
+ }
+ m.refreshUsers()
+ return m, nil
+}
+
+func (m model) openAddRepoForm() (tea.Model, tea.Cmd) {
+ a := m.ops
+ m.activeForm = newForm("Add repo",
+ []string{"Name", "Owner (local username)"},
+ []string{"alice/mon-projet", "alice"},
+ func(values []string) tea.Msg {
+ name := strings.TrimSpace(values[0])
+ owner := strings.TrimSpace(values[1])
+ if name == "" || owner == "" {
+ return statusMsg{err: fmt.Errorf("name and owner are required")}
+ }
+ if err := a.CreateRepo(name, owner); err != nil {
+ return statusMsg{err: err}
+ }
+ return statusMsg{text: "repo " + name + " created"}
+ },
+ func() tea.Msg { return formCancelMsg{} },
+ )
+ return m, nil
+}
+
+func (m model) deleteSelectedRepo() (tea.Model, tea.Cmd) {
+ item, ok := m.reposList.SelectedItem().(repoItem)
+ if !ok {
+ return m, nil
+ }
+ if err := m.ops.DeleteRepo(item.r.Name); err != nil {
+ m.err, m.status = err, ""
+ } else {
+ m.status, m.err = "deleted repo "+item.r.Name, nil
+ }
+ m.refreshRepos()
+ return m, nil
+}
+
+func (m model) openACLForSelectedRepo() (tea.Model, tea.Cmd) {
+ item, ok := m.reposList.SelectedItem().(repoItem)
+ if !ok {
+ return m, nil
+ }
+ m.selectedRepo = item.r.Name
+ m.aclList.Title = "Collaborators — " + item.r.Name
+ m.refreshACL()
+ m.view = viewACL
+ m.status, m.err = "", nil
+ return m, nil
+}
+
+func (m model) openAddCollaboratorForm() (tea.Model, tea.Cmd) {
+ a := m.ops
+ repoName := m.selectedRepo
+ m.activeForm = newForm("Add collaborator",
+ []string{"Principal", "Role"},
+ []string{"bob@instanceb.example", "read|write|admin"},
+ func(values []string) tea.Msg {
+ principal := strings.TrimSpace(values[0])
+ role := store.Role(strings.ToLower(strings.TrimSpace(values[1])))
+ if !role.Valid() {
+ return statusMsg{err: fmt.Errorf("role must be read, write or admin")}
+ }
+ if err := a.GrantCollaborator(repoName, principal, role); err != nil {
+ return statusMsg{err: err}
+ }
+ return statusMsg{text: principal + " granted " + string(role) + " on " + repoName}
+ },
+ func() tea.Msg { return formCancelMsg{} },
+ )
+ return m, nil
+}
+
+func (m model) deleteSelectedCollaborator() (tea.Model, tea.Cmd) {
+ item, ok := m.aclList.SelectedItem().(collaboratorItem)
+ if !ok {
+ return m, nil
+ }
+ if item.isOwner {
+ m.status, m.err = "", fmt.Errorf("cannot remove the repo owner")
+ return m, nil
+ }
+ if err := m.ops.RevokeCollaborator(m.selectedRepo, item.c.Principal); err != nil {
+ m.err, m.status = err, ""
+ } else {
+ m.status, m.err = "removed "+item.c.Principal, nil
+ }
+ m.refreshACL()
+ return m, nil
+}
+
+func (m model) approveSelectedDomain() (tea.Model, tea.Cmd) {
+ item, ok := m.trustList.SelectedItem().(trustItem)
+ if !ok {
+ return m, nil
+ }
+ if err := m.ops.ApproveDomain(item.t.Domain); err != nil {
+ m.err, m.status = err, ""
+ } else {
+ m.status, m.err = "approved "+item.t.Domain, nil
+ }
+ m.refreshTrust()
+ return m, nil
+}
cmd/gitfed-tui/form.go
@@ -0,0 +1,84 @@
+package main
+
+import (
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+// form is a tiny multi-field text input flow: tab/down/up move focus,
+// enter on the last field submits, esc cancels.
+type form struct {
+ title string
+ labels []string
+ inputs []textinput.Model
+ focus int
+ onSubmit func(values []string) tea.Msg
+ onCancel func() tea.Msg
+}
+
+func newForm(title string, labels []string, placeholders []string, onSubmit func([]string) tea.Msg, onCancel func() tea.Msg) *form {
+ inputs := make([]textinput.Model, len(labels))
+ for i := range inputs {
+ ti := textinput.New()
+ if i < len(placeholders) {
+ ti.Placeholder = placeholders[i]
+ }
+ ti.CharLimit = 256
+ ti.Width = 60
+ if i == 0 {
+ ti.Focus()
+ }
+ inputs[i] = ti
+ }
+ return &form{title: title, labels: labels, inputs: inputs, onSubmit: onSubmit, onCancel: onCancel}
+}
+
+func (f *form) Update(msg tea.Msg) (*form, tea.Cmd) {
+ if keyMsg, ok := msg.(tea.KeyMsg); ok {
+ switch keyMsg.String() {
+ case "esc":
+ return f, func() tea.Msg { return f.onCancel() }
+ case "tab", "down":
+ f.inputs[f.focus].Blur()
+ f.focus = (f.focus + 1) % len(f.inputs)
+ f.inputs[f.focus].Focus()
+ return f, nil
+ case "shift+tab", "up":
+ f.inputs[f.focus].Blur()
+ f.focus = (f.focus - 1 + len(f.inputs)) % len(f.inputs)
+ f.inputs[f.focus].Focus()
+ return f, nil
+ case "enter":
+ if f.focus == len(f.inputs)-1 {
+ values := make([]string, len(f.inputs))
+ for i, in := range f.inputs {
+ values[i] = in.Value()
+ }
+ return f, func() tea.Msg { return f.onSubmit(values) }
+ }
+ f.inputs[f.focus].Blur()
+ f.focus++
+ f.inputs[f.focus].Focus()
+ return f, nil
+ }
+ }
+ var cmd tea.Cmd
+ f.inputs[f.focus], cmd = f.inputs[f.focus].Update(msg)
+ return f, cmd
+}
+
+var (
+ formTitleStyle = lipgloss.NewStyle().Bold(true).MarginBottom(1)
+ formLabelStyle = lipgloss.NewStyle().Width(14)
+ formHintStyle = lipgloss.NewStyle().Faint(true).MarginTop(1)
+)
+
+func (f *form) View() string {
+ s := formTitleStyle.Render(f.title) + "\n"
+ for i, in := range f.inputs {
+ s += formLabelStyle.Render(f.labels[i]+":") + in.View() + "\n"
+ }
+ s += formHintStyle.Render("tab/↑↓ move · enter next/submit · esc cancel")
+ return s
+}
cmd/gitfed-tui/items.go
@@ -0,0 +1,76 @@
+package main
+
+import (
+ "fmt"
+
+ "gitfed/internal/store"
+)
+
+type userItem struct{ u store.User }
+
+func (i userItem) Title() string { return i.u.Username }
+func (i userItem) Description() string {
+ return fmt.Sprintf("%d key(s)", len(i.u.PubKeys))
+}
+func (i userItem) FilterValue() string { return i.u.Username }
+
+type repoItem struct{ r store.Repo }
+
+func (i repoItem) Title() string { return i.r.Name }
+func (i repoItem) Description() string { return "owner: " + i.r.Owner }
+func (i repoItem) FilterValue() string { return i.r.Name }
+
+type collaboratorItem struct {
+ c store.Collaborator
+ isOwner bool
+}
+
+func (i collaboratorItem) Title() string {
+ if i.isOwner {
+ return i.c.Principal + " (owner)"
+ }
+ return i.c.Principal
+}
+func (i collaboratorItem) Description() string { return string(i.c.Role) }
+func (i collaboratorItem) FilterValue() string { return i.c.Principal }
+
+type trustItem struct{ t store.TrustedCA }
+
+func (i trustItem) Title() string { return i.t.Domain }
+func (i trustItem) Description() string { return string(i.t.Status) }
+func (i trustItem) FilterValue() string { return i.t.Domain }
+
+type auditItem struct{ e store.AuditEvent }
+
+func (i auditItem) Title() string {
+ mark := "✓"
+ if !i.e.Allowed {
+ mark = "✗"
+ }
+ return fmt.Sprintf("%s %s %s", mark, i.e.Time.Local().Format("15:04:05"), i.e.Action)
+}
+func (i auditItem) Description() string {
+ who := i.e.Principal
+ if who == "" {
+ who = "?"
+ }
+ target := i.e.Repo
+ if target == "" {
+ target = i.e.Domain
+ }
+ s := who
+ if target != "" {
+ s += " · " + target
+ }
+ if i.e.Detail != "" {
+ s += " · " + i.e.Detail
+ }
+ return s
+}
+func (i auditItem) FilterValue() string { return i.e.Action + " " + i.e.Principal }
+
+type menuItem struct{ title, desc string }
+
+func (i menuItem) Title() string { return i.title }
+func (i menuItem) Description() string { return i.desc }
+func (i menuItem) FilterValue() string { return i.title }
cmd/gitfed-tui/main.go
@@ -0,0 +1,45 @@
+// gitfed-tui is the admin terminal UI for managing users, repos, ACLs and
+// the federation trust store, per DESIGN.md §2/§9 (Phase 1-2).
+//
+// It talks to a running gitfed-server over its admin socket when one is up
+// (live mode), or opens the store directly when it isn't (offline mode,
+// e.g. first-time setup before the server has ever run). See
+// internal/opsconnect for the shared connection logic (also used by
+// gitfed-web).
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+
+ tea "github.com/charmbracelet/bubbletea"
+
+ "gitfed/internal/config"
+ "gitfed/internal/opsconnect"
+)
+
+func main() {
+ configPath := flag.String("config", "gitfed.json", "path to instance config file")
+ flag.Parse()
+
+ cfg, err := config.Load(*configPath)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "gitfed-tui: load config: %v\n", err)
+ fmt.Fprintln(os.Stderr, "hint: run gitfed-server -init <domain> first")
+ os.Exit(1)
+ }
+
+ ops, mode, closeFn, err := opsconnect.Connect(cfg)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "gitfed-tui: %v\n", err)
+ os.Exit(1)
+ }
+ defer closeFn()
+
+ p := tea.NewProgram(newModel(ops, cfg.Domain, mode), tea.WithAltScreen())
+ if _, err := p.Run(); err != nil {
+ fmt.Fprintf(os.Stderr, "gitfed-tui: %v\n", err)
+ os.Exit(1)
+ }
+}
cmd/gitfed-tui/model.go
@@ -0,0 +1,291 @@
+package main
+
+import (
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+
+ "gitfed/internal/admin"
+)
+
+type view int
+
+const (
+ viewMain view = iota
+ viewUsers
+ viewRepos
+ viewACL
+ viewTrust
+ viewAudit
+)
+
+type statusMsg struct {
+ text string
+ err error
+}
+
+type formCancelMsg struct{}
+
+type model struct {
+ ops admin.Ops
+ domain string
+ mode string
+
+ view view
+
+ mainList list.Model
+ usersList list.Model
+ reposList list.Model
+ aclList list.Model
+ trustList list.Model
+ auditList list.Model
+
+ selectedRepo string
+ activeForm *form
+
+ status string
+ err error
+
+ width, height int
+}
+
+func newModel(ops admin.Ops, domain, mode string) model {
+ mkList := func(title string, items []list.Item) list.Model {
+ l := list.New(items, list.NewDefaultDelegate(), 0, 0)
+ l.Title = title
+ l.SetShowHelp(false)
+ return l
+ }
+
+ m := model{
+ ops: ops,
+ domain: domain,
+ mode: mode,
+ view: viewMain,
+ mainList: mkList("gitfed admin — "+domain, []list.Item{
+ menuItem{"Users", "manage local users & keys"},
+ menuItem{"Repos", "manage repositories & ACLs"},
+ menuItem{"Trust store", "approve/inspect remote CAs"},
+ menuItem{"Audit log", "recent auth & access events"},
+ }),
+ usersList: mkList("Users", nil),
+ reposList: mkList("Repos", nil),
+ aclList: mkList("Collaborators", nil),
+ trustList: mkList("Trust store", nil),
+ auditList: mkList("Audit log", nil),
+ }
+ m.refreshUsers()
+ m.refreshRepos()
+ m.refreshTrust()
+ m.refreshAudit()
+ return m
+}
+
+func (m model) Init() tea.Cmd { return nil }
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width, m.height = msg.Width, msg.Height
+ h := msg.Height - 4
+ if h < 3 {
+ h = 3
+ }
+ m.mainList.SetSize(msg.Width, h)
+ m.usersList.SetSize(msg.Width, h)
+ m.reposList.SetSize(msg.Width, h)
+ m.aclList.SetSize(msg.Width, h)
+ m.trustList.SetSize(msg.Width, h)
+ m.auditList.SetSize(msg.Width, h)
+ return m, nil
+
+ case statusMsg:
+ m.status, m.err = msg.text, msg.err
+ m.activeForm = nil
+ switch m.view {
+ case viewUsers:
+ m.refreshUsers()
+ case viewRepos:
+ m.refreshRepos()
+ case viewACL:
+ m.refreshACL()
+ case viewTrust:
+ m.refreshTrust()
+ case viewAudit:
+ m.refreshAudit()
+ }
+ return m, nil
+
+ case formCancelMsg:
+ m.activeForm = nil
+ return m, nil
+
+ case tea.KeyMsg:
+ if m.activeForm != nil {
+ f, cmd := m.activeForm.Update(msg)
+ m.activeForm = f
+ return m, cmd
+ }
+ return m.handleKey(msg)
+ }
+ return m, nil
+}
+
+func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ if msg.String() == "ctrl+c" {
+ return m, tea.Quit
+ }
+
+ switch m.view {
+ case viewMain:
+ switch msg.String() {
+ case "q":
+ return m, tea.Quit
+ case "enter":
+ switch m.mainList.Index() {
+ case 0:
+ m.view = viewUsers
+ case 1:
+ m.view = viewRepos
+ case 2:
+ m.view = viewTrust
+ case 3:
+ m.refreshAudit()
+ m.view = viewAudit
+ }
+ m.status, m.err = "", nil
+ return m, nil
+ }
+ var cmd tea.Cmd
+ m.mainList, cmd = m.mainList.Update(msg)
+ return m, cmd
+
+ case viewUsers:
+ switch msg.String() {
+ case "esc", "q":
+ m.view, m.status, m.err = viewMain, "", nil
+ return m, nil
+ case "a":
+ return m.openAddUserForm()
+ case "d":
+ return m.deleteSelectedUser()
+ }
+ var cmd tea.Cmd
+ m.usersList, cmd = m.usersList.Update(msg)
+ return m, cmd
+
+ case viewRepos:
+ switch msg.String() {
+ case "esc", "q":
+ m.view, m.status, m.err = viewMain, "", nil
+ return m, nil
+ case "a":
+ return m.openAddRepoForm()
+ case "d":
+ return m.deleteSelectedRepo()
+ case "enter":
+ return m.openACLForSelectedRepo()
+ }
+ var cmd tea.Cmd
+ m.reposList, cmd = m.reposList.Update(msg)
+ return m, cmd
+
+ case viewACL:
+ switch msg.String() {
+ case "esc", "q":
+ m.view, m.status, m.err = viewRepos, "", nil
+ return m, nil
+ case "a":
+ return m.openAddCollaboratorForm()
+ case "d":
+ return m.deleteSelectedCollaborator()
+ }
+ var cmd tea.Cmd
+ m.aclList, cmd = m.aclList.Update(msg)
+ return m, cmd
+
+ case viewTrust:
+ switch msg.String() {
+ case "esc", "q":
+ m.view, m.status, m.err = viewMain, "", nil
+ return m, nil
+ case "a":
+ return m.approveSelectedDomain()
+ }
+ var cmd tea.Cmd
+ m.trustList, cmd = m.trustList.Update(msg)
+ return m, cmd
+
+ case viewAudit:
+ switch msg.String() {
+ case "esc", "q":
+ m.view, m.status, m.err = viewMain, "", nil
+ return m, nil
+ case "r":
+ m.refreshAudit()
+ return m, nil
+ }
+ var cmd tea.Cmd
+ m.auditList, cmd = m.auditList.Update(msg)
+ return m, cmd
+ }
+ return m, nil
+}
+
+var (
+ errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
+ statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
+ helpStyle = lipgloss.NewStyle().Faint(true)
+)
+
+func (m model) View() string {
+ var body string
+ switch m.view {
+ case viewMain:
+ body = m.mainList.View()
+ case viewUsers:
+ body = m.usersList.View()
+ case viewRepos:
+ body = m.reposList.View()
+ case viewACL:
+ body = m.aclList.View()
+ case viewTrust:
+ body = m.trustList.View()
+ case viewAudit:
+ body = m.auditList.View()
+ }
+ if m.activeForm != nil {
+ body = m.activeForm.View()
+ }
+
+ footer := ""
+ switch {
+ case m.err != nil:
+ footer = errStyle.Render("error: " + m.err.Error())
+ case m.status != "":
+ footer = statusStyle.Render(m.status)
+ }
+
+ return lipgloss.JoinVertical(lipgloss.Left, body, footer, helpStyle.Render(m.helpText()), helpStyle.Render("mode: "+m.mode))
+}
+
+func (m model) helpText() string {
+ if m.activeForm != nil {
+ return ""
+ }
+ switch m.view {
+ case viewMain:
+ return "↑/↓: navigate · enter: open · q: quit"
+ case viewUsers:
+ return "a: add user · d: delete user · esc: back"
+ case viewRepos:
+ return "a: add repo · d: delete repo · enter: manage ACL · esc: back"
+ case viewACL:
+ return "a: add collaborator · d: remove collaborator · esc: back"
+ case viewTrust:
+ return "a: approve pending domain · esc: back"
+ case viewAudit:
+ return "r: refresh · esc: back"
+ }
+ return ""
+}
cmd/gitfed-web/handlers_acl.go
@@ -0,0 +1,143 @@
+package main
+
+import (
+ "bytes"
+ "html/template"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "gitfed/internal/store"
+)
+
+var aclTpl = template.Must(template.New("acl").Parse(`
+{{.Flash}}
+<p><a href="/repos">← Repos</a></p>
+<h2>{{.Repo}} {{if .Public}}<span class="badge trusted">public</span>{{else}}<span class="badge pending">private</span>{{end}}</h2>
+
+<form class="card" method="post" action="/repos/settings">
+ <strong>Visibility & topics</strong>
+ <input type="hidden" name="repo" value="{{.Repo}}">
+ <label><input type="checkbox" name="public" value="1" style="width:auto; display:inline-block;" {{if .Public}}checked{{end}}> Public (readable by any authenticated principal, per DESIGN.md §5.2)</label>
+ <label>Topics (comma-separated)</label>
+ <input name="topics" value="{{.TopicsCSV}}" placeholder="cli, tooling, go">
+ <button type="submit">Save</button>
+</form>
+
+<p class="muted">Git tags: {{if .Tags}}{{range $i, $t := .Tags}}{{if $i}}, {{end}}<code>{{$t}}</code>{{end}}{{else}}none{{end}}{{if .Public}} · public repo browsing (README/license) is served by gitfed-site{{end}}</p>
+
+<table>
+<tr><th>Principal</th><th>Role</th><th></th></tr>
+<tr>
+ <td>{{.Owner}}</td>
+ <td>admin</td>
+ <td class="muted">owner</td>
+</tr>
+{{range .Collaborators}}
+<tr>
+ <td>{{.Principal}}</td>
+ <td>{{.Role}}</td>
+ <td>
+ <form class="inline" method="post" action="/repos/acl/revoke">
+ <input type="hidden" name="repo" value="{{$.Repo}}">
+ <input type="hidden" name="principal" value="{{.Principal}}">
+ <button class="danger" type="submit">Revoke</button>
+ </form>
+ </td>
+</tr>
+{{end}}
+</table>
+
+<form class="card" method="post" action="/repos/acl/grant">
+ <strong>Grant collaborator</strong>
+ <input type="hidden" name="repo" value="{{.Repo}}">
+ <label>Principal</label>
+ <input name="principal" required placeholder="bob@instanceb.example">
+ <label>Role</label>
+ <select name="role">
+ <option value="read">read</option>
+ <option value="write">write</option>
+ <option value="admin">admin</option>
+ </select>
+ <button type="submit">Grant</button>
+</form>
+<p class="muted">Granting a collaborator on a domain not yet known to this instance triggers federation discovery of that domain's CA (DESIGN.md §5.2) — check <a href="/trust">Trust store</a> afterwards if it needs approval.</p>
+`))
+
+func (s *server) handleACL(w http.ResponseWriter, r *http.Request) {
+ repoName := r.URL.Query().Get("repo")
+ repo, err := s.ops.GetRepo(repoName)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusNotFound)
+ return
+ }
+ acl, err := s.ops.GetACL(repoName)
+ if err != nil && err != store.ErrNotFound {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ tags, err := s.ops.ListRepoTags(repoName)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ var buf bytes.Buffer
+ _ = aclTpl.Execute(&buf, struct {
+ Repo string
+ Owner string
+ Public bool
+ TopicsCSV string
+ Tags []string
+ Collaborators []store.Collaborator
+ Flash template.HTML
+ }{repoName, repo.Owner, repo.Public, strings.Join(repo.Topics, ", "), tags, acl.Collaborators, flash(r)})
+ s.render(w, "ACL — "+repoName, "repos", template.HTML(buf.String()))
+}
+
+func (s *server) handleRepoSettings(w http.ResponseWriter, r *http.Request) {
+ repo := r.FormValue("repo")
+ back := "/repos/acl?repo=" + url.QueryEscape(repo)
+
+ public := r.FormValue("public") == "1"
+ if err := s.ops.SetRepoPublic(repo, public); err != nil {
+ redirectWithMsg(w, r, back, err.Error(), true)
+ return
+ }
+
+ var topics []string
+ for _, t := range strings.Split(r.FormValue("topics"), ",") {
+ if t = strings.TrimSpace(t); t != "" {
+ topics = append(topics, t)
+ }
+ }
+ if err := s.ops.SetRepoTopics(repo, topics); err != nil {
+ redirectWithMsg(w, r, back, err.Error(), true)
+ return
+ }
+
+ redirectWithMsg(w, r, back, "saved settings", false)
+}
+
+func (s *server) handleACLGrant(w http.ResponseWriter, r *http.Request) {
+ repo := r.FormValue("repo")
+ principal := r.FormValue("principal")
+ role := store.Role(r.FormValue("role"))
+ back := "/repos/acl?repo=" + url.QueryEscape(repo)
+ if err := s.ops.GrantCollaborator(repo, principal, role); err != nil {
+ redirectWithMsg(w, r, back, err.Error(), true)
+ return
+ }
+ redirectWithMsg(w, r, back, "granted "+principal+" "+string(role), false)
+}
+
+func (s *server) handleACLRevoke(w http.ResponseWriter, r *http.Request) {
+ repo := r.FormValue("repo")
+ principal := r.FormValue("principal")
+ back := "/repos/acl?repo=" + url.QueryEscape(repo)
+ if err := s.ops.RevokeCollaborator(repo, principal); err != nil {
+ redirectWithMsg(w, r, back, err.Error(), true)
+ return
+ }
+ redirectWithMsg(w, r, back, "revoked "+principal, false)
+}
cmd/gitfed-web/handlers_audit.go
@@ -0,0 +1,41 @@
+package main
+
+import (
+ "bytes"
+ "html/template"
+ "net/http"
+
+ "gitfed/internal/store"
+)
+
+var auditTpl = template.Must(template.New("audit").Parse(`
+<table>
+<tr><th>Time</th><th>Action</th><th>Principal</th><th>Repo/Domain</th><th>Result</th><th>Detail</th></tr>
+{{range .Events}}
+<tr>
+ <td class="muted">{{.Time.Local.Format "2006-01-02 15:04:05"}}</td>
+ <td>{{.Action}}</td>
+ <td>{{.Principal}}</td>
+ <td>{{if .Repo}}{{.Repo}}{{else}}{{.Domain}}{{end}}</td>
+ <td>{{if .Allowed}}<span class="badge trusted">allow</span>{{else}}<span class="badge pending">deny</span>{{end}}</td>
+ <td class="muted">{{.Detail}}</td>
+</tr>
+{{else}}
+<tr><td colspan="6" class="muted">No events recorded yet.</td></tr>
+{{end}}
+</table>
+<p class="muted">Showing the most recent 200 events.</p>
+`))
+
+func (s *server) handleAudit(w http.ResponseWriter, r *http.Request) {
+ events, err := s.ops.ListAudit(200)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ var buf bytes.Buffer
+ _ = auditTpl.Execute(&buf, struct {
+ Events []store.AuditEvent
+ }{events})
+ s.render(w, "Audit log", "audit", template.HTML(buf.String()))
+}
cmd/gitfed-web/handlers_home.go
@@ -0,0 +1,48 @@
+package main
+
+import (
+ "bytes"
+ "html/template"
+ "net/http"
+
+ "gitfed/internal/store"
+)
+
+var homeTpl = template.Must(template.New("home").Parse(`
+{{.Flash}}
+<div style="display:flex; gap:1rem;">
+ <div class="card" style="flex:1; background:#171a21; border:1px solid #2a2f3a; border-radius:8px; padding:1rem;">
+ <div class="muted">Users</div>
+ <div style="font-size:1.8rem;">{{.Users}}</div>
+ </div>
+ <div class="card" style="flex:1; background:#171a21; border:1px solid #2a2f3a; border-radius:8px; padding:1rem;">
+ <div class="muted">Repos</div>
+ <div style="font-size:1.8rem;">{{.Repos}}</div>
+ </div>
+ <div class="card" style="flex:1; background:#171a21; border:1px solid #2a2f3a; border-radius:8px; padding:1rem;">
+ <div class="muted">Trusted domains</div>
+ <div style="font-size:1.8rem;">{{.Trust}}{{if .Pending}} <span class="badge pending">{{.Pending}} pending</span>{{end}}</div>
+ </div>
+</div>
+<p class="muted" style="margin-top:1.5rem;">Manage users and their keys under <a href="/users">Users</a>, repos and per-repo collaborators under <a href="/repos">Repos</a>, remote CA trust under <a href="/trust">Trust store</a>, and review access history under <a href="/audit">Audit log</a>.</p>
+`))
+
+func (s *server) handleHome(w http.ResponseWriter, r *http.Request) {
+ users, _ := s.ops.ListUsers()
+ repos, _ := s.ops.ListRepos()
+ trust, _ := s.ops.ListTrustedCAs()
+ pending := 0
+ for _, t := range trust {
+ if t.Status == store.TrustPending {
+ pending++
+ }
+ }
+
+ var buf bytes.Buffer
+ _ = homeTpl.Execute(&buf, struct {
+ Users, Repos, Trust, Pending int
+ Flash template.HTML
+ }{len(users), len(repos), len(trust), pending, flash(r)})
+
+ s.render(w, "Home", "home", template.HTML(buf.String()))
+}
cmd/gitfed-web/handlers_repos.go
@@ -0,0 +1,74 @@
+package main
+
+import (
+ "bytes"
+ "html/template"
+ "net/http"
+
+ "gitfed/internal/store"
+)
+
+var reposTpl = template.Must(template.New("repos").Parse(`
+{{.Flash}}
+<table>
+<tr><th>Repo</th><th>Owner</th><th>Visibility</th><th>Topics</th><th></th></tr>
+{{range .Repos}}
+<tr>
+ <td><a href="/repos/acl?repo={{.Name}}">{{.Name}}</a></td>
+ <td>{{.Owner}}</td>
+ <td>{{if .Public}}<span class="badge trusted">public</span>{{else}}<span class="badge pending">private</span>{{end}}</td>
+ <td class="muted">{{range $i, $t := .Topics}}{{if $i}}, {{end}}{{$t}}{{end}}</td>
+ <td>
+ <form class="inline" method="post" action="/repos/delete" onsubmit="return confirm('Delete repo {{.Name}}? This does not delete the bare repo on disk.');">
+ <input type="hidden" name="name" value="{{.Name}}">
+ <button class="danger" type="submit">Delete</button>
+ </form>
+ </td>
+</tr>
+{{else}}
+<tr><td colspan="5" class="muted">No repos yet.</td></tr>
+{{end}}
+</table>
+
+<form class="card" method="post" action="/repos">
+ <strong>Add repo</strong>
+ <label>Name</label>
+ <input name="name" required placeholder="alice/mon-projet">
+ <label>Owner (local username)</label>
+ <input name="owner" required placeholder="alice">
+ <button type="submit">Create</button>
+</form>
+`))
+
+func (s *server) handleReposList(w http.ResponseWriter, r *http.Request) {
+ repos, err := s.ops.ListRepos()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ var buf bytes.Buffer
+ _ = reposTpl.Execute(&buf, struct {
+ Repos []store.Repo
+ Flash template.HTML
+ }{repos, flash(r)})
+ s.render(w, "Repos", "repos", template.HTML(buf.String()))
+}
+
+func (s *server) handleReposCreate(w http.ResponseWriter, r *http.Request) {
+ name := r.FormValue("name")
+ owner := r.FormValue("owner")
+ if err := s.ops.CreateRepo(name, owner); err != nil {
+ redirectWithMsg(w, r, "/repos", err.Error(), true)
+ return
+ }
+ redirectWithMsg(w, r, "/repos", "created repo "+name, false)
+}
+
+func (s *server) handleReposDelete(w http.ResponseWriter, r *http.Request) {
+ name := r.FormValue("name")
+ if err := s.ops.DeleteRepo(name); err != nil {
+ redirectWithMsg(w, r, "/repos", err.Error(), true)
+ return
+ }
+ redirectWithMsg(w, r, "/repos", "deleted repo "+name, false)
+}
cmd/gitfed-web/handlers_trust.go
@@ -0,0 +1,57 @@
+package main
+
+import (
+ "bytes"
+ "html/template"
+ "net/http"
+
+ "gitfed/internal/store"
+)
+
+var trustTpl = template.Must(template.New("trust").Parse(`
+{{.Flash}}
+<table>
+<tr><th>Domain</th><th>Status</th><th>First seen</th><th></th></tr>
+{{range .Trust}}
+<tr>
+ <td>{{.Domain}}</td>
+ <td><span class="badge {{.Status}}">{{.Status}}</span></td>
+ <td class="muted">{{.FirstSeenAt.Format "2006-01-02 15:04"}}</td>
+ <td>
+ {{if eq .Status "pending"}}
+ <form class="inline" method="post" action="/trust/approve">
+ <input type="hidden" name="domain" value="{{.Domain}}">
+ <button type="submit">Approve</button>
+ </form>
+ {{end}}
+ </td>
+</tr>
+{{else}}
+<tr><td colspan="4" class="muted">No remote domains discovered yet.</td></tr>
+{{end}}
+</table>
+<p class="muted">Domains show up here automatically the first time a collaborator on a remote domain is granted access to a repo (DESIGN.md §5.2). Under the default whitelist policy they stay <span class="badge pending">pending</span> until approved here.</p>
+`))
+
+func (s *server) handleTrustList(w http.ResponseWriter, r *http.Request) {
+ trust, err := s.ops.ListTrustedCAs()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ var buf bytes.Buffer
+ _ = trustTpl.Execute(&buf, struct {
+ Trust []store.TrustedCA
+ Flash template.HTML
+ }{trust, flash(r)})
+ s.render(w, "Trust store", "trust", template.HTML(buf.String()))
+}
+
+func (s *server) handleTrustApprove(w http.ResponseWriter, r *http.Request) {
+ domain := r.FormValue("domain")
+ if err := s.ops.ApproveDomain(domain); err != nil {
+ redirectWithMsg(w, r, "/trust", err.Error(), true)
+ return
+ }
+ redirectWithMsg(w, r, "/trust", "approved "+domain, false)
+}
cmd/gitfed-web/handlers_users.go
@@ -0,0 +1,72 @@
+package main
+
+import (
+ "bytes"
+ "html/template"
+ "net/http"
+
+ "gitfed/internal/store"
+)
+
+var usersTpl = template.Must(template.New("users").Parse(`
+{{.Flash}}
+<table>
+<tr><th>Username</th><th>Keys</th><th></th></tr>
+{{range .Users}}
+<tr>
+ <td>{{.Username}}</td>
+ <td>{{len .PubKeys}}</td>
+ <td>
+ <form class="inline" method="post" action="/users/delete" onsubmit="return confirm('Delete user {{.Username}}?');">
+ <input type="hidden" name="username" value="{{.Username}}">
+ <button class="danger" type="submit">Delete</button>
+ </form>
+ </td>
+</tr>
+{{else}}
+<tr><td colspan="3" class="muted">No users yet.</td></tr>
+{{end}}
+</table>
+
+<form class="card" method="post" action="/users">
+ <strong>Add user</strong>
+ <label>Username</label>
+ <input name="username" required placeholder="alice">
+ <label>Public key (authorized_keys format)</label>
+ <input name="pubkey" required placeholder="ssh-ed25519 AAAA... comment">
+ <button type="submit">Create</button>
+</form>
+`))
+
+func (s *server) handleUsersList(w http.ResponseWriter, r *http.Request) {
+ users, err := s.ops.ListUsers()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ var buf bytes.Buffer
+ _ = usersTpl.Execute(&buf, struct {
+ Users []store.User
+ Flash template.HTML
+ }{users, flash(r)})
+ s.render(w, "Users", "users", template.HTML(buf.String()))
+}
+
+func (s *server) handleUsersCreate(w http.ResponseWriter, r *http.Request) {
+ username := r.FormValue("username")
+ pubkey := r.FormValue("pubkey")
+ if err := s.ops.CreateUser(username, pubkey); err != nil {
+ redirectWithMsg(w, r, "/users", err.Error(), true)
+ return
+ }
+ redirectWithMsg(w, r, "/users", "created user "+username, false)
+}
+
+func (s *server) handleUsersDelete(w http.ResponseWriter, r *http.Request) {
+ username := r.FormValue("username")
+ if err := s.ops.DeleteUser(username); err != nil {
+ redirectWithMsg(w, r, "/users", err.Error(), true)
+ return
+ }
+ redirectWithMsg(w, r, "/users", "deleted user "+username, false)
+}
cmd/gitfed-web/main.go
@@ -0,0 +1,62 @@
+// gitfed-web is a minimal HTTP admin UI for users, repos, ACLs, the trust
+// store and the audit log — DESIGN.md §9 Phase 4 ("UI web plus tard,
+// branchée sur le même noyau identité/ACL"). It drives the exact same
+// admin.Ops interface as gitfed-tui (live socket or offline store, via
+// internal/opsconnect), so there is no second implementation of any admin
+// logic to keep in sync.
+//
+// This binds to 127.0.0.1 by default and has no login of its own: anyone
+// who can reach it has full admin control of the instance, the same trust
+// boundary as gitfed-tui or a shell on the box. Do not expose -listen on a
+// public or shared network without putting a real authenticating proxy in
+// front of it.
+package main
+
+import (
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+
+ "gitfed/internal/admin"
+ "gitfed/internal/config"
+ "gitfed/internal/opsconnect"
+)
+
+type server struct {
+ ops admin.Ops
+ domain string
+ mode string
+}
+
+func main() {
+ configPath := flag.String("config", "gitfed.json", "path to instance config file")
+ listen := flag.String("listen", "127.0.0.1:8088", "address to serve the admin UI on")
+ flag.Parse()
+
+ cfg, err := config.Load(*configPath)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "gitfed-web: load config: %v\n", err)
+ fmt.Fprintln(os.Stderr, "hint: run gitfed-server -init <domain> first")
+ os.Exit(1)
+ }
+
+ ops, mode, closeFn, err := opsconnect.Connect(cfg)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "gitfed-web: %v\n", err)
+ os.Exit(1)
+ }
+ defer closeFn()
+
+ s := &server{ops: ops, domain: cfg.Domain, mode: mode}
+
+ mux := http.NewServeMux()
+ s.routes(mux)
+
+ log.Printf("gitfed-web serving on http://%s (mode: %s)", *listen, mode)
+ if err := http.ListenAndServe(*listen, mux); err != nil {
+ fmt.Fprintln(os.Stderr, "gitfed-web:", err)
+ os.Exit(1)
+ }
+}
cmd/gitfed-web/render.go
@@ -0,0 +1,97 @@
+package main
+
+import (
+ "html/template"
+ "net/http"
+ "strings"
+)
+
+const shellSrc = `<!doctype html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>{{.Title}} — gitfed</title>
+<style>
+ body { font-family: -apple-system, system-ui, sans-serif; margin: 0; background: #0f1115; color: #e6e6e6; }
+ header { background: #171a21; padding: 0.75rem 1.5rem; display: flex; align-items: center; gap: 1.5rem; border-bottom: 1px solid #2a2f3a; }
+ header h1 { font-size: 1rem; margin: 0; color: #8ab4f8; }
+ nav a { color: #cfd3dc; text-decoration: none; margin-right: 1rem; font-size: 0.9rem; }
+ nav a.active { color: #8ab4f8; font-weight: bold; }
+ main { padding: 1.5rem; max-width: 960px; margin: 0 auto; }
+ table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
+ th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid #2a2f3a; font-size: 0.9rem; }
+ th { color: #9aa1ac; font-weight: 600; }
+ form.inline { display: inline; }
+ form.card { background: #171a21; border: 1px solid #2a2f3a; border-radius: 8px; padding: 1rem; margin: 1rem 0; max-width: 480px; }
+ form.card label { display: block; font-size: 0.85rem; color: #9aa1ac; margin-top: 0.6rem; }
+ input, select { width: 100%; box-sizing: border-box; padding: 0.4rem; margin-top: 0.2rem; background: #0f1115; border: 1px solid #333944; color: #e6e6e6; border-radius: 4px; }
+ button { margin-top: 0.8rem; padding: 0.4rem 0.9rem; background: #8ab4f8; border: none; border-radius: 4px; color: #0f1115; font-weight: 600; cursor: pointer; }
+ button.danger { background: #f28b82; }
+ .msg { padding: 0.6rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
+ .msg.ok { background: #1e3a2a; color: #9ae6b4; }
+ .msg.err { background: #3a1e1e; color: #f28b82; }
+ .badge { display: inline-block; padding: 0.1rem 0.5rem; border-radius: 10px; font-size: 0.75rem; }
+ .badge.pending { background: #3a3320; color: #f5cf5b; }
+ .badge.trusted { background: #1e3a2a; color: #9ae6b4; }
+ .muted { color: #9aa1ac; font-size: 0.85rem; }
+ code { background: #0f1115; padding: 0.1rem 0.3rem; border-radius: 4px; }
+</style>
+</head>
+<body>
+<header>
+ <h1>gitfed — {{.Domain}}</h1>
+ <nav>
+ <a href="/"{{if eq .Active "home"}} class="active"{{end}}>Home</a>
+ <a href="/users"{{if eq .Active "users"}} class="active"{{end}}>Users</a>
+ <a href="/repos"{{if eq .Active "repos"}} class="active"{{end}}>Repos</a>
+ <a href="/trust"{{if eq .Active "trust"}} class="active"{{end}}>Trust store</a>
+ <a href="/audit"{{if eq .Active "audit"}} class="active"{{end}}>Audit log</a>
+ </nav>
+ <span class="muted">{{.Mode}}</span>
+</header>
+<main>
+{{.Body}}
+</main>
+</body>
+</html>`
+
+var shellTpl = template.Must(template.New("shell").Parse(shellSrc))
+
+type shellData struct {
+ Title string
+ Active string
+ Domain string
+ Mode string
+ Body template.HTML
+}
+
+func (s *server) render(w http.ResponseWriter, title, active string, body template.HTML) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _ = shellTpl.Execute(w, shellData{Title: title, Active: active, Domain: s.domain, Mode: s.mode, Body: body})
+}
+
+// flash renders the ?msg=&err= query params (set by handlers that redirect
+// after a POST) as a status banner.
+func flash(r *http.Request) template.HTML {
+ msg := r.URL.Query().Get("msg")
+ if msg == "" {
+ return ""
+ }
+ class := "ok"
+ if r.URL.Query().Get("err") == "1" {
+ class = "err"
+ }
+ return template.HTML(`<div class="msg ` + class + `">` + template.HTMLEscapeString(msg) + `</div>`)
+}
+
+func redirectWithMsg(w http.ResponseWriter, r *http.Request, path, msg string, isErr bool) {
+ sep := "?"
+ if strings.Contains(path, "?") {
+ sep = "&"
+ }
+ q := sep + "msg=" + template.URLQueryEscaper(msg)
+ if isErr {
+ q += "&err=1"
+ }
+ http.Redirect(w, r, path+q, http.StatusSeeOther)
+}
cmd/gitfed-web/routes.go
@@ -0,0 +1,25 @@
+package main
+
+import "net/http"
+
+func (s *server) routes(mux *http.ServeMux) {
+ mux.HandleFunc("GET /{$}", s.handleHome)
+
+ mux.HandleFunc("GET /users", s.handleUsersList)
+ mux.HandleFunc("POST /users", s.handleUsersCreate)
+ mux.HandleFunc("POST /users/delete", s.handleUsersDelete)
+
+ mux.HandleFunc("GET /repos", s.handleReposList)
+ mux.HandleFunc("POST /repos", s.handleReposCreate)
+ mux.HandleFunc("POST /repos/delete", s.handleReposDelete)
+
+ mux.HandleFunc("GET /repos/acl", s.handleACL)
+ mux.HandleFunc("POST /repos/acl/grant", s.handleACLGrant)
+ mux.HandleFunc("POST /repos/acl/revoke", s.handleACLRevoke)
+ mux.HandleFunc("POST /repos/settings", s.handleRepoSettings)
+
+ mux.HandleFunc("GET /trust", s.handleTrustList)
+ mux.HandleFunc("POST /trust/approve", s.handleTrustApprove)
+
+ mux.HandleFunc("GET /audit", s.handleAudit)
+}
deploy/docker/Dockerfile
@@ -0,0 +1,32 @@
+# Builds gitfed-server, gitfed-web, gitfed-site and gitfed-tui into a single
+# image. gitfed-tui is included so an admin can `kubectl exec` into the pod
+# and drive the live admin socket directly, since gitfed-web is intentionally
+# not exposed outside the cluster (see deploy/k8s/README.md).
+#
+# gitfed-renew-cert is NOT included — it's a client-side tool end users run
+# on their own machines, not part of the server deployment.
+
+FROM golang:1.25-bookworm AS builder
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 go build -o /out/gitfed-server ./cmd/gitfed-server && \
+ CGO_ENABLED=0 go build -o /out/gitfed-web ./cmd/gitfed-web && \
+ CGO_ENABLED=0 go build -o /out/gitfed-site ./cmd/gitfed-site && \
+ CGO_ENABLED=0 go build -o /out/gitfed-tui ./cmd/gitfed-tui
+
+FROM debian:bookworm-slim
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends git ca-certificates && \
+ rm -rf /var/lib/apt/lists/* && \
+ useradd --system --create-home --home-dir /home/gitfed --uid 1000 gitfed && \
+ mkdir -p /data && chown gitfed:gitfed /data
+
+COPY --from=builder /out/gitfed-server /out/gitfed-web /out/gitfed-site /out/gitfed-tui /usr/local/bin/
+
+USER gitfed
+WORKDIR /home/gitfed
+VOLUME /data
+# No ENTRYPOINT/CMD: each container in the pod spec picks which binary to
+# run (gitfed-server / gitfed-web / gitfed-site) against the same image.
deploy/k8s/README.md
@@ -0,0 +1,138 @@
+# Deploying gitfed alongside ess-helm (k3s + Traefik + cert-manager)
+
+This assumes the same single-node k3s setup ess-helm's own install guide
+sets up: k3s with its bundled Traefik ingress controller, and cert-manager
+already installed with a `ClusterIssuer` for Let's Encrypt. gitfed reuses
+both rather than standing up anything new.
+
+## Why this shape
+
+- **One pod, three containers, one PVC.** `gitfed-server` owns the bbolt
+ store (single-writer, exclusive file lock) and a Unix admin socket at
+ `/data/admin.sock`. `gitfed-web` (admin UI) and `gitfed-site` (public
+ browser) talk to it over that socket instead of opening the database
+ themselves — see `internal/opsconnect`. That only works if they share a
+ filesystem with the server, hence one pod.
+- **`replicas: 1`, `strategy: Recreate`, forever.** bbolt, the admin socket,
+ and the SSH `hostPort` are all single-instance by construction — this
+ isn't a temporary limitation, don't try to scale it out.
+- **`gitfed-web` has no Service and no Ingress.** It grants total admin
+ control with no login of its own — reaching it is equivalent to a shell
+ on the box. It stays bound to `127.0.0.1` inside its own container by
+ default; the only way in is `kubectl port-forward` (see below).
+- **Port 22 is the VPS's own sshd** — gitfed's git+ssh listens on 2222
+ instead, exposed via `hostPort` since this is a single-node cluster (no
+ separate load balancer needed).
+
+## 1. Build and import the image
+
+From the repo root, on your workstation:
+
+```sh
+docker build -f deploy/docker/Dockerfile -t gitfed:latest .
+docker save gitfed:latest -o gitfed.tar
+scp gitfed.tar your-vps:/tmp/
+```
+
+On the VPS:
+
+```sh
+sudo k3s ctr images import /tmp/gitfed.tar
+rm /tmp/gitfed.tar
+```
+
+`imagePullPolicy: Never` in `deployment.yaml` means k3s will never try to
+pull this from a registry — it only ever uses what you've imported.
+**Whenever you rebuild, redo this import and then**
+`kubectl rollout restart deployment/gitfed -n gitfed` — importing a new
+image under the same tag does not restart pods that are already running.
+
+## 2. DNS
+
+Add one more A/AAAA record to the same zone ess-helm's subdomains live in:
+
+```
+git.example.com -> <VPS public IP>
+```
+
+Same IP as your `matrix.`/`chat.`/etc. records — Traefik will route by
+hostname for HTTP(S), and the SSH `hostPort` listens directly on the node's
+network interface regardless of Traefik.
+
+## 3. Edit the placeholders
+
+- `configmap.yaml`: `domain` and `contact`
+- `ingress.yaml`: both `git.CHANGEME.example` occurrences, and
+ `cert-manager.io/cluster-issuer` — reuse the exact issuer name from your
+ ess-helm setup, don't create a second one.
+
+## 4. Apply
+
+```sh
+kubectl apply -f deploy/k8s/namespace.yaml
+kubectl apply -f deploy/k8s/configmap.yaml
+kubectl apply -f deploy/k8s/pvc.yaml
+kubectl apply -f deploy/k8s/deployment.yaml
+kubectl apply -f deploy/k8s/service.yaml
+kubectl apply -f deploy/k8s/ingress.yaml
+```
+
+Watch it come up:
+
+```sh
+kubectl -n gitfed get pods -w
+```
+
+All three containers must reach `Running`/`Ready` — `web` and `site` wait
+in a loop for `server`'s admin socket before starting, so a brief `0/3` is
+normal on first boot.
+
+## 5. Verify
+
+```sh
+curl https://git.example.com/.well-known/gitfed.json
+curl https://git.example.com/ # gitfed-site homepage
+```
+
+Create your first user/repo (there's no user yet, so this has to happen
+from inside the cluster):
+
+```sh
+kubectl -n gitfed port-forward deployment/gitfed 8088:8088
+# then, in another terminal:
+curl -X POST http://127.0.0.1:8088/users \
+ --data-urlencode "username=alice" \
+ --data-urlencode "pubkey=$(cat ~/.ssh/id_ed25519.pub)"
+```
+
+Or drive the same admin socket interactively with the TUI instead:
+
+```sh
+kubectl -n gitfed exec -it deployment/gitfed -c server -- \
+ gitfed-tui -config /etc/gitfed/gitfed.json
+```
+
+Then clone for real:
+
+```sh
+git clone ssh://git@git.example.com:2222/alice/some-repo
+```
+
+## Backups
+
+Everything that matters lives on the `gitfed-data` PVC:
+`gitfed.db` (users/repos/ACLs/trust store/audit log), `ca/` (the instance's
+signing key — **losing this invalidates every certificate and breaks every
+federated trust relationship pointing at this domain**, there's no
+recovery short of everyone re-establishing trust), `host_key`, and
+`repos/` (the actual bare git repos). Back up the whole PVC, not just the
+git data.
+
+## Further hardening (not included here, worth doing later)
+
+- A `NetworkPolicy` restricting which namespaces/pods can reach
+ `gitfed-wellknown`/`gitfed-site` at all, if your cluster runs anything
+ else you don't fully trust.
+- Rotating `cert_ttl_hours` down and setting up `gitfed-renew-cert` on a
+ timer for any users who script access, instead of the default 48h/manual
+ `gitfed-cert` request.
deploy/k8s/configmap.yaml
@@ -0,0 +1,26 @@
+# EDIT ME before applying:
+# - domain: the public hostname you'll point DNS at (also becomes the
+# identity used in certificate principals, e.g. "alice@git.example.com")
+# - contact: shown in /.well-known/gitfed.json for other admins
+#
+# insecure_federation is false here on purpose: Traefik + cert-manager give
+# this a real TLS certificate (see ingress.yaml), so there's no need for the
+# plain-HTTP dev escape hatch described in internal/federation/wellknown.go.
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: gitfed-config
+ namespace: gitfed
+data:
+ gitfed.json: |
+ {
+ "domain": "git.CHANGEME.example",
+ "data_dir": "/data",
+ "repos_dir": "/data/repos",
+ "listen_ssh": ":2222",
+ "listen_http": ":8443",
+ "contact": "admin@CHANGEME.example",
+ "trust_policy": "whitelist",
+ "cert_ttl_hours": 48,
+ "insecure_federation": false
+ }
deploy/k8s/deployment.yaml
@@ -0,0 +1,130 @@
+# Three containers, one pod, one shared PVC:
+# - server: owns the bbolt store exclusively and the admin Unix socket
+# (/data/admin.sock). git+ssh only, no ordinary auth beyond keys/certs.
+# - web: admin UI. Left at its default 127.0.0.1 bind (no -listen
+# override, no Service for it below) — reachable ONLY via
+# `kubectl port-forward`, by design (see deploy/k8s/README.md).
+# - site: public read-only repo browser. Bound to 0.0.0.0 so the
+# ClusterIP Service (and Ingress) can reach it.
+#
+# web and site talk to server over the admin socket (internal/opsconnect),
+# never by opening the bbolt file themselves — that file's exclusive lock
+# means only one process may ever hold it, which is why they wait for the
+# socket to appear rather than racing server for the store at pod startup.
+#
+# replicas MUST stay at 1: bbolt, the admin socket, and the SSH hostPort are
+# all single-instance by construction. strategy=Recreate so a rollout tears
+# down the old pod (and its hostPort/PVC claim) before starting the new one,
+# rather than briefly running two.
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: gitfed
+ namespace: gitfed
+ labels:
+ app: gitfed
+spec:
+ replicas: 1
+ strategy:
+ type: Recreate
+ selector:
+ matchLabels:
+ app: gitfed
+ template:
+ metadata:
+ labels:
+ app: gitfed
+ spec:
+ securityContext:
+ fsGroup: 1000
+ containers:
+ - name: server
+ image: gitfed:latest
+ imagePullPolicy: Never
+ command: ["/usr/local/bin/gitfed-server", "-config", "/etc/gitfed/gitfed.json"]
+ ports:
+ - name: ssh
+ containerPort: 2222
+ hostPort: 2222
+ - name: wellknown
+ containerPort: 8443
+ volumeMounts:
+ - name: data
+ mountPath: /data
+ - name: config
+ mountPath: /etc/gitfed
+ readOnly: true
+ securityContext:
+ runAsNonRoot: true
+ runAsUser: 1000
+ allowPrivilegeEscalation: false
+ resources:
+ requests: {cpu: 20m, memory: 32Mi}
+ limits: {cpu: 300m, memory: 256Mi}
+ readinessProbe:
+ tcpSocket: {port: ssh}
+ initialDelaySeconds: 2
+ livenessProbe:
+ tcpSocket: {port: ssh}
+ initialDelaySeconds: 5
+ periodSeconds: 20
+
+ - name: web
+ image: gitfed:latest
+ imagePullPolicy: Never
+ command:
+ - sh
+ - -c
+ - |
+ until [ -S /data/admin.sock ]; do sleep 1; done
+ exec /usr/local/bin/gitfed-web -config /etc/gitfed/gitfed.json
+ volumeMounts:
+ - name: data
+ mountPath: /data
+ - name: config
+ mountPath: /etc/gitfed
+ readOnly: true
+ securityContext:
+ runAsNonRoot: true
+ runAsUser: 1000
+ allowPrivilegeEscalation: false
+ resources:
+ requests: {cpu: 10m, memory: 32Mi}
+ limits: {cpu: 200m, memory: 128Mi}
+
+ - name: site
+ image: gitfed:latest
+ imagePullPolicy: Never
+ command:
+ - sh
+ - -c
+ - |
+ until [ -S /data/admin.sock ]; do sleep 1; done
+ exec /usr/local/bin/gitfed-site -config /etc/gitfed/gitfed.json -listen 0.0.0.0:8090
+ ports:
+ - name: site
+ containerPort: 8090
+ volumeMounts:
+ - name: data
+ mountPath: /data
+ - name: config
+ mountPath: /etc/gitfed
+ readOnly: true
+ securityContext:
+ runAsNonRoot: true
+ runAsUser: 1000
+ allowPrivilegeEscalation: false
+ resources:
+ requests: {cpu: 10m, memory: 32Mi}
+ limits: {cpu: 200m, memory: 128Mi}
+ readinessProbe:
+ httpGet: {path: /, port: site}
+ initialDelaySeconds: 3
+
+ volumes:
+ - name: data
+ persistentVolumeClaim:
+ claimName: gitfed-data
+ - name: config
+ configMap:
+ name: gitfed-config
deploy/k8s/ingress.yaml
@@ -0,0 +1,33 @@
+# EDIT ME before applying:
+# - host: must match "domain" in configmap.yaml exactly
+# - cert-manager.io/cluster-issuer: match the ClusterIssuer name you
+# already created for ess-helm (their install guide has you create one
+# for Let's Encrypt) — reuse it, no need for a second issuer.
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: gitfed
+ namespace: gitfed
+ annotations:
+ cert-manager.io/cluster-issuer: letsencrypt-prod
+spec:
+ ingressClassName: traefik
+ tls:
+ - hosts: ["git.CHANGEME.example"]
+ secretName: gitfed-tls
+ rules:
+ - host: git.CHANGEME.example
+ http:
+ paths:
+ - path: /.well-known/gitfed.json
+ pathType: Exact
+ backend:
+ service:
+ name: gitfed-wellknown
+ port: {number: 8443}
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: gitfed-site
+ port: {number: 8090}
deploy/k8s/namespace.yaml
@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: gitfed
deploy/k8s/pvc.yaml
@@ -0,0 +1,13 @@
+# No storageClassName set: uses the cluster default, which on k3s is the
+# bundled "local-path" provisioner — the same default ess-helm relies on.
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: gitfed-data
+ namespace: gitfed
+spec:
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 5Gi
deploy/k8s/service.yaml
@@ -0,0 +1,27 @@
+# No Service for the "web" container on purpose — it binds 127.0.0.1 inside
+# its container by default, unreachable from any Service/Pod IP even inside
+# the cluster. Use `kubectl port-forward deployment/gitfed 8088:8088 -n
+# gitfed` to reach it, per deploy/k8s/README.md.
+apiVersion: v1
+kind: Service
+metadata:
+ name: gitfed-wellknown
+ namespace: gitfed
+spec:
+ selector:
+ app: gitfed
+ ports:
+ - port: 8443
+ targetPort: wellknown
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: gitfed-site
+ namespace: gitfed
+spec:
+ selector:
+ app: gitfed
+ ports:
+ - port: 8090
+ targetPort: site
go.mod
@@ -0,0 +1,37 @@
+module gitfed
+
+go 1.25.4
+
+require (
+ github.com/charmbracelet/bubbles v1.0.0
+ github.com/charmbracelet/bubbletea v1.3.10
+ github.com/charmbracelet/lipgloss v1.1.0
+ go.etcd.io/bbolt v1.5.0
+ golang.org/x/crypto v0.54.0
+)
+
+require (
+ github.com/atotto/clipboard v0.1.4 // indirect
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/charmbracelet/colorprofile v0.4.1 // indirect
+ github.com/charmbracelet/x/ansi v0.11.6 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
+ github.com/charmbracelet/x/term v0.2.2 // indirect
+ github.com/clipperhouse/displaywidth v0.9.0 // indirect
+ github.com/clipperhouse/stringish v0.1.1 // indirect
+ github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-localereader v0.0.1 // indirect
+ github.com/mattn/go-runewidth v0.0.19 // indirect
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/termenv v0.16.0 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/sahilm/fuzzy v0.1.1 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ github.com/yuin/goldmark v1.8.4 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.40.0 // indirect
+)
go.sum
@@ -0,0 +1,78 @@
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
+github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
+github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
+github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
+github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
+github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
+github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
+github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
+github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
+github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
+github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
+github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
+github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
+github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
+github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
+github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
+github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
+github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
+github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
+go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
+golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
+golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
internal/acl/acl.go
@@ -0,0 +1,67 @@
+// Package acl implements the repo access-control model described in
+// DESIGN.md §6: an owner plus a list of collaborators (local or federated
+// principals) each granted read, write or admin.
+package acl
+
+import (
+ "fmt"
+
+ "gitfed/internal/store"
+)
+
+var roleRank = map[store.Role]int{
+ store.RoleRead: 1,
+ store.RoleWrite: 2,
+ store.RoleAdmin: 3,
+}
+
+// Satisfies reports whether having role `have` satisfies a requirement of
+// role `want`.
+func Satisfies(have, want store.Role) bool {
+ return roleRank[have] >= roleRank[want]
+}
+
+// Check determines whether principal has at least `want` access on repo.
+// The repo owner always has admin. Returns the effective role and whether
+// access is granted.
+func Check(s *store.Store, repoName, principal string, want store.Role) (store.Role, bool, error) {
+ repo, err := s.GetRepo(repoName)
+ if err != nil {
+ return "", false, fmt.Errorf("acl: repo %q: %w", repoName, err)
+ }
+ if repo.Owner == principal {
+ return store.RoleAdmin, true, nil
+ }
+
+ a, err := s.GetACL(repoName)
+ if err != nil && err != store.ErrNotFound {
+ return "", false, err
+ }
+ for _, c := range a.Collaborators {
+ if c.Principal == principal {
+ return c.Role, Satisfies(c.Role, want), nil
+ }
+ }
+
+ // A public repo is readable by any authenticated principal without an
+ // explicit collaborator entry — this never extends to write/admin.
+ if repo.Public && want == store.RoleRead {
+ return store.RoleRead, true, nil
+ }
+ return "", false, nil
+}
+
+// Grant adds or updates a collaborator's role on a repo. It does not itself
+// resolve federation trust for remote principals — callers should invoke
+// federation.Resolver.EnsureTrust first per DESIGN.md §6.
+func Grant(s *store.Store, repoName string, principal string, role store.Role) error {
+ if !role.Valid() {
+ return fmt.Errorf("acl: invalid role %q", role)
+ }
+ return s.UpsertCollaborator(repoName, store.Collaborator{Principal: principal, Role: role})
+}
+
+// Revoke removes a collaborator from a repo's ACL.
+func Revoke(s *store.Store, repoName, principal string) error {
+ return s.RemoveCollaborator(repoName, principal)
+}
internal/acl/acl_test.go
@@ -0,0 +1,74 @@
+package acl
+
+import (
+ "path/filepath"
+ "testing"
+
+ "gitfed/internal/store"
+)
+
+func newTestStore(t *testing.T) *store.Store {
+ t.Helper()
+ s, err := store.Open(filepath.Join(t.TempDir(), "gitfed.db"))
+ if err != nil {
+ t.Fatalf("open store: %v", err)
+ }
+ t.Cleanup(func() { s.Close() })
+ return s
+}
+
+func TestCheckPublicRepoGrantsReadOnly(t *testing.T) {
+ s := newTestStore(t)
+ if err := s.CreateRepo(store.Repo{Name: "alice/demo", Owner: "alice@local", Path: "/tmp/x", Public: true}); err != nil {
+ t.Fatalf("create repo: %v", err)
+ }
+
+ role, ok, err := Check(s, "alice/demo", "stranger@elsewhere", store.RoleRead)
+ if err != nil {
+ t.Fatalf("check read: %v", err)
+ }
+ if !ok || role != store.RoleRead {
+ t.Fatalf("public repo read: got role=%v ok=%v, want RoleRead/true", role, ok)
+ }
+
+ _, ok, err = Check(s, "alice/demo", "stranger@elsewhere", store.RoleWrite)
+ if err != nil {
+ t.Fatalf("check write: %v", err)
+ }
+ if ok {
+ t.Fatal("public repo must not grant write to a non-collaborator")
+ }
+}
+
+func TestCheckPrivateRepoDeniesNonCollaborator(t *testing.T) {
+ s := newTestStore(t)
+ if err := s.CreateRepo(store.Repo{Name: "alice/demo", Owner: "alice@local", Path: "/tmp/x", Public: false}); err != nil {
+ t.Fatalf("create repo: %v", err)
+ }
+
+ _, ok, err := Check(s, "alice/demo", "stranger@elsewhere", store.RoleRead)
+ if err != nil {
+ t.Fatalf("check read: %v", err)
+ }
+ if ok {
+ t.Fatal("private repo must deny read to a non-collaborator stranger")
+ }
+}
+
+func TestCheckExplicitCollaboratorOverridesPublicDefault(t *testing.T) {
+ s := newTestStore(t)
+ if err := s.CreateRepo(store.Repo{Name: "alice/demo", Owner: "alice@local", Path: "/tmp/x", Public: true}); err != nil {
+ t.Fatalf("create repo: %v", err)
+ }
+ if err := Grant(s, "alice/demo", "bob@elsewhere", store.RoleWrite); err != nil {
+ t.Fatalf("grant: %v", err)
+ }
+
+ role, ok, err := Check(s, "alice/demo", "bob@elsewhere", store.RoleWrite)
+ if err != nil {
+ t.Fatalf("check write: %v", err)
+ }
+ if !ok || role != store.RoleWrite {
+ t.Fatalf("explicit collaborator: got role=%v ok=%v, want RoleWrite/true", role, ok)
+ }
+}
internal/admin/admin.go
@@ -0,0 +1,256 @@
+// Package admin implements the management operations exposed by the TUI
+// (and usable headlessly): users, repos, ACLs and the trust store, per
+// DESIGN.md §2 ("Gestion des repos/utilisateurs/ACL via TUI").
+package admin
+
+import (
+ "fmt"
+ "strings"
+
+ gossh "golang.org/x/crypto/ssh"
+
+ "gitfed/internal/acl"
+ "gitfed/internal/federation"
+ "gitfed/internal/gitexec"
+ "gitfed/internal/store"
+)
+
+// Ops is the set of management operations the TUI drives, satisfied both by
+// *Admin (direct, in-process access to the store) and by an RPC client
+// talking to a running gitfed-server over its admin socket (see
+// internal/adminrpc). This lets gitfed-tui manage a live instance without
+// fighting the store's single-writer file lock.
+type Ops interface {
+ ListUsers() ([]store.User, error)
+ CreateUser(username, pubKeyAuthorized string) error
+ AddUserKey(username, pubKeyAuthorized string) error
+ DeleteUser(username string) error
+
+ ListRepos() ([]store.Repo, error)
+ GetRepo(name string) (store.Repo, error)
+ CreateRepo(name, ownerUsername string) error
+ DeleteRepo(name string) error
+ SetRepoPublic(name string, public bool) error
+ SetRepoTopics(name string, topics []string) error
+ GetRepoReadme(name string) (content string, found bool, err error)
+ GetRepoLicense(name string) (content, filename string, found bool, err error)
+ ListRepoTags(name string) ([]string, error)
+
+ GetACL(repoName string) (store.ACL, error)
+ GrantCollaborator(repoName, principal string, role store.Role) error
+ RevokeCollaborator(repoName, principal string) error
+
+ ListTrustedCAs() ([]store.TrustedCA, error)
+ ApproveDomain(domain string) error
+
+ ListAudit(limit int) ([]store.AuditEvent, error)
+}
+
+type Admin struct {
+ Store *store.Store
+ Resolver *federation.Resolver
+ Domain string
+ ReposDir string
+}
+
+var _ Ops = (*Admin)(nil)
+
+func New(st *store.Store, resolver *federation.Resolver, domain, reposDir string) *Admin {
+ return &Admin{Store: st, Resolver: resolver, Domain: domain, ReposDir: reposDir}
+}
+
+// CreateUser registers a new local user with an initial SSH public key
+// (authorized_keys format).
+func (a *Admin) CreateUser(username, pubKeyAuthorized string) error {
+ key, err := canonicalAuthorizedKey(pubKeyAuthorized)
+ if err != nil {
+ return err
+ }
+ return a.Store.CreateUser(store.User{Username: username, PubKeys: []string{key}})
+}
+
+func (a *Admin) AddUserKey(username, pubKeyAuthorized string) error {
+ key, err := canonicalAuthorizedKey(pubKeyAuthorized)
+ if err != nil {
+ return err
+ }
+ return a.Store.AddUserKey(username, key)
+}
+
+// canonicalAuthorizedKey re-marshals a pasted authorized_keys line (which,
+// coming straight from a .pub file, normally carries a "user@host" comment)
+// into the bare "algo base64" form with no comment. That's the exact form
+// ssh.MarshalAuthorizedKey produces from the key offered on the wire during
+// auth (comments aren't part of the SSH protocol's pubkey exchange) — so
+// storing anything else means FindUserByKey's exact-string match against
+// the login attempt would silently never succeed.
+func canonicalAuthorizedKey(pubKeyAuthorized string) (string, error) {
+ pub, _, _, _, err := gossh.ParseAuthorizedKey([]byte(strings.TrimSpace(pubKeyAuthorized)))
+ if err != nil {
+ return "", fmt.Errorf("admin: invalid public key: %w", err)
+ }
+ return strings.TrimSpace(string(gossh.MarshalAuthorizedKey(pub))), nil
+}
+
+// DeleteUser removes a local user, and strips them as a collaborator from
+// every repo's ACL — otherwise a stale entry naming a nonexistent principal
+// lingers forever. Repos they own are left alone; those must be reassigned
+// or deleted first, since a repo without a valid owner would silently lose
+// its "owner always has admin" guarantee (§6).
+func (a *Admin) DeleteUser(username string) error {
+ principal := fmt.Sprintf("%s@%s", username, a.Domain)
+
+ repos, err := a.Store.ListRepos()
+ if err != nil {
+ return err
+ }
+ for _, r := range repos {
+ if r.Owner == principal {
+ return fmt.Errorf("admin: %s owns repo %q; delete or reassign it before deleting the user", principal, r.Name)
+ }
+ }
+
+ if err := a.Store.DeleteUser(username); err != nil {
+ return err
+ }
+
+ for _, r := range repos {
+ if err := a.Store.RemoveCollaborator(r.Name, principal); err != nil && err != store.ErrNotFound {
+ return err
+ }
+ }
+ return nil
+}
+
+func (a *Admin) ListUsers() ([]store.User, error) {
+ return a.Store.ListUsers()
+}
+
+func (a *Admin) ListRepos() ([]store.Repo, error) {
+ return a.Store.ListRepos()
+}
+
+func (a *Admin) GetRepo(name string) (store.Repo, error) {
+ return a.Store.GetRepo(name)
+}
+
+func (a *Admin) GetACL(repoName string) (store.ACL, error) {
+ return a.Store.GetACL(repoName)
+}
+
+func (a *Admin) ListTrustedCAs() ([]store.TrustedCA, error) {
+ return a.Store.ListTrustedCAs()
+}
+
+func (a *Admin) ListAudit(limit int) ([]store.AuditEvent, error) {
+ return a.Store.ListAudit(limit)
+}
+
+// CreateRepo creates a bare repo on disk and registers it, owned by
+// "<owner>@<localDomain>".
+func (a *Admin) CreateRepo(name, ownerUsername string) error {
+ path, err := gitexec.ResolvePath(a.ReposDir, name)
+ if err != nil {
+ return err
+ }
+ if err := gitexec.InitBareRepo(path); err != nil {
+ return err
+ }
+ owner := fmt.Sprintf("%s@%s", ownerUsername, a.Domain)
+ return a.Store.CreateRepo(store.Repo{Name: name, Owner: owner, Path: path})
+}
+
+func (a *Admin) DeleteRepo(name string) error {
+ if err := a.Store.DeleteRepo(name); err != nil {
+ return err
+ }
+ return a.Store.DeleteACL(name)
+}
+
+func (a *Admin) SetRepoPublic(name string, public bool) error {
+ return a.Store.SetRepoPublic(name, public)
+}
+
+func (a *Admin) SetRepoTopics(name string, topics []string) error {
+ return a.Store.SetRepoTopics(name, topics)
+}
+
+// readmeCandidates and licenseCandidates are tried in order against the
+// tree at HEAD; the first match wins.
+var readmeCandidates = []string{"README.md", "Readme.md", "README.markdown", "README", "README.txt"}
+var licenseCandidates = []string{"LICENSE", "LICENSE.md", "LICENSE.txt", "LICENSE.rst", "COPYING"}
+
+func (a *Admin) GetRepoReadme(name string) (string, bool, error) {
+ repo, err := a.Store.GetRepo(name)
+ if err != nil {
+ return "", false, err
+ }
+ for _, candidate := range readmeCandidates {
+ content, found, err := gitexec.ReadFileAtHEAD(repo.Path, candidate)
+ if err != nil {
+ return "", false, err
+ }
+ if found {
+ return content, true, nil
+ }
+ }
+ return "", false, nil
+}
+
+func (a *Admin) GetRepoLicense(name string) (string, string, bool, error) {
+ repo, err := a.Store.GetRepo(name)
+ if err != nil {
+ return "", "", false, err
+ }
+ for _, candidate := range licenseCandidates {
+ content, found, err := gitexec.ReadFileAtHEAD(repo.Path, candidate)
+ if err != nil {
+ return "", "", false, err
+ }
+ if found {
+ return content, candidate, true, nil
+ }
+ }
+ return "", "", false, nil
+}
+
+func (a *Admin) ListRepoTags(name string) ([]string, error) {
+ repo, err := a.Store.GetRepo(name)
+ if err != nil {
+ return nil, err
+ }
+ return gitexec.ListTags(repo.Path)
+}
+
+// GrantCollaborator adds/updates a collaborator's role on a repo. If the
+// principal belongs to a remote domain, it first resolves trust for that
+// domain (§5.2/§6); for the whitelist policy this leaves the domain pending
+// until an admin approves it, but the collaborator entry is still recorded.
+func (a *Admin) GrantCollaborator(repoName, principal string, role store.Role) error {
+ _, domain, ok := splitPrincipal(principal)
+ if !ok {
+ return fmt.Errorf("admin: invalid principal %q, expected user@domain", principal)
+ }
+ if domain != a.Domain {
+ if _, err := a.Resolver.EnsureTrust(domain); err != nil {
+ return fmt.Errorf("admin: resolve trust for %q: %w", domain, err)
+ }
+ }
+ return acl.Grant(a.Store, repoName, principal, role)
+}
+
+func (a *Admin) RevokeCollaborator(repoName, principal string) error {
+ return acl.Revoke(a.Store, repoName, principal)
+}
+
+func (a *Admin) ApproveDomain(domain string) error {
+ return a.Resolver.Approve(domain)
+}
+
+func splitPrincipal(principal string) (username, domain string, ok bool) {
+ i := strings.LastIndex(principal, "@")
+ if i <= 0 || i == len(principal)-1 {
+ return "", "", false
+ }
+ return principal[:i], principal[i+1:], true
+}
internal/admin/admin_test.go
@@ -0,0 +1,57 @@
+package admin
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ gossh "golang.org/x/crypto/ssh"
+
+ "gitfed/internal/store"
+)
+
+// TestCreateUserStripsKeyComment guards against a real bug: a key pasted
+// straight from a .pub file carries a "user@host" comment, but the SSH
+// protocol never transmits comments during pubkey auth, so the server
+// re-derives a bare "algo base64" string from the wire and compares it
+// exactly against what's stored. If the comment isn't stripped at
+// creation time, the user can never actually log in with that key.
+func TestCreateUserStripsKeyComment(t *testing.T) {
+ s, err := store.Open(filepath.Join(t.TempDir(), "gitfed.db"))
+ if err != nil {
+ t.Fatalf("open store: %v", err)
+ }
+ defer s.Close()
+ a := New(s, nil, "local.test", t.TempDir())
+
+ pub, _, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatalf("generate key: %v", err)
+ }
+ sshPub, err := gossh.NewPublicKey(pub)
+ if err != nil {
+ t.Fatalf("ssh public key: %v", err)
+ }
+ bare := strings.TrimSpace(string(gossh.MarshalAuthorizedKey(sshPub)))
+ withComment := bare + " alice@laptop"
+
+ if err := a.CreateUser("alice", withComment); err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+
+ u, err := s.GetUser("alice")
+ if err != nil {
+ t.Fatalf("GetUser: %v", err)
+ }
+ if len(u.PubKeys) != 1 || u.PubKeys[0] != bare {
+ t.Fatalf("stored key = %q, want comment stripped to %q", u.PubKeys, bare)
+ }
+
+ // This is exactly what internal/ssh.checkRawKey does with the key
+ // offered on the wire: no comment involved at all.
+ if _, err := s.FindUserByKey(bare); err != nil {
+ t.Fatalf("FindUserByKey(bare) after CreateUser: %v (login would fail)", err)
+ }
+}
internal/adminrpc/client.go
@@ -0,0 +1,160 @@
+package adminrpc
+
+import (
+ "encoding/json"
+ "errors"
+ "net"
+ "time"
+
+ "gitfed/internal/admin"
+ "gitfed/internal/store"
+)
+
+// Client implements admin.Ops by calling a running gitfed-server's admin
+// socket. One connection is opened per call — simple, and admin operations
+// are rare enough that the overhead doesn't matter.
+type Client struct {
+ socketPath string
+ timeout time.Duration
+}
+
+var _ admin.Ops = (*Client)(nil)
+
+func NewClient(socketPath string) *Client {
+ return &Client{socketPath: socketPath, timeout: 5 * time.Second}
+}
+
+// Ping checks whether a gitfed-server is listening on the socket.
+func (c *Client) Ping() error {
+ return c.call(methodListUsers, nil, nil)
+}
+
+func (c *Client) call(method string, args any, out any) error {
+ conn, err := net.DialTimeout("unix", c.socketPath, c.timeout)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+ _ = conn.SetDeadline(time.Now().Add(c.timeout))
+
+ if err := json.NewEncoder(conn).Encode(request{Method: method, Args: args}); err != nil {
+ return err
+ }
+
+ var resp struct {
+ Result json.RawMessage `json:"result,omitempty"`
+ Error string `json:"error,omitempty"`
+ }
+ if err := json.NewDecoder(conn).Decode(&resp); err != nil {
+ return err
+ }
+ if resp.Error != "" {
+ return errors.New(resp.Error)
+ }
+ if out != nil && len(resp.Result) > 0 {
+ return json.Unmarshal(resp.Result, out)
+ }
+ return nil
+}
+
+func (c *Client) ListUsers() ([]store.User, error) {
+ var out listUsersResult
+ err := c.call(methodListUsers, nil, &out)
+ return out.Users, err
+}
+
+func (c *Client) CreateUser(username, pubKeyAuthorized string) error {
+ err := c.call(methodCreateUser, userKeyArgs{Username: username, PubKey: pubKeyAuthorized}, nil)
+ return err
+}
+
+func (c *Client) AddUserKey(username, pubKeyAuthorized string) error {
+ err := c.call(methodAddUserKey, userKeyArgs{Username: username, PubKey: pubKeyAuthorized}, nil)
+ return err
+}
+
+func (c *Client) DeleteUser(username string) error {
+ err := c.call(methodDeleteUser, nameArgs{Name: username}, nil)
+ return err
+}
+
+func (c *Client) ListRepos() ([]store.Repo, error) {
+ var out listReposResult
+ err := c.call(methodListRepos, nil, &out)
+ return out.Repos, err
+}
+
+func (c *Client) GetRepo(name string) (store.Repo, error) {
+ var out store.Repo
+ err := c.call(methodGetRepo, nameArgs{Name: name}, &out)
+ return out, err
+}
+
+func (c *Client) CreateRepo(name, ownerUsername string) error {
+ err := c.call(methodCreateRepo, createRepoArgs{Name: name, Owner: ownerUsername}, nil)
+ return err
+}
+
+func (c *Client) DeleteRepo(name string) error {
+ err := c.call(methodDeleteRepo, nameArgs{Name: name}, nil)
+ return err
+}
+
+func (c *Client) GetACL(repoName string) (store.ACL, error) {
+ var out store.ACL
+ err := c.call(methodGetACL, nameArgs{Name: repoName}, &out)
+ return out, err
+}
+
+func (c *Client) GrantCollaborator(repoName, principal string, role store.Role) error {
+ err := c.call(methodGrantCollaborator, collaboratorArgs{Repo: repoName, Principal: principal, Role: role}, nil)
+ return err
+}
+
+func (c *Client) RevokeCollaborator(repoName, principal string) error {
+ err := c.call(methodRevokeCollaborator, collaboratorArgs{Repo: repoName, Principal: principal}, nil)
+ return err
+}
+
+func (c *Client) ListTrustedCAs() ([]store.TrustedCA, error) {
+ var out listTrustResult
+ err := c.call(methodListTrustedCAs, nil, &out)
+ return out.Trust, err
+}
+
+func (c *Client) ApproveDomain(domain string) error {
+ err := c.call(methodApproveDomain, domainArgs{Domain: domain}, nil)
+ return err
+}
+
+func (c *Client) ListAudit(limit int) ([]store.AuditEvent, error) {
+ var out listAuditResult
+ err := c.call(methodListAudit, limitArgs{Limit: limit}, &out)
+ return out.Events, err
+}
+
+func (c *Client) SetRepoPublic(name string, public bool) error {
+ return c.call(methodSetRepoPublic, setPublicArgs{Name: name, Public: public}, nil)
+}
+
+func (c *Client) SetRepoTopics(name string, topics []string) error {
+ return c.call(methodSetRepoTopics, setTopicsArgs{Name: name, Topics: topics}, nil)
+}
+
+func (c *Client) GetRepoReadme(name string) (string, bool, error) {
+ var out readmeResult
+ err := c.call(methodGetRepoReadme, nameArgs{Name: name}, &out)
+ return out.Content, out.Found, err
+}
+
+func (c *Client) GetRepoLicense(name string) (string, string, bool, error) {
+ var out licenseResult
+ err := c.call(methodGetRepoLicense, nameArgs{Name: name}, &out)
+ return out.Content, out.Filename, out.Found, err
+}
+
+func (c *Client) ListRepoTags(name string) ([]string, error) {
+ var out listTagsResult
+ err := c.call(methodListRepoTags, nameArgs{Name: name}, &out)
+ return out.Tags, err
+}
internal/adminrpc/protocol.go
@@ -0,0 +1,114 @@
+// Package adminrpc lets gitfed-tui drive a *running* gitfed-server over a
+// local Unix socket, implementing admin.Ops on the client side. This exists
+// because the store (bbolt) takes an exclusive file lock: only one OS
+// process can hold it open, so a separate CLI/TUI process can't just open
+// the same database file while the server is up. Routing admin operations
+// through the server process that already owns the store sidesteps that
+// entirely.
+package adminrpc
+
+import "gitfed/internal/store"
+
+// method names
+const (
+ methodListUsers = "ListUsers"
+ methodCreateUser = "CreateUser"
+ methodAddUserKey = "AddUserKey"
+ methodDeleteUser = "DeleteUser"
+ methodListRepos = "ListRepos"
+ methodGetRepo = "GetRepo"
+ methodCreateRepo = "CreateRepo"
+ methodDeleteRepo = "DeleteRepo"
+ methodGetACL = "GetACL"
+ methodGrantCollaborator = "GrantCollaborator"
+ methodRevokeCollaborator = "RevokeCollaborator"
+ methodListTrustedCAs = "ListTrustedCAs"
+ methodApproveDomain = "ApproveDomain"
+ methodListAudit = "ListAudit"
+ methodSetRepoPublic = "SetRepoPublic"
+ methodSetRepoTopics = "SetRepoTopics"
+ methodGetRepoReadme = "GetRepoReadme"
+ methodGetRepoLicense = "GetRepoLicense"
+ methodListRepoTags = "ListRepoTags"
+)
+
+// request is the envelope sent by the client for every call.
+type request struct {
+ Method string `json:"method"`
+ Args any `json:"args,omitempty"`
+}
+
+// response is the envelope returned by the server for every call.
+type response struct {
+ Result any `json:"result,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type userKeyArgs struct {
+ Username string `json:"username"`
+ PubKey string `json:"pub_key"`
+}
+
+type nameArgs struct {
+ Name string `json:"name"`
+}
+
+type createRepoArgs struct {
+ Name string `json:"name"`
+ Owner string `json:"owner"`
+}
+
+type collaboratorArgs struct {
+ Repo string `json:"repo"`
+ Principal string `json:"principal"`
+ Role store.Role `json:"role,omitempty"`
+}
+
+type domainArgs struct {
+ Domain string `json:"domain"`
+}
+
+type listUsersResult struct {
+ Users []store.User `json:"users"`
+}
+
+type listReposResult struct {
+ Repos []store.Repo `json:"repos"`
+}
+
+type listTrustResult struct {
+ Trust []store.TrustedCA `json:"trust"`
+}
+
+type limitArgs struct {
+ Limit int `json:"limit"`
+}
+
+type listAuditResult struct {
+ Events []store.AuditEvent `json:"events"`
+}
+
+type setPublicArgs struct {
+ Name string `json:"name"`
+ Public bool `json:"public"`
+}
+
+type setTopicsArgs struct {
+ Name string `json:"name"`
+ Topics []string `json:"topics"`
+}
+
+type readmeResult struct {
+ Content string `json:"content"`
+ Found bool `json:"found"`
+}
+
+type licenseResult struct {
+ Content string `json:"content"`
+ Filename string `json:"filename"`
+ Found bool `json:"found"`
+}
+
+type listTagsResult struct {
+ Tags []string `json:"tags"`
+}
internal/adminrpc/server.go
@@ -0,0 +1,200 @@
+package adminrpc
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "net"
+ "os"
+
+ "gitfed/internal/admin"
+)
+
+type Server struct {
+ ops *admin.Admin
+ socketPath string
+}
+
+func NewServer(ops *admin.Admin, socketPath string) *Server {
+ return &Server{ops: ops, socketPath: socketPath}
+}
+
+// ListenAndServe serves admin RPC requests on the Unix socket until it
+// errors. Only the owning user can connect (socket mode 0600) since this
+// grants full admin control of the instance.
+func (s *Server) ListenAndServe() error {
+ _ = os.Remove(s.socketPath) // safe: bbolt's exclusive lock means we're the only server for this data dir
+
+ l, err := net.Listen("unix", s.socketPath)
+ if err != nil {
+ return fmt.Errorf("adminrpc: listen %s: %w", s.socketPath, err)
+ }
+ if err := os.Chmod(s.socketPath, 0600); err != nil {
+ return fmt.Errorf("adminrpc: chmod %s: %w", s.socketPath, err)
+ }
+ log.Printf("gitfed admin socket listening on %s", s.socketPath)
+
+ for {
+ conn, err := l.Accept()
+ if err != nil {
+ return err
+ }
+ go s.handleConn(conn)
+ }
+}
+
+func (s *Server) handleConn(conn net.Conn) {
+ defer conn.Close()
+
+ var req wireRequest
+ if err := json.NewDecoder(conn).Decode(&req); err != nil {
+ return
+ }
+
+ result, err := s.dispatch(req)
+ resp := response{Result: result}
+ if err != nil {
+ resp.Error = err.Error()
+ }
+ _ = json.NewEncoder(conn).Encode(resp)
+}
+
+type wireRequest struct {
+ Method string `json:"method"`
+ Args json.RawMessage `json:"args,omitempty"`
+}
+
+func (s *Server) dispatch(req wireRequest) (any, error) {
+ switch req.Method {
+ case methodListUsers:
+ users, err := s.ops.ListUsers()
+ return listUsersResult{Users: users}, err
+
+ case methodCreateUser:
+ var a userKeyArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ return nil, s.ops.CreateUser(a.Username, a.PubKey)
+
+ case methodAddUserKey:
+ var a userKeyArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ return nil, s.ops.AddUserKey(a.Username, a.PubKey)
+
+ case methodDeleteUser:
+ var a nameArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ return nil, s.ops.DeleteUser(a.Name)
+
+ case methodListRepos:
+ repos, err := s.ops.ListRepos()
+ return listReposResult{Repos: repos}, err
+
+ case methodGetRepo:
+ var a nameArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ return s.ops.GetRepo(a.Name)
+
+ case methodCreateRepo:
+ var a createRepoArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ return nil, s.ops.CreateRepo(a.Name, a.Owner)
+
+ case methodDeleteRepo:
+ var a nameArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ return nil, s.ops.DeleteRepo(a.Name)
+
+ case methodGetACL:
+ var a nameArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ return s.ops.GetACL(a.Name)
+
+ case methodGrantCollaborator:
+ var a collaboratorArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ return nil, s.ops.GrantCollaborator(a.Repo, a.Principal, a.Role)
+
+ case methodRevokeCollaborator:
+ var a collaboratorArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ return nil, s.ops.RevokeCollaborator(a.Repo, a.Principal)
+
+ case methodListTrustedCAs:
+ trust, err := s.ops.ListTrustedCAs()
+ return listTrustResult{Trust: trust}, err
+
+ case methodApproveDomain:
+ var a domainArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ return nil, s.ops.ApproveDomain(a.Domain)
+
+ case methodListAudit:
+ var a limitArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ events, err := s.ops.ListAudit(a.Limit)
+ return listAuditResult{Events: events}, err
+
+ case methodSetRepoPublic:
+ var a setPublicArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ return nil, s.ops.SetRepoPublic(a.Name, a.Public)
+
+ case methodSetRepoTopics:
+ var a setTopicsArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ return nil, s.ops.SetRepoTopics(a.Name, a.Topics)
+
+ case methodGetRepoReadme:
+ var a nameArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ content, found, err := s.ops.GetRepoReadme(a.Name)
+ return readmeResult{Content: content, Found: found}, err
+
+ case methodGetRepoLicense:
+ var a nameArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ content, filename, found, err := s.ops.GetRepoLicense(a.Name)
+ return licenseResult{Content: content, Filename: filename, Found: found}, err
+
+ case methodListRepoTags:
+ var a nameArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ tags, err := s.ops.ListRepoTags(a.Name)
+ return listTagsResult{Tags: tags}, err
+
+ default:
+ return nil, fmt.Errorf("adminrpc: unknown method %q", req.Method)
+ }
+}
internal/ca/ca.go
@@ -0,0 +1,139 @@
+// Package ca generates the instance's local certificate authority keypair
+// and issues short-TTL OpenSSH user certificates, per DESIGN.md §5.1.
+package ca
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/pem"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "golang.org/x/crypto/ssh"
+)
+
+const (
+ privateKeyFile = "ca_key"
+ publicKeyFile = "ca_key.pub"
+)
+
+// CA holds the instance's signing keypair.
+type CA struct {
+ signer ssh.Signer
+ pub ssh.PublicKey
+}
+
+// LoadOrCreate loads the CA keypair from dir, generating a new ed25519
+// keypair on first run.
+func LoadOrCreate(dir string) (*CA, error) {
+ privPath := filepath.Join(dir, privateKeyFile)
+ pubPath := filepath.Join(dir, publicKeyFile)
+
+ if _, err := os.Stat(privPath); err == nil {
+ return load(privPath)
+ } else if !os.IsNotExist(err) {
+ return nil, fmt.Errorf("ca: stat %s: %w", privPath, err)
+ }
+
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ return nil, fmt.Errorf("ca: mkdir %s: %w", dir, err)
+ }
+
+ pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ return nil, fmt.Errorf("ca: generate key: %w", err)
+ }
+
+ block, err := ssh.MarshalPrivateKey(privKey, "gitfed CA")
+ if err != nil {
+ return nil, fmt.Errorf("ca: marshal private key: %w", err)
+ }
+ if err := os.WriteFile(privPath, pem.EncodeToMemory(block), 0600); err != nil {
+ return nil, fmt.Errorf("ca: write %s: %w", privPath, err)
+ }
+
+ sshPub, err := ssh.NewPublicKey(pubKey)
+ if err != nil {
+ return nil, fmt.Errorf("ca: derive public key: %w", err)
+ }
+ if err := os.WriteFile(pubPath, ssh.MarshalAuthorizedKey(sshPub), 0644); err != nil {
+ return nil, fmt.Errorf("ca: write %s: %w", pubPath, err)
+ }
+
+ return load(privPath)
+}
+
+func load(privPath string) (*CA, error) {
+ data, err := os.ReadFile(privPath)
+ if err != nil {
+ return nil, fmt.Errorf("ca: read %s: %w", privPath, err)
+ }
+ signer, err := ssh.ParsePrivateKey(data)
+ if err != nil {
+ return nil, fmt.Errorf("ca: parse private key: %w", err)
+ }
+ return &CA{signer: signer, pub: signer.PublicKey()}, nil
+}
+
+// PublicKeyAuthorized returns the CA's public key in authorized_keys format,
+// e.g. for publishing in /.well-known/gitfed.json.
+func (c *CA) PublicKeyAuthorized() string {
+ return string(ssh.MarshalAuthorizedKey(c.pub))
+}
+
+// PublicKey returns the CA's ssh.PublicKey.
+func (c *CA) PublicKey() ssh.PublicKey {
+ return c.pub
+}
+
+// IssueParams describes a certificate to be issued for a local user.
+type IssueParams struct {
+ Username string
+ Domain string
+ UserKey ssh.PublicKey // the user's own SSH public key being certified
+ TTL time.Duration
+}
+
+// IssueUserCert signs a short-lived OpenSSH user certificate for the given
+// user key, with principal "<username>@<domain>" per DESIGN.md §5.1.
+func (c *CA) IssueUserCert(p IssueParams) (*ssh.Certificate, error) {
+ if p.TTL <= 0 {
+ p.TTL = 48 * time.Hour
+ }
+ now := time.Now()
+ principal := fmt.Sprintf("%s@%s", p.Username, p.Domain)
+
+ keyID, err := randomKeyID()
+ if err != nil {
+ return nil, err
+ }
+
+ cert := &ssh.Certificate{
+ Key: p.UserKey,
+ Serial: 0,
+ CertType: ssh.UserCert,
+ KeyId: keyID,
+ ValidPrincipals: []string{principal},
+ ValidAfter: uint64(now.Add(-1 * time.Minute).Unix()),
+ ValidBefore: uint64(now.Add(p.TTL).Unix()),
+ Permissions: ssh.Permissions{
+ Extensions: map[string]string{
+ "permit-pty": "",
+ },
+ },
+ }
+ if err := cert.SignCert(rand.Reader, c.signer); err != nil {
+ return nil, fmt.Errorf("ca: sign certificate: %w", err)
+ }
+ return cert, nil
+}
+
+func randomKeyID() (string, error) {
+ buf := make([]byte, 16)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("gitfed-%x", buf), nil
+}
internal/config/config.go
@@ -0,0 +1,69 @@
+// Package config holds the on-disk instance configuration shared by
+// cmd/gitfed-server and cmd/gitfed-tui.
+package config
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "gitfed/internal/store"
+)
+
+type Config struct {
+ Domain string `json:"domain"`
+ DataDir string `json:"data_dir"`
+ ReposDir string `json:"repos_dir"`
+ ListenSSH string `json:"listen_ssh"`
+ ListenHTTP string `json:"listen_http"` // empty disables the well-known HTTP server
+ Contact string `json:"contact"`
+ TrustPolicy store.TrustPolicy `json:"trust_policy"`
+ CertTTLHours int `json:"cert_ttl_hours"`
+
+ // InsecureFederation fetches remote .well-known documents over plain
+ // HTTP instead of HTTPS. Dev/testing only (e.g. federating two local
+ // instances with no real domain or TLS cert) — never enable this
+ // against real remote instances.
+ InsecureFederation bool `json:"insecure_federation"`
+}
+
+func Default(domain, dataDir string) Config {
+ return Config{
+ Domain: domain,
+ DataDir: dataDir,
+ ReposDir: filepath.Join(dataDir, "repos"),
+ ListenSSH: ":2222",
+ ListenHTTP: ":8443",
+ TrustPolicy: store.TrustPolicyWhitelist,
+ CertTTLHours: 48,
+ }
+}
+
+func (c Config) DBPath() string { return filepath.Join(c.DataDir, "gitfed.db") }
+func (c Config) CADir() string { return filepath.Join(c.DataDir, "ca") }
+func (c Config) HostKeyPath() string { return filepath.Join(c.DataDir, "host_key") }
+func (c Config) AdminSocketPath() string { return filepath.Join(c.DataDir, "admin.sock") }
+
+func Load(path string) (Config, error) {
+ var c Config
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return c, fmt.Errorf("config: read %s: %w", path, err)
+ }
+ if err := json.Unmarshal(data, &c); err != nil {
+ return c, fmt.Errorf("config: parse %s: %w", path, err)
+ }
+ return c, nil
+}
+
+func Save(path string, c Config) error {
+ data, err := json.MarshalIndent(c, "", " ")
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ return err
+ }
+ return os.WriteFile(path, data, 0644)
+}
internal/federation/resolver.go
@@ -0,0 +1,140 @@
+package federation
+
+import (
+ "fmt"
+ "sync"
+ "time"
+
+ "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}
+}
+
+// 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
This commit's diff is too large to show in full — the list above still shows every changed file.