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="/">← 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">← Repos</a></p>
-<h2>{{.Repo}} {{if .Public}}<span class="badge trusted">public</span>{{else}}<span class="badge pending">private</span>{{end}}</h2>
-
-<form class="card" method="post" action="/repos/settings">
- <strong>Visibility & topics</strong>
- <input type="hidden" name="repo" value="{{.Repo}}">
- <label><input type="checkbox" name="public" value="1" style="width:auto; display:inline-block;" {{if .Public}}checked{{end}}> Public (readable by any authenticated principal, per DESIGN.md §5.2)</label>
- <label>Topics (comma-separated)</label>
- <input name="topics" value="{{.TopicsCSV}}" placeholder="cli, tooling, go">
- <button type="submit">Save</button>
-</form>
-
-<p class="muted">Git tags: {{if .Tags}}{{range $i, $t := .Tags}}{{if $i}}, {{end}}<code>{{$t}}</code>{{end}}{{else}}none{{end}}{{if .Public}} · public repo browsing (README/license) is served by gitfed-site{{end}}</p>
-
-<table>
-<tr><th>Principal</th><th>Role</th><th></th></tr>
-<tr>
- <td>{{.Owner}}</td>
- <td>admin</td>
- <td class="muted">owner</td>
-</tr>
-{{range .Collaborators}}
-<tr>
- <td>{{.Principal}}</td>
- <td>{{.Role}}</td>
- <td>
- <form class="inline" method="post" action="/repos/acl/revoke">
- <input type="hidden" name="repo" value="{{$.Repo}}">
- <input type="hidden" name="principal" value="{{.Principal}}">
- <button class="danger" type="submit">Revoke</button>
- </form>
- </td>
-</tr>
-{{end}}
-</table>
-
-<form class="card" method="post" action="/repos/acl/grant">
- <strong>Grant collaborator</strong>
- <input type="hidden" name="repo" value="{{.Repo}}">
- <label>Principal</label>
- <input name="principal" required placeholder="bob@instanceb.example">
- <label>Role</label>
- <select name="role">
- <option value="read">read</option>
- <option value="write">write</option>
- <option value="admin">admin</option>
- </select>
- <button type="submit">Grant</button>
-</form>
-<p class="muted">Granting a collaborator on a domain not yet known to this instance triggers federation discovery of that domain's CA (DESIGN.md §5.2) — check <a href="/trust">Trust store</a> afterwards if it needs approval.</p>
-`))
-
-func (s *server) handleACL(w http.ResponseWriter, r *http.Request) {
- repoName := r.URL.Query().Get("repo")
- repo, err := s.ops.GetRepo(repoName)
- if err != nil {
- http.Error(w, err.Error(), http.StatusNotFound)
- return
- }
- acl, err := s.ops.GetACL(repoName)
- if err != nil && err != store.ErrNotFound {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- tags, err := s.ops.ListRepoTags(repoName)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
-
- var buf bytes.Buffer
- _ = aclTpl.Execute(&buf, struct {
- Repo string
- Owner string
- Public bool
- TopicsCSV string
- Tags []string
- Collaborators []store.Collaborator
- Flash template.HTML
- }{repoName, repo.Owner, repo.Public, strings.Join(repo.Topics, ", "), tags, acl.Collaborators, flash(r)})
- s.render(w, "ACL — "+repoName, "repos", template.HTML(buf.String()))
-}
-
-func (s *server) handleRepoSettings(w http.ResponseWriter, r *http.Request) {
- repo := r.FormValue("repo")
- back := "/repos/acl?repo=" + url.QueryEscape(repo)
-
- public := r.FormValue("public") == "1"
- if err := s.ops.SetRepoPublic(repo, public); err != nil {
- redirectWithMsg(w, r, back, err.Error(), true)
- return
- }
-
- var topics []string
- for _, t := range strings.Split(r.FormValue("topics"), ",") {
- if t = strings.TrimSpace(t); t != "" {
- topics = append(topics, t)
- }
- }
- if err := s.ops.SetRepoTopics(repo, topics); err != nil {
- redirectWithMsg(w, r, back, err.Error(), true)
- return
- }
-
- redirectWithMsg(w, r, back, "saved settings", false)
-}
-
-func (s *server) handleACLGrant(w http.ResponseWriter, r *http.Request) {
- repo := r.FormValue("repo")
- principal := r.FormValue("principal")
- role := store.Role(r.FormValue("role"))
- back := "/repos/acl?repo=" + url.QueryEscape(repo)
- if err := s.ops.GrantCollaborator(repo, principal, role); err != nil {
- redirectWithMsg(w, r, back, err.Error(), true)
- return
- }
- redirectWithMsg(w, r, back, "granted "+principal+" "+string(role), false)
-}
-
-func (s *server) handleACLRevoke(w http.ResponseWriter, r *http.Request) {
- repo := r.FormValue("repo")
- principal := r.FormValue("principal")
- back := "/repos/acl?repo=" + url.QueryEscape(repo)
- if err := s.ops.RevokeCollaborator(repo, principal); err != nil {
- redirectWithMsg(w, r, back, err.Error(), true)
- return
- }
- redirectWithMsg(w, r, back, "revoked "+principal, false)
-}
cmd/gitfed-web/handlers_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 & 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">← 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">← 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">← 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="/">← 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}}">← {{.Repo}}</a></p>
+<h1>{{.Repo}} settings</h1>
+
+<form class="card" method="post" action="/repo-settings/{{.Repo}}">
+ <strong>Visibility & 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 → 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