Gitfed
bastien-mrq/gitfed/ Commits/ 0a6f3b8

Add web login and self-service to gitfed-web, merge gitfed-site into it

Real username/password accounts with server-side sessions, bcrypt hashes, and an admin role, so the web UI can safely be exposed on the public internet instead of being reachable only via kubectl port-forward. SSH keys/certificates remain the only way to actually push/pull git — the web login is a separate concern. - store: password hashes (separate bucket, never returned with User), IsAdmin flag, server-side sessions with expiry, RemoveUserKey. - admin.Ops: SetPassword/VerifyPassword/CreateSession/GetSession/ DeleteSession/CheckAccess, wired through adminrpc like everything else. - gitfed-web: merged gitfed-site's public repo browsing (README/LICENSE rendering, tags, topics) in as the unauthenticated part of the same app; self-service dashboard/settings/repo management behind login, scoped via CheckAccess rather than trusting client input; admin section gated by the IsAdmin session flag. - gitfed-tui: Add User form now also takes a password and admin flag, so the first account can be bootstrapped from the live admin socket (kubectl exec) without any other entry point. - deploy/: dropped the separate gitfed-site container/Service; gitfed-web now gets a real Service + Ingress since it has actual auth.

bastien-mrq 2026-07-28 10:11 commit 0a6f3b879e54b240fc711f9245ec104fd6cbeafc parent d50f2d47439ba8f4e06e2b1341f719492d1fb6f8
36 files changed +1615 −796
D cmd/gitfed-site/handlers.go +0 −169
D cmd/gitfed-site/main.go +0 −64
D cmd/gitfed-site/render.go +0 −66
M cmd/gitfed-tui/actions.go +12 −2
D cmd/gitfed-web/handlers_acl.go +0 −143
A cmd/gitfed-web/handlers_admin.go +22 −0
R cmd/gitfed-web/handlers_audit.go → cmd/gitfed-web/handlers_admin_audit.go +6 −4
R cmd/gitfed-web/handlers_trust.go → cmd/gitfed-web/handlers_admin_trust.go +12 −10
A cmd/gitfed-web/handlers_admin_users.go +106 −0
A cmd/gitfed-web/handlers_auth.go +79 −0
A cmd/gitfed-web/handlers_dashboard.go +77 −0
M cmd/gitfed-web/handlers_home.go +42 −26
A cmd/gitfed-web/handlers_repo.go +298 −0
D cmd/gitfed-web/handlers_repos.go +0 −74
A cmd/gitfed-web/handlers_settings.go +112 −0
D cmd/gitfed-web/handlers_users.go +0 −72
M cmd/gitfed-web/main.go +13 −16
M cmd/gitfed-web/render.go +52 −20
M cmd/gitfed-web/routes.go +30 −15
A cmd/gitfed-web/session.go +75 −0
M deploy/docker/Dockerfile +6 −7
M deploy/k8s/README.md +47 −39
M deploy/k8s/configmap.yaml +4 −9
M deploy/k8s/deployment.yaml +18 −36
M deploy/k8s/ingress.yaml +4 −9
M deploy/k8s/service.yaml +3 −7
M internal/admin/admin.go +94 −0
M internal/admin/admin_test.go +98 −0
M internal/adminrpc/client.go +46 −0
M internal/adminrpc/protocol.go +57 −0
M internal/adminrpc/server.go +68 −0
A internal/store/auth.go +34 −0
A internal/store/auth_test.go +80 −0
A internal/store/sessions.go +72 −0
M internal/store/store.go +9 −7
M internal/store/users.go +39 −1
cmd/gitfed-site/handlers.go
diff --git a/cmd/gitfed-site/handlers.go b/cmd/gitfed-site/handlers.go deleted file mode 100644 index 8cedd18..0000000 --- a/cmd/gitfed-site/handlers.go +++ /dev/null @@ -1,169 +0,0 @@ -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 deleted file mode 100644 index 21e7829..0000000 --- a/cmd/gitfed-site/main.go +++ /dev/null @@ -1,64 +0,0 @@ -// 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 deleted file mode 100644 index 62b4c2b..0000000 --- a/cmd/gitfed-site/render.go +++ /dev/null @@ -1,66 +0,0 @@ -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 index 7203443..2794a56 100644 --- a/cmd/gitfed-tui/actions.go +++ b/cmd/gitfed-tui/actions.go @@ -67,8 +67,8 @@ func (m *model) refreshAudit() { 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"}, + []string{"Username", "Public key", "Password", "Admin (y/n)"}, + []string{"alice", "ssh-ed25519 AAAA... comment", "min. 8 characters", "n"}, func(values []string) tea.Msg { username := strings.TrimSpace(values[0]) if username == "" { @@ -77,6 +77,16 @@ func (m model) openAddUserForm() (tea.Model, tea.Cmd) { if err := a.CreateUser(username, values[1]); err != nil { return statusMsg{err: err} } + if password := values[2]; password != "" { + if err := a.SetPassword(username, password); err != nil { + return statusMsg{err: err} + } + } + if isAdmin := strings.EqualFold(strings.TrimSpace(values[3]), "y"); isAdmin { + if err := a.SetUserAdmin(username, true); err != nil { + return statusMsg{err: err} + } + } return statusMsg{text: "user " + username + " created"} }, func() tea.Msg { return formCancelMsg{} },
cmd/gitfed-web/handlers_acl.go
diff --git a/cmd/gitfed-web/handlers_acl.go b/cmd/gitfed-web/handlers_acl.go deleted file mode 100644 index 2fa8add..0000000 --- a/cmd/gitfed-web/handlers_acl.go +++ /dev/null @@ -1,143 +0,0 @@ -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_admin.go
diff --git a/cmd/gitfed-web/handlers_admin.go b/cmd/gitfed-web/handlers_admin.go new file mode 100644 index 0000000..00a70c9 --- /dev/null +++ b/cmd/gitfed-web/handlers_admin.go @@ -0,0 +1,22 @@ +package main + +import ( + "bytes" + "html/template" + "net/http" +) + +var adminIndexTpl = template.Must(template.New("admin-index").Parse(` +<h1>Admin</h1> +<ul> + <li><a href="/admin/users">Users</a> — create/delete accounts, grant admin</li> + <li><a href="/admin/trust">Trust store</a> — approve federated domains</li> + <li><a href="/admin/audit">Audit log</a> — recent auth &amp; access events</li> +</ul> +`)) + +func (s *server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { + var buf bytes.Buffer + _ = adminIndexTpl.Execute(&buf, nil) + s.render(w, r, "Admin", "admin", template.HTML(buf.String())) +}
cmd/gitfed-web/handlers_audit.go
diff --git a/cmd/gitfed-web/handlers_audit.go b/cmd/gitfed-web/handlers_admin_audit.go similarity index 73% rename from cmd/gitfed-web/handlers_audit.go rename to cmd/gitfed-web/handlers_admin_audit.go index cc8dbdd..5ee80b4 100644 --- a/cmd/gitfed-web/handlers_audit.go +++ b/cmd/gitfed-web/handlers_admin_audit.go @@ -8,7 +8,9 @@ import ( "gitfed/internal/store" ) -var auditTpl = template.Must(template.New("audit").Parse(` +var adminAuditTpl = template.Must(template.New("admin-audit").Parse(` +<p><a href="/admin">&larr; Admin</a></p> +<h1>Audit log</h1> <table> <tr><th>Time</th><th>Action</th><th>Principal</th><th>Repo/Domain</th><th>Result</th><th>Detail</th></tr> {{range .Events}} @@ -27,15 +29,15 @@ var auditTpl = template.Must(template.New("audit").Parse(` <p class="muted">Showing the most recent 200 events.</p> `)) -func (s *server) handleAudit(w http.ResponseWriter, r *http.Request) { +func (s *server) handleAdminAudit(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 { + _ = adminAuditTpl.Execute(&buf, struct { Events []store.AuditEvent }{events}) - s.render(w, "Audit log", "audit", template.HTML(buf.String())) + s.render(w, r, "Admin — Audit log", "admin", template.HTML(buf.String())) }
cmd/gitfed-web/handlers_trust.go
diff --git a/cmd/gitfed-web/handlers_trust.go b/cmd/gitfed-web/handlers_admin_trust.go similarity index 57% rename from cmd/gitfed-web/handlers_trust.go rename to cmd/gitfed-web/handlers_admin_trust.go index 8ed953c..67ba426 100644 --- a/cmd/gitfed-web/handlers_trust.go +++ b/cmd/gitfed-web/handlers_admin_trust.go @@ -8,18 +8,20 @@ import ( "gitfed/internal/store" ) -var trustTpl = template.Must(template.New("trust").Parse(` +var adminTrustTpl = template.Must(template.New("admin-trust").Parse(` {{.Flash}} +<p><a href="/admin">&larr; Admin</a></p> +<h1>Trust store</h1> <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 class="muted">{{.FirstSeenAt.Local.Format "2006-01-02 15:04"}}</td> <td> {{if eq .Status "pending"}} - <form class="inline" method="post" action="/trust/approve"> + <form class="inline" method="post" action="/admin/trust/approve"> <input type="hidden" name="domain" value="{{.Domain}}"> <button type="submit">Approve</button> </form> @@ -30,28 +32,28 @@ var trustTpl = template.Must(template.New("trust").Parse(` <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> +<p class="muted">Domains show up here automatically the first time a collaborator on a remote domain is granted access to a repo. Under the default whitelist policy they stay pending until approved here.</p> `)) -func (s *server) handleTrustList(w http.ResponseWriter, r *http.Request) { +func (s *server) handleAdminTrustList(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 { + _ = adminTrustTpl.Execute(&buf, struct { Trust []store.TrustedCA Flash template.HTML }{trust, flash(r)}) - s.render(w, "Trust store", "trust", template.HTML(buf.String())) + s.render(w, r, "Admin — Trust store", "admin", template.HTML(buf.String())) } -func (s *server) handleTrustApprove(w http.ResponseWriter, r *http.Request) { +func (s *server) handleAdminTrustApprove(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) + redirectWithMsg(w, r, "/admin/trust", err.Error(), true) return } - redirectWithMsg(w, r, "/trust", "approved "+domain, false) + redirectWithMsg(w, r, "/admin/trust", "approved "+domain, false) }
cmd/gitfed-web/handlers_admin_users.go
diff --git a/cmd/gitfed-web/handlers_admin_users.go b/cmd/gitfed-web/handlers_admin_users.go new file mode 100644 index 0000000..683fc51 --- /dev/null +++ b/cmd/gitfed-web/handlers_admin_users.go @@ -0,0 +1,106 @@ +package main + +import ( + "bytes" + "html/template" + "net/http" + + "gitfed/internal/store" +) + +var adminUsersTpl = template.Must(template.New("admin-users").Parse(` +{{.Flash}} +<p><a href="/admin">&larr; Admin</a></p> +<h1>Users</h1> +<table> +<tr><th>Username</th><th>Keys</th><th>Admin</th><th></th></tr> +{{range .Users}} +<tr> + <td>{{.Username}}</td> + <td>{{len .PubKeys}}</td> + <td> + <form class="inline" method="post" action="/admin/users/set-admin"> + <input type="hidden" name="username" value="{{.Username}}"> + <input type="hidden" name="is_admin" value="{{if .IsAdmin}}0{{else}}1{{end}}"> + <button type="submit">{{if .IsAdmin}}Revoke admin{{else}}Make admin{{end}}</button> + </form> + </td> + <td> + <form class="inline" method="post" action="/admin/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="4" class="muted">No users yet.</td></tr> +{{end}} +</table> + +<form class="card" method="post" action="/admin/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"> + <label>Initial password</label> + <input name="password" type="password" required minlength="8"> + <label><input type="checkbox" name="is_admin" value="1" style="width:auto; display:inline-block;"> Grant admin</label> + <button type="submit">Create</button> +</form> +`)) + +func (s *server) handleAdminUsersList(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 + _ = adminUsersTpl.Execute(&buf, struct { + Users []store.User + Flash template.HTML + }{users, flash(r)}) + s.render(w, r, "Admin — Users", "admin", template.HTML(buf.String())) +} + +func (s *server) handleAdminUsersCreate(w http.ResponseWriter, r *http.Request) { + username := r.FormValue("username") + pubkey := r.FormValue("pubkey") + password := r.FormValue("password") + + if err := s.ops.CreateUser(username, pubkey); err != nil { + redirectWithMsg(w, r, "/admin/users", err.Error(), true) + return + } + if err := s.ops.SetPassword(username, password); err != nil { + redirectWithMsg(w, r, "/admin/users", err.Error(), true) + return + } + if r.FormValue("is_admin") == "1" { + if err := s.ops.SetUserAdmin(username, true); err != nil { + redirectWithMsg(w, r, "/admin/users", err.Error(), true) + return + } + } + redirectWithMsg(w, r, "/admin/users", "created user "+username, false) +} + +func (s *server) handleAdminUsersDelete(w http.ResponseWriter, r *http.Request) { + username := r.FormValue("username") + if err := s.ops.DeleteUser(username); err != nil { + redirectWithMsg(w, r, "/admin/users", err.Error(), true) + return + } + redirectWithMsg(w, r, "/admin/users", "deleted user "+username, false) +} + +func (s *server) handleAdminUsersSetAdmin(w http.ResponseWriter, r *http.Request) { + username := r.FormValue("username") + isAdmin := r.FormValue("is_admin") == "1" + if err := s.ops.SetUserAdmin(username, isAdmin); err != nil { + redirectWithMsg(w, r, "/admin/users", err.Error(), true) + return + } + redirectWithMsg(w, r, "/admin/users", "updated "+username, false) +}
cmd/gitfed-web/handlers_auth.go
diff --git a/cmd/gitfed-web/handlers_auth.go b/cmd/gitfed-web/handlers_auth.go new file mode 100644 index 0000000..d91d7c5 --- /dev/null +++ b/cmd/gitfed-web/handlers_auth.go @@ -0,0 +1,79 @@ +package main + +import ( + "bytes" + "html/template" + "net/http" + "strings" +) + +var loginTpl = template.Must(template.New("login").Parse(` +{{.Flash}} +<form class="card" method="post" action="/login"> + <strong>Log in</strong> + <input type="hidden" name="next" value="{{.Next}}"> + <label>Username</label> + <input name="username" required autofocus> + <label>Password</label> + <input name="password" type="password" required> + <button type="submit">Log in</button> +</form> +<p class="muted">Accounts are created by an instance admin — there's no self-registration. This only logs you into the web UI; git push/pull still goes over SSH with your key.</p> +`)) + +func (s *server) handleLoginForm(w http.ResponseWriter, r *http.Request) { + if _, ok := s.currentSession(r); ok { + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + return + } + next := sanitizeNext(r.URL.Query().Get("next")) + var buf bytes.Buffer + _ = loginTpl.Execute(&buf, struct { + Next string + Flash template.HTML + }{next, flash(r)}) + s.render(w, r, "Log in", "login", template.HTML(buf.String())) +} + +func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) { + username := r.FormValue("username") + password := r.FormValue("password") + next := sanitizeNext(r.FormValue("next")) + + isAdmin, ok, err := s.ops.VerifyPassword(username, password) + if err != nil { + redirectWithMsg(w, r, "/login?next="+template.URLQueryEscaper(next), err.Error(), true) + return + } + if !ok { + redirectWithMsg(w, r, "/login?next="+template.URLQueryEscaper(next), "invalid username or password", true) + return + } + + principal := username + "@" + s.domain + token, err := s.ops.CreateSession(principal, username, isAdmin) + if err != nil { + redirectWithMsg(w, r, "/login?next="+template.URLQueryEscaper(next), err.Error(), true) + return + } + setSessionCookie(w, token) + http.Redirect(w, r, next, http.StatusSeeOther) +} + +func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(sessionCookieName); err == nil { + _ = s.ops.DeleteSession(c.Value) + } + clearSessionCookie(w) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// sanitizeNext keeps "next" redirects local-only — otherwise a crafted +// login link ("?next=https://evil.example") could bounce a freshly +// authenticated user off to an attacker's site. +func sanitizeNext(next string) string { + if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") { + return "/dashboard" + } + return next +}
cmd/gitfed-web/handlers_dashboard.go
diff --git a/cmd/gitfed-web/handlers_dashboard.go b/cmd/gitfed-web/handlers_dashboard.go new file mode 100644 index 0000000..24556ea --- /dev/null +++ b/cmd/gitfed-web/handlers_dashboard.go @@ -0,0 +1,77 @@ +package main + +import ( + "bytes" + "html/template" + "net/http" + + "gitfed/internal/store" +) + +var dashboardTpl = template.Must(template.New("dashboard").Parse(` +{{.Flash}} +<h1>Your repos</h1> +<table> +<tr><th>Repo</th><th>Role</th><th>Visibility</th></tr> +{{range .Repos}} +<tr> + <td><a href="/r/{{.Name}}">{{.Name}}</a></td> + <td class="muted">{{.Role}}</td> + <td>{{if .Public}}<span class="badge trusted">public</span>{{else}}<span class="badge pending">private</span>{{end}}</td> +</tr> +{{else}} +<tr><td colspan="3" class="muted">No repos yet — create one below.</td></tr> +{{end}} +</table> + +<form class="card" method="post" action="/repos"> + <strong>Create repo</strong> + <label>Name</label> + <input name="name" required placeholder="{{.Username}}/my-project"> + <button type="submit">Create</button> +</form> +`)) + +type dashboardRepo struct { + store.Repo + Role string +} + +func (s *server) handleDashboard(w http.ResponseWriter, r *http.Request) { + sess, _ := s.currentSession(r) + + all, err := s.ops.ListRepos() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + var mine []dashboardRepo + for _, repo := range all { + if repo.Owner == sess.Principal { + mine = append(mine, dashboardRepo{Repo: repo, Role: "owner"}) + continue + } + role, ok, err := s.ops.CheckAccess(repo.Name, sess.Principal, store.RoleRead) + if err == nil && ok { + mine = append(mine, dashboardRepo{Repo: repo, Role: string(role)}) + } + } + + var buf bytes.Buffer + _ = dashboardTpl.Execute(&buf, struct { + Repos []dashboardRepo + Username string + Flash template.HTML + }{mine, sess.Username, flash(r)}) + s.render(w, r, "Dashboard", "dashboard", template.HTML(buf.String())) +} + +func (s *server) handleCreateRepo(w http.ResponseWriter, r *http.Request) { + sess, _ := s.currentSession(r) + name := r.FormValue("name") + if err := s.ops.CreateRepo(name, sess.Username); err != nil { + redirectWithMsg(w, r, "/dashboard", err.Error(), true) + return + } + redirectWithMsg(w, r, "/repo-settings/"+name, "created "+name, false) +}
cmd/gitfed-web/handlers_home.go
diff --git a/cmd/gitfed-web/handlers_home.go b/cmd/gitfed-web/handlers_home.go index e645d14..cc8e071 100644 --- a/cmd/gitfed-web/handlers_home.go +++ b/cmd/gitfed-web/handlers_home.go @@ -10,39 +10,55 @@ import ( 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> +<h1>Explore public repositories</h1> +{{if .Topic}}<p class="muted">Filtered by topic <span class="badge plain">{{.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 plain" 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) { - users, _ := s.ops.ListUsers() - repos, _ := s.ops.ListRepos() - trust, _ := s.ops.ListTrustedCAs() - pending := 0 - for _, t := range trust { - if t.Status == store.TrustPending { - pending++ + 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 { - Users, Repos, Trust, Pending int - Flash template.HTML - }{len(users), len(repos), len(trust), pending, flash(r)}) + Repos []store.Repo + Topic string + Flash template.HTML + }{visible, topic, flash(r)}) + s.render(w, r, "Explore", "home", template.HTML(buf.String())) +} - s.render(w, "Home", "home", template.HTML(buf.String())) +func containsTopic(topics []string, topic string) bool { + for _, t := range topics { + if t == topic { + return true + } + } + return false }
cmd/gitfed-web/handlers_repo.go
diff --git a/cmd/gitfed-web/handlers_repo.go b/cmd/gitfed-web/handlers_repo.go new file mode 100644 index 0000000..c3dc1e4 --- /dev/null +++ b/cmd/gitfed-web/handlers_repo.go @@ -0,0 +1,298 @@ +package main + +import ( + "bytes" + "html/template" + "net/http" + "net/url" + "strings" + + "gitfed/internal/store" +) + +// canView reports whether the current request may see repo at all: public +// repos are open to everyone, private ones require a logged-in principal +// with at least read access. Not found and access-denied look identical to +// the caller — neither anonymous visitors nor unrelated logged-in users +// should be able to tell a private repo exists. +func (s *server) canView(r *http.Request, repo store.Repo) bool { + if repo.Public { + return true + } + sess, ok := s.currentSession(r) + if !ok { + return false + } + _, allowed, err := s.ops.CheckAccess(repo.Name, sess.Principal, store.RoleRead) + return err == nil && allowed +} + +// canAdminister reports whether the current session may change repo's +// settings (visibility, topics, collaborators) or delete it. +func (s *server) canAdminister(r *http.Request, repoName string) (string, bool) { + sess, ok := s.currentSession(r) + if !ok { + return "", false + } + _, allowed, err := s.ops.CheckAccess(repoName, sess.Principal, store.RoleAdmin) + return sess.Principal, err == nil && allowed +} + +var repoTpl = template.Must(template.New("repo").Parse(` +{{.Flash}} +<p><a href="/">&larr; All repos</a></p> +<h1>{{.Repo.Name}} {{if .Repo.Public}}<span class="badge trusted">public</span>{{else}}<span class="badge pending">private</span>{{end}}</h1> +<p class="muted">owner: {{.Repo.Owner}} +{{range .Repo.Topics}}<span class="badge plain">{{.}}</span>{{end}} +{{if .CanAdminister}} · <a href="/repo-settings/{{.Repo.Name}}">settings</a>{{end}}</p> + +<section> +<code>ssh://git@{{.Domain}}:2222/{{.Repo.Name}}.git</code> +<p class="muted">Cloning requires a gitfed SSH identity — this web login is separate from git access.</p> +</section> + +{{if .Tags}} +<section> +<h3>Tags</h3> +{{range .Tags}}<span class="badge plain">{{.}}</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) handleRepoView(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("repo") + + repo, err := s.ops.GetRepo(name) + if err != nil || !s.canView(r, repo) { + http.NotFound(w, r) + return + } + _, canAdminister := s.canAdminister(r, name) + + tags, err := s.ops.ListRepoTags(name) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + readmeContent, readmeFound, err := s.ops.GetRepoReadme(name) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + var readmeHTML template.HTML + if readmeFound { + readmeHTML, err = renderFileContent("README.md", readmeContent) + 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 + CanAdminister bool + Flash template.HTML + }{repo, s.domain, tags, readmeHTML, licenseHTML, licenseFile, canAdminister, flash(r)}) + s.render(w, r, name, "home", template.HTML(buf.String())) +} + +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>`)) + +var repoSettingsTpl = template.Must(template.New("repo-settings").Parse(` +{{.Flash}} +<p><a href="/r/{{.Repo}}">&larr; {{.Repo}}</a></p> +<h1>{{.Repo}} settings</h1> + +<form class="card" method="post" action="/repo-settings/{{.Repo}}"> + <strong>Visibility &amp; topics</strong> + <label><input type="checkbox" name="public" value="1" style="width:auto; display:inline-block;" {{if .Public}}checked{{end}}> Public (readable by any authenticated principal)</label> + <label>Topics (comma-separated)</label> + <input name="topics" value="{{.TopicsCSV}}" placeholder="cli, tooling, go"> + <button type="submit">Save</button> +</form> + +<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="/repo-revoke/{{$.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="/repo-grant/{{.Repo}}"> + <strong>Grant collaborator</strong> + <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 — an instance admin needs to approve it under Admin &rarr; Trust store (whitelist policy).</p> + +<form class="card" method="post" action="/repo-delete/{{.Repo}}" onsubmit="return confirm('Delete {{.Repo}}? This does not delete the bare repo on disk.');"> + <strong>Danger zone</strong> + <button class="danger" type="submit">Delete repo record</button> +</form> +`)) + +func (s *server) handleRepoSettingsForm(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("repo") + if _, ok := s.canAdminister(r, name); !ok { + http.NotFound(w, r) + return + } + repo, err := s.ops.GetRepo(name) + if err != nil { + http.NotFound(w, r) + return + } + acl, err := s.ops.GetACL(name) + if err != nil && err != store.ErrNotFound { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var buf bytes.Buffer + _ = repoSettingsTpl.Execute(&buf, struct { + Repo string + Owner string + Public bool + TopicsCSV string + Collaborators []store.Collaborator + Flash template.HTML + }{name, repo.Owner, repo.Public, strings.Join(repo.Topics, ", "), acl.Collaborators, flash(r)}) + s.render(w, r, name+" settings", "home", template.HTML(buf.String())) +} + +func (s *server) handleRepoSettingsSave(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("repo") + if _, ok := s.canAdminister(r, name); !ok { + http.NotFound(w, r) + return + } + back := "/repo-settings/" + url.PathEscape(name) + + public := r.FormValue("public") == "1" + if err := s.ops.SetRepoPublic(name, 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(name, topics); err != nil { + redirectWithMsg(w, r, back, err.Error(), true) + return + } + redirectWithMsg(w, r, back, "saved settings", false) +} + +func (s *server) handleCollabGrant(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("repo") + if _, ok := s.canAdminister(r, name); !ok { + http.NotFound(w, r) + return + } + back := "/repo-settings/" + url.PathEscape(name) + principal := r.FormValue("principal") + role := store.Role(r.FormValue("role")) + if err := s.ops.GrantCollaborator(name, principal, role); err != nil { + redirectWithMsg(w, r, back, err.Error(), true) + return + } + redirectWithMsg(w, r, back, "granted "+principal+" "+string(role), false) +} + +func (s *server) handleCollabRevoke(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("repo") + if _, ok := s.canAdminister(r, name); !ok { + http.NotFound(w, r) + return + } + back := "/repo-settings/" + url.PathEscape(name) + principal := r.FormValue("principal") + if err := s.ops.RevokeCollaborator(name, principal); err != nil { + redirectWithMsg(w, r, back, err.Error(), true) + return + } + redirectWithMsg(w, r, back, "revoked "+principal, false) +} + +func (s *server) handleRepoDelete(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("repo") + if _, ok := s.canAdminister(r, name); !ok { + http.NotFound(w, r) + return + } + if err := s.ops.DeleteRepo(name); err != nil { + redirectWithMsg(w, r, "/repo-settings/"+url.PathEscape(name), err.Error(), true) + return + } + redirectWithMsg(w, r, "/dashboard", "deleted "+name, false) +}
cmd/gitfed-web/handlers_repos.go
diff --git a/cmd/gitfed-web/handlers_repos.go b/cmd/gitfed-web/handlers_repos.go deleted file mode 100644 index 45bd047..0000000 --- a/cmd/gitfed-web/handlers_repos.go +++ /dev/null @@ -1,74 +0,0 @@ -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_settings.go
diff --git a/cmd/gitfed-web/handlers_settings.go b/cmd/gitfed-web/handlers_settings.go new file mode 100644 index 0000000..61360e7 --- /dev/null +++ b/cmd/gitfed-web/handlers_settings.go @@ -0,0 +1,112 @@ +package main + +import ( + "bytes" + "html/template" + "net/http" +) + +var settingsTpl = template.Must(template.New("settings").Parse(` +{{.Flash}} +<h1>Settings</h1> +<p class="muted">Logged in as <strong>{{.Username}}</strong>{{if .IsAdmin}} (admin){{end}}</p> + +<table> +<tr><th>SSH key</th><th></th></tr> +{{range .Keys}} +<tr> + <td><code>{{.Short}}</code></td> + <td> + <form class="inline" method="post" action="/settings/keys/remove"> + <input type="hidden" name="key" value="{{.Full}}"> + <button class="danger" type="submit">Remove</button> + </form> + </td> +</tr> +{{end}} +</table> + +<form class="card" method="post" action="/settings/keys/add"> + <strong>Add SSH key</strong> + <label>Public key (authorized_keys format)</label> + <input name="pubkey" required placeholder="ssh-ed25519 AAAA... comment"> + <button type="submit">Add</button> +</form> + +<form class="card" method="post" action="/settings/password"> + <strong>Change password</strong> + <label>Current password</label> + <input name="old_password" type="password" required> + <label>New password</label> + <input name="new_password" type="password" required minlength="8"> + <button type="submit">Change password</button> +</form> +`)) + +type keyView struct{ Short, Full string } + +func (s *server) handleSettings(w http.ResponseWriter, r *http.Request) { + sess, _ := s.currentSession(r) + user, err := s.ops.GetUser(sess.Username) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + keys := make([]keyView, len(user.PubKeys)) + for i, k := range user.PubKeys { + short := k + if len(short) > 60 { + short = short[:60] + "..." + } + keys[i] = keyView{Short: short, Full: k} + } + + var buf bytes.Buffer + _ = settingsTpl.Execute(&buf, struct { + Username string + IsAdmin bool + Keys []keyView + Flash template.HTML + }{sess.Username, sess.IsAdmin, keys, flash(r)}) + s.render(w, r, "Settings", "settings", template.HTML(buf.String())) +} + +func (s *server) handleAddOwnKey(w http.ResponseWriter, r *http.Request) { + sess, _ := s.currentSession(r) + if err := s.ops.AddUserKey(sess.Username, r.FormValue("pubkey")); err != nil { + redirectWithMsg(w, r, "/settings", err.Error(), true) + return + } + redirectWithMsg(w, r, "/settings", "key added", false) +} + +func (s *server) handleRemoveOwnKey(w http.ResponseWriter, r *http.Request) { + sess, _ := s.currentSession(r) + if err := s.ops.RemoveUserKey(sess.Username, r.FormValue("key")); err != nil { + redirectWithMsg(w, r, "/settings", err.Error(), true) + return + } + redirectWithMsg(w, r, "/settings", "key removed", false) +} + +func (s *server) handleChangePassword(w http.ResponseWriter, r *http.Request) { + sess, _ := s.currentSession(r) + oldPassword := r.FormValue("old_password") + newPassword := r.FormValue("new_password") + + _, ok, err := s.ops.VerifyPassword(sess.Username, oldPassword) + if err != nil { + redirectWithMsg(w, r, "/settings", err.Error(), true) + return + } + if !ok { + redirectWithMsg(w, r, "/settings", "current password is incorrect", true) + return + } + if err := s.ops.SetPassword(sess.Username, newPassword); err != nil { + redirectWithMsg(w, r, "/settings", err.Error(), true) + return + } + redirectWithMsg(w, r, "/settings", "password changed", false) +}
cmd/gitfed-web/handlers_users.go
diff --git a/cmd/gitfed-web/handlers_users.go b/cmd/gitfed-web/handlers_users.go deleted file mode 100644 index 27b1e9b..0000000 --- a/cmd/gitfed-web/handlers_users.go +++ /dev/null @@ -1,72 +0,0 @@ -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 index b930dc6..299985e 100644 --- a/cmd/gitfed-web/main.go +++ b/cmd/gitfed-web/main.go @@ -1,21 +1,19 @@ -// 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. +// gitfed-web is gitfed's web UI: a public repo browser (README/LICENSE/tags, +// public repos, no login needed) plus, behind a real username/password +// login, self-service (manage your own keys, repos, collaborators) and an +// admin section for instance-admin accounts. 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 (live socket or offline store, +// via internal/opsconnect), not a second implementation of the repo/ACL +// logic. // -// 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. +// SSH keys remain the only way to actually push/pull git — the password +// login here only ever grants access to this web UI. package main import ( "flag" "fmt" - "log" "net/http" "os" @@ -27,12 +25,11 @@ import ( 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") + listen := flag.String("listen", ":8088", "address to serve the web UI on") flag.Parse() cfg, err := config.Load(*configPath) @@ -49,12 +46,12 @@ func main() { } defer closeFn() - s := &server{ops: ops, domain: cfg.Domain, mode: mode} + s := &server{ops: ops, domain: cfg.Domain} mux := http.NewServeMux() s.routes(mux) - log.Printf("gitfed-web serving on http://%s (mode: %s)", *listen, mode) + fmt.Printf("gitfed-web serving on %s (mode: %s)\n", *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 index 7550628..7bc8ec8 100644 --- a/cmd/gitfed-web/render.go +++ b/cmd/gitfed-web/render.go @@ -1,11 +1,29 @@ package main import ( + "bytes" "html/template" "net/http" "strings" + + "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 reader trusts, 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> @@ -14,9 +32,13 @@ const shellSrc = `<!doctype html> <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; } + header h1 { font-size: 1rem; margin: 0; } + header h1 a { color: #8ab4f8; text-decoration: none; } + nav { display: flex; gap: 1rem; flex: 1; } + nav a { color: #cfd3dc; text-decoration: none; font-size: 0.9rem; } nav a.active { color: #8ab4f8; font-weight: bold; } + nav form { margin: 0; } + nav button.linklike { background: none; border: none; color: #cfd3dc; font-size: 0.9rem; cursor: pointer; padding: 0; font-family: inherit; } 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; } @@ -33,21 +55,34 @@ const shellSrc = `<!doctype html> .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; } + .badge.plain { background: #1e3a2a; color: #9ae6b4; margin-right: 0.3rem; } .muted { color: #9aa1ac; font-size: 0.85rem; } - code { background: #0f1115; padding: 0.1rem 0.3rem; border-radius: 4px; } + 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> - <h1>gitfed — {{.Domain}}</h1> + <h1><a href="/">gitfed — {{.Domain}}</a></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> + <a href="/"{{if eq .Active "home"}} class="active"{{end}}>Explore</a> + {{if .LoggedIn}} + <a href="/dashboard"{{if eq .Active "dashboard"}} class="active"{{end}}>Dashboard</a> + <a href="/settings"{{if eq .Active "settings"}} class="active"{{end}}>Settings</a> + {{if .IsAdmin}}<a href="/admin"{{if eq .Active "admin"}} class="active"{{end}}>Admin</a>{{end}} + {{end}} </nav> - <span class="muted">{{.Mode}}</span> + {{if .LoggedIn}} + <span class="muted">{{.Username}}</span> + <form method="post" action="/logout"><button class="linklike" type="submit">Log out</button></form> + {{else}} + <a href="/login">Log in</a> + {{end}} </header> <main> {{.Body}} @@ -57,17 +92,14 @@ const shellSrc = `<!doctype 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) { +func (s *server) render(w http.ResponseWriter, r *http.Request, title, active string, body template.HTML) { + sess, loggedIn := s.currentSession(r) 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}) + _ = shellTpl.Execute(w, struct { + Title, Domain, Active, Username string + LoggedIn, IsAdmin bool + Body template.HTML + }{title, s.domain, active, sess.Username, loggedIn, sess.IsAdmin, body}) } // flash renders the ?msg=&err= query params (set by handlers that redirect
cmd/gitfed-web/routes.go
diff --git a/cmd/gitfed-web/routes.go b/cmd/gitfed-web/routes.go index 363e056..8d83c1e 100644 --- a/cmd/gitfed-web/routes.go +++ b/cmd/gitfed-web/routes.go @@ -3,23 +3,38 @@ package main import "net/http" func (s *server) routes(mux *http.ServeMux) { + // Public — no login required. mux.HandleFunc("GET /{$}", s.handleHome) + mux.HandleFunc("GET /r/{repo...}", s.handleRepoView) + mux.HandleFunc("GET /login", s.handleLoginForm) + mux.HandleFunc("POST /login", s.handleLogin) + mux.HandleFunc("POST /logout", s.handleLogout) - mux.HandleFunc("GET /users", s.handleUsersList) - mux.HandleFunc("POST /users", s.handleUsersCreate) - mux.HandleFunc("POST /users/delete", s.handleUsersDelete) + // Self-service — any logged-in user, scoped to their own stuff via + // CheckAccess inside the handlers. + // Repo sub-actions get their own path prefixes rather than a suffix + // after {repo...} — net/http's mux requires a "..." wildcard to be the + // last segment of a pattern, so "/r/{repo...}/settings" isn't legal. + mux.HandleFunc("GET /dashboard", s.requireLogin(s.handleDashboard)) + mux.HandleFunc("POST /repos", s.requireLogin(s.handleCreateRepo)) + mux.HandleFunc("GET /repo-settings/{repo...}", s.requireLogin(s.handleRepoSettingsForm)) + mux.HandleFunc("POST /repo-settings/{repo...}", s.requireLogin(s.handleRepoSettingsSave)) + mux.HandleFunc("POST /repo-grant/{repo...}", s.requireLogin(s.handleCollabGrant)) + mux.HandleFunc("POST /repo-revoke/{repo...}", s.requireLogin(s.handleCollabRevoke)) + mux.HandleFunc("POST /repo-delete/{repo...}", s.requireLogin(s.handleRepoDelete)) - mux.HandleFunc("GET /repos", s.handleReposList) - mux.HandleFunc("POST /repos", s.handleReposCreate) - mux.HandleFunc("POST /repos/delete", s.handleReposDelete) + mux.HandleFunc("GET /settings", s.requireLogin(s.handleSettings)) + mux.HandleFunc("POST /settings/keys/add", s.requireLogin(s.handleAddOwnKey)) + mux.HandleFunc("POST /settings/keys/remove", s.requireLogin(s.handleRemoveOwnKey)) + mux.HandleFunc("POST /settings/password", s.requireLogin(s.handleChangePassword)) - 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) + // Admin — instance-admin accounts only. + mux.HandleFunc("GET /admin", s.requireAdmin(s.handleAdminIndex)) + mux.HandleFunc("GET /admin/users", s.requireAdmin(s.handleAdminUsersList)) + mux.HandleFunc("POST /admin/users", s.requireAdmin(s.handleAdminUsersCreate)) + mux.HandleFunc("POST /admin/users/delete", s.requireAdmin(s.handleAdminUsersDelete)) + mux.HandleFunc("POST /admin/users/set-admin", s.requireAdmin(s.handleAdminUsersSetAdmin)) + mux.HandleFunc("GET /admin/trust", s.requireAdmin(s.handleAdminTrustList)) + mux.HandleFunc("POST /admin/trust/approve", s.requireAdmin(s.handleAdminTrustApprove)) + mux.HandleFunc("GET /admin/audit", s.requireAdmin(s.handleAdminAudit)) }
cmd/gitfed-web/session.go
diff --git a/cmd/gitfed-web/session.go b/cmd/gitfed-web/session.go new file mode 100644 index 0000000..07c68ad --- /dev/null +++ b/cmd/gitfed-web/session.go @@ -0,0 +1,75 @@ +package main + +import ( + "net/http" + "net/url" + "time" + + "gitfed/internal/store" +) + +const sessionCookieName = "gitfed_session" + +func setSessionCookie(w http.ResponseWriter, token string) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(30 * 24 * time.Hour), + }) +} + +func clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) +} + +// currentSession returns the caller's session, if any. A missing, invalid +// or expired cookie is just "not logged in" — never an error the caller +// needs to handle specially. +func (s *server) currentSession(r *http.Request) (store.Session, bool) { + c, err := r.Cookie(sessionCookieName) + if err != nil || c.Value == "" { + return store.Session{}, false + } + sess, err := s.ops.GetSession(c.Value) + if err != nil { + return store.Session{}, false + } + return sess, true +} + +func (s *server) requireLogin(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, ok := s.currentSession(r); !ok { + http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther) + return + } + next(w, r) + } +} + +func (s *server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sess, ok := s.currentSession(r) + if !ok { + http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther) + return + } + if !sess.IsAdmin { + http.Error(w, "admin access required", http.StatusForbidden) + return + } + next(w, r) + } +}
deploy/docker/Dockerfile
diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 9f25e47..0d9d0c4 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -1,7 +1,7 @@ -# 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). +# Builds gitfed-server, gitfed-web, gitfed-tui into a single image. +# gitfed-tui is included so an admin can `kubectl exec` into the pod and +# bootstrap the first account (username/password/admin flag) directly +# against the live admin socket — 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. @@ -13,7 +13,6 @@ 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 @@ -23,10 +22,10 @@ RUN apt-get update && \ 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/ +COPY --from=builder /out/gitfed-server /out/gitfed-web /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. +# run (gitfed-server / gitfed-web) against the same image.
deploy/k8s/README.md
diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md index 28fc3fb..d66a00b 100644 --- a/deploy/k8s/README.md +++ b/deploy/k8s/README.md @@ -7,19 +7,23 @@ both rather than standing up anything new. ## Why this shape -- **One pod, three containers, one PVC.** `gitfed-server` owns the bbolt +- **One pod, two 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. + `/data/admin.sock`. `gitfed-web` talks to it over that socket instead of + opening the database itself — 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). +- **`gitfed-web` is a real multi-user app now**, not an admin-only tool: + public repo browsing needs no login; self-service (your own keys, repos, + collaborators) and the admin section both sit behind a real + username/password login (session cookies, bcrypt-hashed passwords). + That's why, unlike an earlier iteration of this deployment, it has both a + Service and an Ingress — it's meant to be reachable from the internet. +- **Password login is web-only.** It never grants git access — `git + clone`/`push` always goes over SSH with a key/certificate, completely + independent of this login system. - **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). @@ -59,12 +63,11 @@ 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 +## 3. Check 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. +`configmap.yaml` and `ingress.yaml` are filled in already for +`git.neuromancer.ovh` / `letsencrypt-prod` — double check those still match +before applying if anything changed since. ## 4. Apply @@ -83,56 +86,61 @@ Watch it come up: 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. +Both containers must reach `Running`/`Ready` — `web` waits in a loop for +`server`'s admin socket before starting, so a brief `0/2` 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 +curl https://git.neuromancer.ovh/.well-known/gitfed.json +curl https://git.neuromancer.ovh/ # gitfed-web homepage ``` -Create your first user/repo (there's no user yet, so this has to happen -from inside the cluster): +## 6. Bootstrap the first (admin) account -```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: +There's no account yet, and there's no self-registration by design. Create +the first one directly against the live admin socket: ```sh kubectl -n gitfed exec -it deployment/gitfed -c server -- \ gitfed-tui -config /etc/gitfed/gitfed.json ``` -Then clone for real: +Go to **Users → a** (add user), fill in username, SSH public key, a +password, and answer `y` to the admin prompt. Then log in at +`https://git.neuromancer.ovh/login` with that username/password — you'll +land on the dashboard with an **Admin** link in the nav. + +From here on, that account (or any other admin account) can create further +users from **Admin → Users** in the web UI itself — `gitfed-tui` is only +needed for this one-time bootstrap, or later if the web UI is ever +unreachable. + +Then clone for real, over SSH (not the web login): ```sh -git clone ssh://git@git.example.com:2222/alice/some-repo +git clone ssh://git@git.neuromancer.ovh:2222/<user>/<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. +`gitfed.db` (users, password hashes, sessions, 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 + `gitfed-wellknown`/`gitfed-web` 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. +- Rate limiting or a CAPTCHA on `/login` if this instance ever becomes a + public target — right now a wrong password just redirects back with an + error, no lockout or backoff.
deploy/k8s/configmap.yaml
diff --git a/deploy/k8s/configmap.yaml b/deploy/k8s/configmap.yaml index 4681356..3f25480 100644 --- a/deploy/k8s/configmap.yaml +++ b/deploy/k8s/configmap.yaml @@ -1,10 +1,5 @@ -# 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 +# insecure_federation is false 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 @@ -14,12 +9,12 @@ metadata: data: gitfed.json: | { - "domain": "git.CHANGEME.example", + "domain": "git.neuromancer.ovh", "data_dir": "/data", "repos_dir": "/data/repos", "listen_ssh": ":2222", "listen_http": ":8443", - "contact": "admin@CHANGEME.example", + "contact": "bastien.marques@outlook.com", "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 index 66e16d5..e4ee6ca 100644 --- a/deploy/k8s/deployment.yaml +++ b/deploy/k8s/deployment.yaml @@ -1,16 +1,21 @@ -# Three containers, one pod, one shared PVC: +# Two 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: the web UI (public repo browsing + password-authenticated +# self-service + admin section). Real login now exists, so unlike the +# earlier admin-only iteration of this deployment, it's meant to be +# reachable from the internet — see service.yaml/ingress.yaml. # -# 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. +# web talks to server over the admin socket (internal/opsconnect), never by +# opening the bbolt file itself — that file's exclusive lock means only one +# process may ever hold it, which is why it waits for the socket to appear +# rather than racing server for the store at pod startup. +# +# There is no account until you create one: after this Deployment comes up, +# bootstrap your first (admin) account with +# kubectl -n gitfed exec -it deployment/gitfed -c server -- \ +# gitfed-tui -config /etc/gitfed/gitfed.json +# (Add user → fill in username/key/password, answer "y" to Admin.) # # 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 @@ -78,32 +83,9 @@ spec: - | 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 + - name: web + containerPort: 8088 volumeMounts: - name: data mountPath: /data @@ -118,7 +100,7 @@ spec: requests: {cpu: 10m, memory: 32Mi} limits: {cpu: 200m, memory: 128Mi} readinessProbe: - httpGet: {path: /, port: site} + httpGet: {path: /, port: web} initialDelaySeconds: 3 volumes:
deploy/k8s/ingress.yaml
diff --git a/deploy/k8s/ingress.yaml b/deploy/k8s/ingress.yaml index 34ffce5..10e358b 100644 --- a/deploy/k8s/ingress.yaml +++ b/deploy/k8s/ingress.yaml @@ -1,8 +1,3 @@ -# 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: @@ -13,10 +8,10 @@ metadata: spec: ingressClassName: traefik tls: - - hosts: ["git.CHANGEME.example"] + - hosts: ["git.neuromancer.ovh"] secretName: gitfed-tls rules: - - host: git.CHANGEME.example + - host: git.neuromancer.ovh http: paths: - path: /.well-known/gitfed.json @@ -29,5 +24,5 @@ spec: pathType: Prefix backend: service: - name: gitfed-site - port: {number: 8090} + name: gitfed-web + port: {number: 8088}
deploy/k8s/service.yaml
diff --git a/deploy/k8s/service.yaml b/deploy/k8s/service.yaml index 57e3424..9e0618e 100644 --- a/deploy/k8s/service.yaml +++ b/deploy/k8s/service.yaml @@ -1,7 +1,3 @@ -# 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: @@ -17,11 +13,11 @@ spec: apiVersion: v1 kind: Service metadata: - name: gitfed-site + name: gitfed-web namespace: gitfed spec: selector: app: gitfed ports: - - port: 8090 - targetPort: site + - port: 8088 + targetPort: web
internal/admin/admin.go
diff --git a/internal/admin/admin.go b/internal/admin/admin.go index ecebca8..5395974 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -6,7 +6,9 @@ package admin import ( "fmt" "strings" + "time" + "golang.org/x/crypto/bcrypt" gossh "golang.org/x/crypto/ssh" "gitfed/internal/acl" @@ -22,9 +24,20 @@ import ( // fighting the store's single-writer file lock. type Ops interface { ListUsers() ([]store.User, error) + GetUser(username string) (store.User, error) CreateUser(username, pubKeyAuthorized string) error AddUserKey(username, pubKeyAuthorized string) error + RemoveUserKey(username, pubKeyAuthorized string) error DeleteUser(username string) error + SetUserAdmin(username string, isAdmin bool) error + + SetPassword(username, newPassword string) error + VerifyPassword(username, password string) (isAdmin bool, ok bool, err error) + CreateSession(principal, username string, isAdmin bool) (token string, err error) + GetSession(token string) (store.Session, error) + DeleteSession(token string) error + + CheckAccess(repoName, principal string, want store.Role) (store.Role, bool, error) ListRepos() ([]store.Repo, error) GetRepo(name string) (store.Repo, error) @@ -77,6 +90,14 @@ func (a *Admin) AddUserKey(username, pubKeyAuthorized string) error { return a.Store.AddUserKey(username, key) } +func (a *Admin) RemoveUserKey(username, pubKeyAuthorized string) error { + key, err := canonicalAuthorizedKey(pubKeyAuthorized) + if err != nil { + return err + } + return a.Store.RemoveUserKey(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 @@ -113,6 +134,9 @@ func (a *Admin) DeleteUser(username string) error { if err := a.Store.DeleteUser(username); err != nil { return err } + if err := a.Store.DeletePasswordHash(username); err != nil && err != store.ErrNotFound { + return err + } for _, r := range repos { if err := a.Store.RemoveCollaborator(r.Name, principal); err != nil && err != store.ErrNotFound { @@ -126,6 +150,14 @@ func (a *Admin) ListUsers() ([]store.User, error) { return a.Store.ListUsers() } +func (a *Admin) GetUser(username string) (store.User, error) { + return a.Store.GetUser(username) +} + +func (a *Admin) SetUserAdmin(username string, isAdmin bool) error { + return a.Store.SetUserAdmin(username, isAdmin) +} + func (a *Admin) ListRepos() ([]store.Repo, error) { return a.Store.ListRepos() } @@ -247,6 +279,68 @@ func (a *Admin) ApproveDomain(domain string) error { return a.Resolver.Approve(domain) } +const minPasswordLength = 8 + +// SetPassword hashes and stores newPassword for username, replacing any +// existing one. Used both for an admin resetting someone's password and for +// a user changing their own (the web layer is responsible for requiring the +// current password in the latter case — this call itself doesn't check). +func (a *Admin) SetPassword(username, newPassword string) error { + if len(newPassword) < minPasswordLength { + return fmt.Errorf("admin: password must be at least %d characters", minPasswordLength) + } + hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("admin: hash password: %w", err) + } + return a.Store.SetPasswordHash(username, string(hash)) +} + +// VerifyPassword reports whether password matches username's stored hash. +// ok is false (with a nil error) for a wrong password or a user with no +// password set yet — both are normal login-form outcomes, not failures. +func (a *Admin) VerifyPassword(username, password string) (isAdmin bool, ok bool, err error) { + user, err := a.Store.GetUser(username) + if err != nil { + if err == store.ErrNotFound { + return false, false, nil + } + return false, false, err + } + hash, err := a.Store.GetPasswordHash(username) + if err != nil { + if err == store.ErrNotFound { + return false, false, nil + } + return false, false, err + } + if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { + return false, false, nil + } + return user.IsAdmin, true, nil +} + +const sessionTTL = 30 * 24 * time.Hour + +func (a *Admin) CreateSession(principal, username string, isAdmin bool) (string, error) { + return a.Store.CreateSession(principal, username, isAdmin, sessionTTL) +} + +func (a *Admin) GetSession(token string) (store.Session, error) { + return a.Store.GetSession(token) +} + +func (a *Admin) DeleteSession(token string) error { + return a.Store.DeleteSession(token) +} + +// CheckAccess is the self-service-safe way to ask "can principal do want on +// repoName" without handing out full Ops access — the web layer uses this +// to authorize actions instead of trusting client-supplied ownership. +func (a *Admin) CheckAccess(repoName, principal string, want store.Role) (store.Role, bool, error) { + return acl.Check(a.Store, repoName, principal, want) +} + func splitPrincipal(principal string) (username, domain string, ok bool) { i := strings.LastIndex(principal, "@") if i <= 0 || i == len(principal)-1 {
internal/admin/admin_test.go
diff --git a/internal/admin/admin_test.go b/internal/admin/admin_test.go index 02a57d6..420699e 100644 --- a/internal/admin/admin_test.go +++ b/internal/admin/admin_test.go @@ -55,3 +55,101 @@ func TestCreateUserStripsKeyComment(t *testing.T) { t.Fatalf("FindUserByKey(bare) after CreateUser: %v (login would fail)", err) } } + +func TestPasswordLoginFlow(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()) + + if err := a.CreateUser("alice", validTestKey(t)); err != nil { + t.Fatalf("CreateUser: %v", err) + } + + // No password set yet: VerifyPassword must fail closed, not error. + _, ok, err := a.VerifyPassword("alice", "whatever") + if err != nil { + t.Fatalf("VerifyPassword before SetPassword: %v", err) + } + if ok { + t.Fatal("VerifyPassword succeeded with no password ever set") + } + + if err := a.SetPassword("alice", "correct horse battery staple"); err != nil { + t.Fatalf("SetPassword: %v", err) + } + + if _, ok, err := a.VerifyPassword("alice", "wrong password"); err != nil || ok { + t.Fatalf("VerifyPassword with wrong password: ok=%v err=%v", ok, err) + } + + isAdmin, ok, err := a.VerifyPassword("alice", "correct horse battery staple") + if err != nil { + t.Fatalf("VerifyPassword with correct password: %v", err) + } + if !ok { + t.Fatal("VerifyPassword rejected the correct password") + } + if isAdmin { + t.Fatal("alice should not be admin yet") + } + + if err := a.SetUserAdmin("alice", true); err != nil { + t.Fatalf("SetUserAdmin: %v", err) + } + isAdmin, ok, err = a.VerifyPassword("alice", "correct horse battery staple") + if err != nil || !ok || !isAdmin { + t.Fatalf("VerifyPassword after SetUserAdmin: isAdmin=%v ok=%v err=%v", isAdmin, ok, err) + } +} + +func TestSetPasswordRejectsShortPassword(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()) + + if err := a.CreateUser("alice", validTestKey(t)); err != nil { + t.Fatalf("CreateUser: %v", err) + } + if err := a.SetPassword("alice", "short"); err == nil { + t.Fatal("expected an error for a too-short password") + } +} + +func TestCheckAccessRespectsRoles(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()) + + if err := a.CreateRepo("alice/demo", "alice"); err != nil { + t.Fatalf("CreateRepo: %v", err) + } + + if _, ok, err := a.CheckAccess("alice/demo", "alice@local.test", store.RoleAdmin); err != nil || !ok { + t.Fatalf("owner CheckAccess admin: ok=%v err=%v", ok, err) + } + if _, ok, err := a.CheckAccess("alice/demo", "mallory@elsewhere", store.RoleRead); err != nil || ok { + t.Fatalf("stranger CheckAccess on private repo: ok=%v err=%v, want denied", ok, err) + } +} + +func validTestKey(t *testing.T) string { + t.Helper() + 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) + } + return string(gossh.MarshalAuthorizedKey(sshPub)) +}
internal/adminrpc/client.go
diff --git a/internal/adminrpc/client.go b/internal/adminrpc/client.go index 6b5991f..f4c3272 100644 --- a/internal/adminrpc/client.go +++ b/internal/adminrpc/client.go @@ -73,6 +73,10 @@ func (c *Client) AddUserKey(username, pubKeyAuthorized string) error { return err } +func (c *Client) RemoveUserKey(username, pubKeyAuthorized string) error { + return c.call(methodRemoveUserKey, userKeyArgs{Username: username, PubKey: pubKeyAuthorized}, nil) +} + func (c *Client) DeleteUser(username string) error { err := c.call(methodDeleteUser, nameArgs{Name: username}, nil) return err @@ -158,3 +162,45 @@ func (c *Client) ListRepoTags(name string) ([]string, error) { err := c.call(methodListRepoTags, nameArgs{Name: name}, &out) return out.Tags, err } + +func (c *Client) GetUser(username string) (store.User, error) { + var out userResult + err := c.call(methodGetUser, nameArgs{Name: username}, &out) + return out.User, err +} + +func (c *Client) SetUserAdmin(username string, isAdmin bool) error { + return c.call(methodSetUserAdmin, setAdminArgs{Username: username, IsAdmin: isAdmin}, nil) +} + +func (c *Client) SetPassword(username, newPassword string) error { + return c.call(methodSetPassword, setPasswordArgs{Username: username, Password: newPassword}, nil) +} + +func (c *Client) VerifyPassword(username, password string) (bool, bool, error) { + var out verifyPasswordResult + err := c.call(methodVerifyPassword, setPasswordArgs{Username: username, Password: password}, &out) + return out.IsAdmin, out.OK, err +} + +func (c *Client) CreateSession(principal, username string, isAdmin bool) (string, error) { + var out createSessionResult + err := c.call(methodCreateSession, createSessionArgs{Principal: principal, Username: username, IsAdmin: isAdmin}, &out) + return out.Token, err +} + +func (c *Client) GetSession(token string) (store.Session, error) { + var out sessionResult + err := c.call(methodGetSession, tokenArgs{Token: token}, &out) + return out.Session, err +} + +func (c *Client) DeleteSession(token string) error { + return c.call(methodDeleteSession, tokenArgs{Token: token}, nil) +} + +func (c *Client) CheckAccess(repoName, principal string, want store.Role) (store.Role, bool, error) { + var out checkAccessResult + err := c.call(methodCheckAccess, checkAccessArgs{Repo: repoName, Principal: principal, Want: want}, &out) + return out.Role, out.OK, err +}
internal/adminrpc/protocol.go
diff --git a/internal/adminrpc/protocol.go b/internal/adminrpc/protocol.go index b1990e2..9bf3e1a 100644 --- a/internal/adminrpc/protocol.go +++ b/internal/adminrpc/protocol.go @@ -14,6 +14,7 @@ const ( methodListUsers = "ListUsers" methodCreateUser = "CreateUser" methodAddUserKey = "AddUserKey" + methodRemoveUserKey = "RemoveUserKey" methodDeleteUser = "DeleteUser" methodListRepos = "ListRepos" methodGetRepo = "GetRepo" @@ -30,6 +31,14 @@ const ( methodGetRepoReadme = "GetRepoReadme" methodGetRepoLicense = "GetRepoLicense" methodListRepoTags = "ListRepoTags" + methodGetUser = "GetUser" + methodSetUserAdmin = "SetUserAdmin" + methodSetPassword = "SetPassword" + methodVerifyPassword = "VerifyPassword" + methodCreateSession = "CreateSession" + methodGetSession = "GetSession" + methodDeleteSession = "DeleteSession" + methodCheckAccess = "CheckAccess" ) // request is the envelope sent by the client for every call. @@ -112,3 +121,51 @@ type licenseResult struct { type listTagsResult struct { Tags []string `json:"tags"` } + +type userResult struct { + User store.User `json:"user"` +} + +type setAdminArgs struct { + Username string `json:"username"` + IsAdmin bool `json:"is_admin"` +} + +type setPasswordArgs struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type verifyPasswordResult struct { + IsAdmin bool `json:"is_admin"` + OK bool `json:"ok"` +} + +type createSessionArgs struct { + Principal string `json:"principal"` + Username string `json:"username"` + IsAdmin bool `json:"is_admin"` +} + +type createSessionResult struct { + Token string `json:"token"` +} + +type tokenArgs struct { + Token string `json:"token"` +} + +type sessionResult struct { + Session store.Session `json:"session"` +} + +type checkAccessArgs struct { + Repo string `json:"repo"` + Principal string `json:"principal"` + Want store.Role `json:"want"` +} + +type checkAccessResult struct { + Role store.Role `json:"role"` + OK bool `json:"ok"` +}
internal/adminrpc/server.go
diff --git a/internal/adminrpc/server.go b/internal/adminrpc/server.go index 9538746..8badfac 100644 --- a/internal/adminrpc/server.go +++ b/internal/adminrpc/server.go @@ -84,6 +84,13 @@ func (s *Server) dispatch(req wireRequest) (any, error) { } return nil, s.ops.AddUserKey(a.Username, a.PubKey) + case methodRemoveUserKey: + var a userKeyArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + return nil, s.ops.RemoveUserKey(a.Username, a.PubKey) + case methodDeleteUser: var a nameArgs if err := json.Unmarshal(req.Args, &a); err != nil { @@ -194,6 +201,67 @@ func (s *Server) dispatch(req wireRequest) (any, error) { tags, err := s.ops.ListRepoTags(a.Name) return listTagsResult{Tags: tags}, err + case methodGetUser: + var a nameArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + user, err := s.ops.GetUser(a.Name) + return userResult{User: user}, err + + case methodSetUserAdmin: + var a setAdminArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + return nil, s.ops.SetUserAdmin(a.Username, a.IsAdmin) + + case methodSetPassword: + var a setPasswordArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + return nil, s.ops.SetPassword(a.Username, a.Password) + + case methodVerifyPassword: + var a setPasswordArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + isAdmin, ok, err := s.ops.VerifyPassword(a.Username, a.Password) + return verifyPasswordResult{IsAdmin: isAdmin, OK: ok}, err + + case methodCreateSession: + var a createSessionArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + token, err := s.ops.CreateSession(a.Principal, a.Username, a.IsAdmin) + return createSessionResult{Token: token}, err + + case methodGetSession: + var a tokenArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + sess, err := s.ops.GetSession(a.Token) + return sessionResult{Session: sess}, err + + case methodDeleteSession: + var a tokenArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + return nil, s.ops.DeleteSession(a.Token) + + case methodCheckAccess: + var a checkAccessArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + role, ok, err := s.ops.CheckAccess(a.Repo, a.Principal, a.Want) + return checkAccessResult{Role: role, OK: ok}, err + default: return nil, fmt.Errorf("adminrpc: unknown method %q", req.Method) }
internal/store/auth.go
diff --git a/internal/store/auth.go b/internal/store/auth.go new file mode 100644 index 0000000..d0a8a9f --- /dev/null +++ b/internal/store/auth.go @@ -0,0 +1,34 @@ +package store + +import ( + bolt "go.etcd.io/bbolt" +) + +// credential holds a user's password hash, kept in its own bucket so it +// never travels with a store.User value (see the comment on User). +type credential struct { + PasswordHash string `json:"password_hash"` +} + +func (s *Store) SetPasswordHash(username, hash string) error { + return s.db.Update(func(tx *bolt.Tx) error { + if tx.Bucket(bucketUsers).Get([]byte(username)) == nil { + return ErrNotFound + } + return putJSON(tx, bucketAuth, username, credential{PasswordHash: hash}) + }) +} + +func (s *Store) GetPasswordHash(username string) (string, error) { + var c credential + err := s.db.View(func(tx *bolt.Tx) error { + return getJSON(tx, bucketAuth, username, &c) + }) + return c.PasswordHash, err +} + +func (s *Store) DeletePasswordHash(username string) error { + return s.db.Update(func(tx *bolt.Tx) error { + return deleteKey(tx, bucketAuth, username) + }) +}
internal/store/auth_test.go
diff --git a/internal/store/auth_test.go b/internal/store/auth_test.go new file mode 100644 index 0000000..086f8d1 --- /dev/null +++ b/internal/store/auth_test.go @@ -0,0 +1,80 @@ +package store + +import ( + "path/filepath" + "testing" + "time" +) + +func TestPasswordHashRequiresExistingUser(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "gitfed.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + defer s.Close() + + if err := s.SetPasswordHash("nobody", "hash"); err != ErrNotFound { + t.Fatalf("SetPasswordHash for nonexistent user: got %v, want ErrNotFound", err) + } + + if err := s.CreateUser(User{Username: "alice"}); err != nil { + t.Fatalf("create user: %v", err) + } + if err := s.SetPasswordHash("alice", "hash123"); err != nil { + t.Fatalf("SetPasswordHash: %v", err) + } + got, err := s.GetPasswordHash("alice") + if err != nil { + t.Fatalf("GetPasswordHash: %v", err) + } + if got != "hash123" { + t.Fatalf("got hash %q, want %q", got, "hash123") + } +} + +func TestSessionLifecycle(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "gitfed.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + defer s.Close() + + token, err := s.CreateSession("alice@local.test", "alice", true, time.Hour) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + if token == "" { + t.Fatal("expected a non-empty token") + } + + sess, err := s.GetSession(token) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if sess.Principal != "alice@local.test" || !sess.IsAdmin { + t.Fatalf("unexpected session: %+v", sess) + } + + if err := s.DeleteSession(token); err != nil { + t.Fatalf("DeleteSession: %v", err) + } + if _, err := s.GetSession(token); err != ErrNotFound { + t.Fatalf("GetSession after delete: got %v, want ErrNotFound", err) + } +} + +func TestSessionExpiry(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "gitfed.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + defer s.Close() + + token, err := s.CreateSession("alice@local.test", "alice", false, -time.Second) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + if _, err := s.GetSession(token); err != ErrNotFound { + t.Fatalf("GetSession on expired token: got %v, want ErrNotFound", err) + } +}
internal/store/sessions.go
diff --git a/internal/store/sessions.go b/internal/store/sessions.go new file mode 100644 index 0000000..36dfe08 --- /dev/null +++ b/internal/store/sessions.go @@ -0,0 +1,72 @@ +package store + +import ( + "crypto/rand" + "encoding/base64" + "time" + + bolt "go.etcd.io/bbolt" +) + +// Session backs web login: an opaque bearer token (held in a cookie) maps +// to this record. Nothing about it is derivable from the token itself +// (unlike a signed JWT), so deleting the record here is a real, immediate +// logout/revocation — there's no separate secret to rotate. +type Session struct { + Token string `json:"token"` + Principal string `json:"principal"` // "<username>@<domain>" + Username string `json:"username"` + IsAdmin bool `json:"is_admin"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +// CreateSession mints a new random token and stores the session, valid for +// ttl. +func (s *Store) CreateSession(principal, username string, isAdmin bool, ttl time.Duration) (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + token := base64.RawURLEncoding.EncodeToString(buf) + + now := time.Now().UTC() + sess := Session{ + Token: token, + Principal: principal, + Username: username, + IsAdmin: isAdmin, + CreatedAt: now, + ExpiresAt: now.Add(ttl), + } + err := s.db.Update(func(tx *bolt.Tx) error { + return putJSON(tx, bucketSessions, token, sess) + }) + if err != nil { + return "", err + } + return token, nil +} + +// GetSession looks up a session by token. An expired session is treated +// (and lazily deleted) as not found. +func (s *Store) GetSession(token string) (Session, error) { + var sess Session + err := s.db.Update(func(tx *bolt.Tx) error { + if err := getJSON(tx, bucketSessions, token, &sess); err != nil { + return err + } + if time.Now().After(sess.ExpiresAt) { + _ = deleteKey(tx, bucketSessions, token) + return ErrNotFound + } + return nil + }) + return sess, err +} + +func (s *Store) DeleteSession(token string) error { + return s.db.Update(func(tx *bolt.Tx) error { + return deleteKey(tx, bucketSessions, token) + }) +}
internal/store/store.go
diff --git a/internal/store/store.go b/internal/store/store.go index bafb388..9427f5f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -14,12 +14,14 @@ import ( var ErrNotFound = errors.New("store: not found") var ( - bucketUsers = []byte("users") - bucketRepos = []byte("repos") - bucketACL = []byte("acl") - bucketTrust = []byte("trust") - bucketMeta = []byte("meta") - bucketAudit = []byte("audit") + bucketUsers = []byte("users") + bucketRepos = []byte("repos") + bucketACL = []byte("acl") + bucketTrust = []byte("trust") + bucketMeta = []byte("meta") + bucketAudit = []byte("audit") + bucketAuth = []byte("auth") // username -> password hash, kept apart from User so it's never returned by ListUsers/GetUser + bucketSessions = []byte("sessions") // token -> Session, for web login ) type Store struct { @@ -34,7 +36,7 @@ func Open(path string) (*Store, error) { return nil, fmt.Errorf("store: open %s: %w", path, err) } err = db.Update(func(tx *bolt.Tx) error { - for _, b := range [][]byte{bucketUsers, bucketRepos, bucketACL, bucketTrust, bucketMeta, bucketAudit} { + for _, b := range [][]byte{bucketUsers, bucketRepos, bucketACL, bucketTrust, bucketMeta, bucketAudit, bucketAuth, bucketSessions} { if _, err := tx.CreateBucketIfNotExists(b); err != nil { return err }
internal/store/users.go
diff --git a/internal/store/users.go b/internal/store/users.go index b9fc34b..4b88a99 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -7,10 +7,14 @@ import ( bolt "go.etcd.io/bbolt" ) -// User is a local account on this instance. +// User is a local account on this instance. Deliberately has no password +// field — credentials live in a separate bucket (see auth.go) so a User +// value is always safe to hand back from ListUsers/GetUser without risking +// leaking a hash over the admin RPC wire. type User struct { Username string `json:"username"` PubKeys []string `json:"pub_keys"` // authorized_keys-format entries + IsAdmin bool `json:"is_admin"` CreatedAt time.Time `json:"created_at"` } @@ -68,6 +72,40 @@ func (s *Store) AddUserKey(username, pubKey string) error { }) } +// RemoveUserKey removes a public key from the user. Removing every key +// would lock the user out of SSH entirely, so it refuses to remove the +// last one. +func (s *Store) RemoveUserKey(username, pubKey string) error { + return s.db.Update(func(tx *bolt.Tx) error { + var u User + if err := getJSON(tx, bucketUsers, username, &u); err != nil { + return err + } + kept := u.PubKeys[:0] + for _, k := range u.PubKeys { + if k != pubKey { + kept = append(kept, k) + } + } + if len(kept) == 0 { + return fmt.Errorf("store: cannot remove %s's last key", username) + } + u.PubKeys = kept + return putJSON(tx, bucketUsers, username, u) + }) +} + +func (s *Store) SetUserAdmin(username string, isAdmin bool) error { + return s.db.Update(func(tx *bolt.Tx) error { + var u User + if err := getJSON(tx, bucketUsers, username, &u); err != nil { + return err + } + u.IsAdmin = isAdmin + return putJSON(tx, bucketUsers, username, u) + }) +} + // FindUserByKey returns the user owning the given authorized_keys-format key. func (s *Store) FindUserByKey(pubKey string) (User, error) { var found User