Gitfed
bastien-mrq/gitfed/ Commits/ d50f2d4

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.

bastien-mrq 2026-07-28 09:42 commit d50f2d47439ba8f4e06e2b1341f719492d1fb6f8
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
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12fa2eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +/bin/ +/data/ +/demo/ +*.db
DESIGN.md
diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..576a938 --- /dev/null +++ b/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
diff --git a/cmd/gitfed-renew-cert/main.go b/cmd/gitfed-renew-cert/main.go new file mode 100644 index 0000000..9101803 --- /dev/null +++ b/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
diff --git a/cmd/gitfed-server/main.go b/cmd/gitfed-server/main.go new file mode 100644 index 0000000..2d41e58 --- /dev/null +++ b/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
diff --git a/cmd/gitfed-site/handlers.go b/cmd/gitfed-site/handlers.go new file mode 100644 index 0000000..8cedd18 --- /dev/null +++ b/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="/">&larr; 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
diff --git a/cmd/gitfed-site/main.go b/cmd/gitfed-site/main.go new file mode 100644 index 0000000..21e7829 --- /dev/null +++ b/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
diff --git a/cmd/gitfed-site/render.go b/cmd/gitfed-site/render.go new file mode 100644 index 0000000..62b4c2b --- /dev/null +++ b/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
diff --git a/cmd/gitfed-tui/actions.go b/cmd/gitfed-tui/actions.go new file mode 100644 index 0000000..7203443 --- /dev/null +++ b/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
diff --git a/cmd/gitfed-tui/form.go b/cmd/gitfed-tui/form.go new file mode 100644 index 0000000..8457f57 --- /dev/null +++ b/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
diff --git a/cmd/gitfed-tui/items.go b/cmd/gitfed-tui/items.go new file mode 100644 index 0000000..0803b56 --- /dev/null +++ b/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
diff --git a/cmd/gitfed-tui/main.go b/cmd/gitfed-tui/main.go new file mode 100644 index 0000000..4e3e6fe --- /dev/null +++ b/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
diff --git a/cmd/gitfed-tui/model.go b/cmd/gitfed-tui/model.go new file mode 100644 index 0000000..ec534f6 --- /dev/null +++ b/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
diff --git a/cmd/gitfed-web/handlers_acl.go b/cmd/gitfed-web/handlers_acl.go new file mode 100644 index 0000000..2fa8add --- /dev/null +++ b/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">&larr; 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 &amp; 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
diff --git a/cmd/gitfed-web/handlers_audit.go b/cmd/gitfed-web/handlers_audit.go new file mode 100644 index 0000000..cc8dbdd --- /dev/null +++ b/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
diff --git a/cmd/gitfed-web/handlers_home.go b/cmd/gitfed-web/handlers_home.go new file mode 100644 index 0000000..e645d14 --- /dev/null +++ b/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
diff --git a/cmd/gitfed-web/handlers_repos.go b/cmd/gitfed-web/handlers_repos.go new file mode 100644 index 0000000..45bd047 --- /dev/null +++ b/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
diff --git a/cmd/gitfed-web/handlers_trust.go b/cmd/gitfed-web/handlers_trust.go new file mode 100644 index 0000000..8ed953c --- /dev/null +++ b/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
diff --git a/cmd/gitfed-web/handlers_users.go b/cmd/gitfed-web/handlers_users.go new file mode 100644 index 0000000..27b1e9b --- /dev/null +++ b/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
diff --git a/cmd/gitfed-web/main.go b/cmd/gitfed-web/main.go new file mode 100644 index 0000000..b930dc6 --- /dev/null +++ b/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
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go new file mode 100644 index 0000000..7550628 --- /dev/null +++ b/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
diff --git a/cmd/gitfed-web/routes.go b/cmd/gitfed-web/routes.go new file mode 100644 index 0000000..363e056 --- /dev/null +++ b/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
diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile new file mode 100644 index 0000000..9f25e47 --- /dev/null +++ b/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
diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md new file mode 100644 index 0000000..28fc3fb --- /dev/null +++ b/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
diff --git a/deploy/k8s/configmap.yaml b/deploy/k8s/configmap.yaml new file mode 100644 index 0000000..4681356 --- /dev/null +++ b/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
diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml new file mode 100644 index 0000000..66e16d5 --- /dev/null +++ b/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
diff --git a/deploy/k8s/ingress.yaml b/deploy/k8s/ingress.yaml new file mode 100644 index 0000000..34ffce5 --- /dev/null +++ b/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
diff --git a/deploy/k8s/namespace.yaml b/deploy/k8s/namespace.yaml new file mode 100644 index 0000000..2f50f15 --- /dev/null +++ b/deploy/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: gitfed
deploy/k8s/pvc.yaml
diff --git a/deploy/k8s/pvc.yaml b/deploy/k8s/pvc.yaml new file mode 100644 index 0000000..15d64bf --- /dev/null +++ b/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
diff --git a/deploy/k8s/service.yaml b/deploy/k8s/service.yaml new file mode 100644 index 0000000..57e3424 --- /dev/null +++ b/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
diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1b2d22c --- /dev/null +++ b/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
diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b98766e --- /dev/null +++ b/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
diff --git a/internal/acl/acl.go b/internal/acl/acl.go new file mode 100644 index 0000000..0c2f2e8 --- /dev/null +++ b/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
diff --git a/internal/acl/acl_test.go b/internal/acl/acl_test.go new file mode 100644 index 0000000..fd6869f --- /dev/null +++ b/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
diff --git a/internal/admin/admin.go b/internal/admin/admin.go new file mode 100644 index 0000000..ecebca8 --- /dev/null +++ b/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
diff --git a/internal/admin/admin_test.go b/internal/admin/admin_test.go new file mode 100644 index 0000000..02a57d6 --- /dev/null +++ b/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
diff --git a/internal/adminrpc/client.go b/internal/adminrpc/client.go new file mode 100644 index 0000000..6b5991f --- /dev/null +++ b/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
diff --git a/internal/adminrpc/protocol.go b/internal/adminrpc/protocol.go new file mode 100644 index 0000000..b1990e2 --- /dev/null +++ b/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
diff --git a/internal/adminrpc/server.go b/internal/adminrpc/server.go new file mode 100644 index 0000000..9538746 --- /dev/null +++ b/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
diff --git a/internal/ca/ca.go b/internal/ca/ca.go new file mode 100644 index 0000000..3f77be2 --- /dev/null +++ b/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
diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8203e7d --- /dev/null +++ b/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
diff --git a/internal/federation/resolver.go b/internal/federation/resolver.go new file mode 100644 index 0000000..43b228e --- /dev/null +++ b/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.