Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Move writes to SSH, drop web authentication, keep identity in git

a5f19b3c19861e080953096f934df25dc38beaf7 · Keenan Nemetz · 18d ago · parent 653e0cfc

Move writes to SSH, drop web authentication, keep identity in git

The authenticated-HTTP design is gone rather than extended. Writes now require
an SSH key, the web is read-only, and the user roster lives in git.

What replaced what:

- internal/sshserver is the only write transport. An embedded SSH server
  (golang.org/x/crypto/ssh) authenticates by public key and hands the principal
  to pre-receive through the environment, so the hook's policy code is unchanged.
  The exec surface is whitelisted to git-upload-pack and git-receive-pack, the
  repository argument is resolved through the store rather than concatenated, and
  everything else -- including `sh -c` -- is refused and asserted refused in a
  test.
- internal/identity stores users on refs/heads/main in data/system.git. The
  directory listing is the table: users/<name>/keys/<sha>.pub. Writes are CAS
  commits, reads come from an in-memory snapshot so the SSH auth callback is a
  map lookup. Nothing stored is a secret, so a clone of system.git is a complete
  backup and git log on it is the audit trail.
- internal/transport is now anonymous read-only: upload-pack behind a visibility
  check, 404 for private repositories, and no push path at all.
- internal/web has no login, no sessions and no mutations.

Deleted outright: internal/auth, the Basic-auth path, login/logout, web
settings/rename/delete, chunked body spooling, the spool janitor, the push
semaphore and the hand-rolled byte cap. The cap is now git's receive.maxInputSize
per repo, which enforces it on every transport without buffering.

Three things worth recording:

- The CAS helper read the tree outside the retry loop and compared against a
  freshly-read old value, so a concurrent writer's commit could be silently
  discarded while the CAS "succeeded". A test with two stores adding eight keys
  at once found it losing half of them; read, mutate and compare now all happen
  inside the loop.
- The roster moved from refs/vole/users to refs/heads/main because `git clone`
  only mirrors refs/heads/*: the documented backup command produced an instance
  with zero users. Storing on a branch also means git itself refuses to point the
  ref at a non-commit.
- Exec refusals are answered on the channel rather than by rejecting the request.
  Rejecting made the client print "exec request failed on channel 0", so the
  reason was unreadable.

Verified by hand as well as by test: init -> user -> add-key -> repo -> serve,
push over SSH, anonymous HTTP clone, web browse, a private repo answering 404 on
both anonymous surfaces while still cloning over SSH, a refused `sh -c` that
prints why and executes nothing, and a plain clone of system.git yielding the
whole roster with no side database present.

internal/server/server.go +99−103

@@ -1,179 +1,175 @@
1 // Package server wires the components together and owns the HTTP surface.
1 // Package server wires the components together and owns the listeners.
2 2 package server
3 3
4 4 import (
5 "context"
6 "errors"
5 7 "fmt"
6 8 "log/slog"
9 "net"
7 10 "net/http"
8 11 "os"
12 "strconv"
13 "time"
9 14
10 "github.com/rodent-software/vole/internal/auth"
11 15 "github.com/rodent-software/vole/internal/config"
16 "github.com/rodent-software/vole/internal/identity"
12 17 "github.com/rodent-software/vole/internal/index"
13 18 "github.com/rodent-software/vole/internal/repo"
14 19 "github.com/rodent-software/vole/internal/signing"
20 "github.com/rodent-software/vole/internal/sshserver"
15 21 "github.com/rodent-software/vole/internal/transport"
16 22 "github.com/rodent-software/vole/internal/web"
17 23 )
18 24
19 25 type Server struct {
20 Cfg *config.Config
21 Repos *repo.Store
22 Auth *auth.Store
23 Index *index.Store
24 Proxy *transport.Proxy
25 Key *signing.Key
26 Web *web.Web
27 Log *slog.Logger
28 mux *http.ServeMux
26 Cfg *config.Config
27 Repos *repo.Store
28 Identity *identity.Store
29 Index *index.Store
30 Proxy *transport.Proxy
31 SSH *sshserver.Server
32 Key *signing.Key
33 Web *web.Web
34 Log *slog.Logger
35 mux *http.ServeMux
29 36 }
30 37
31 38 func New(cfg *config.Config, binPath string, log *slog.Logger) (*Server, error) {
32 39 if log == nil {
33 40 log = slog.Default()
34 41 }
35 for _, d := range []string{cfg.ReposDir(), cfg.SpoolDir()} {
36 if err := os.MkdirAll(d, 0o755); err != nil {
37 return nil, err
38 }
42 if err := os.MkdirAll(cfg.ReposDir(), 0o755); err != nil {
43 return nil, err
39 44 }
40 as, err := auth.Open(cfg.AuthDB())
45 ident, err := identity.Open(cfg.SystemRepo())
41 46 if err != nil {
42 return nil, fmt.Errorf("open auth db: %w", err)
43 }
44 if err := as.PruneSessions(); err != nil {
45 log.Warn("prune sessions", "err", err)
47 return nil, fmt.Errorf("%w\nrun `vole init` to create the instance", err)
46 48 }
47 49 ix, err := index.Open(cfg.IndexDB())
48 50 if err != nil {
49 as.Close()
50 51 return nil, fmt.Errorf("open index: %w", err)
51 52 }
52 53 rs := repo.New(cfg.ReposDir(), binPath)
53 key, err := signing.LoadOrCreate(cfg.InstanceKey())
54 if err != nil {
55 return nil, fmt.Errorf("instance key: %w", err)
56 }
54 rs.MaxInputBytes = cfg.ReceiveMaxBytes
57 55 if n, err := rs.InstallAllHooks(); err != nil {
58 56 log.Warn("install hooks", "err", err)
59 } else {
57 } else if n > 0 {
60 58 log.Info("hooks installed", "repos", n)
61 59 }
60 key, err := signing.LoadOrCreate(cfg.InstanceKey())
61 if err != nil {
62 return nil, fmt.Errorf("instance key: %w", err)
63 }
62 64
63 65 proxy := transport.New(transport.Config{
64 Root: cfg.ReposDir(),
65 Open: rs.Open,
66 Actor: actorResolver(as),
67 MaxPushBytes: cfg.MaxPushBytes,
68 MaxBlobBytes: cfg.MaxBlobBytes,
69 SpoolDir: cfg.SpoolDir(),
70 PushConcurrency: cfg.PushConcurrency,
71 QueueTimeout: cfg.QueueTimeoutDur,
72 Log: log,
66 Root: cfg.ReposDir(),
67 Open: rs.Open,
68 Log: log,
73 69 })
74
75 wp, err := web.New(rs, as, ix, cfg)
70 ssh, err := sshserver.New(sshserver.Config{
71 Identity: ident,
72 Repos: rs,
73 Log: log,
74 HostKeyPath: cfg.SSHHostKey(),
75 MaxBlobBytes: cfg.MaxBlobBytes,
76 })
77 if err != nil {
78 return nil, fmt.Errorf("ssh: %w", err)
79 }
80 wp, err := web.New(rs, ix, cfg)
76 81 if err != nil {
77 82 return nil, err
78 83 }
79 84 wp.Logger = func(format string, args ...any) { log.Warn(fmt.Sprintf(format, args...)) }
80 85
81 s := &Server{Cfg: cfg, Repos: rs, Auth: as, Index: ix, Proxy: proxy, Key: key, Web: wp, Log: log}
86 s := &Server{
87 Cfg: cfg, Repos: rs, Identity: ident, Index: ix,
88 Proxy: proxy, SSH: ssh, Key: key, Web: wp, Log: log,
89 }
82 90 s.mux = s.routes()
83 91 return s, nil
84 92 }
85 93
86 // actorResolver turns a Basic-auth pair into a username. A token may arrive as
87 // the password (the usual case) or as the username with an empty password,
88 // which some clients do.
89 func actorResolver(as *auth.Store) func(user, pass string) (string, error) {
90 return func(user, pass string) (string, error) {
91 switch {
92 case len(user) > 5 && user[:5] == "vole_" && pass == "":
93 u, _, err := as.AuthenticateToken(user)
94 if err != nil {
95 return "", err
96 }
97 return u.Username, nil
98 case len(pass) > 5 && pass[:5] == "vole_":
99 u, _, err := as.AuthenticateToken(pass)
100 if err != nil {
101 return "", err
102 }
103 return u.Username, nil
104 default:
105 u, err := as.Authenticate(user, pass)
106 if err != nil {
107 return "", err
108 }
109 return u.Username, nil
110 }
111 }
112 }
113
114 94 func (s *Server) routes() *http.ServeMux {
115 95 mux := http.NewServeMux()
116 96 w := s.Web
117 97
118 98 mux.HandleFunc("GET /{$}", w.IndexPage)
119 mux.HandleFunc("GET /login", w.LoginPage)
120 mux.HandleFunc("POST /login", w.Login)
121 mux.HandleFunc("POST /logout", w.Logout)
122
123 mux.HandleFunc("POST /r/new", w.NewRepo)
124 99 mux.HandleFunc("GET /r/{name}", w.Repo)
125 100 mux.HandleFunc("GET /r/{name}/tree/{ref}/{path...}", w.Tree)
126 101 mux.HandleFunc("GET /r/{name}/blob/{ref}/{path...}", w.Blob)
127 102 mux.HandleFunc("GET /r/{name}/raw/{ref}/{path...}", w.Raw)
128 103 mux.HandleFunc("GET /r/{name}/commits/{ref}", w.Commits)
129 mux.HandleFunc("GET /r/{name}/settings", w.Settings)
130 mux.HandleFunc("POST /r/{name}/acl", w.UpdateACL)
131 mux.HandleFunc("POST /r/{name}/rename", w.RenameRepo)
132 mux.HandleFunc("POST /r/{name}/delete", w.DeleteRepo)
133 104
134 105 mux.HandleFunc("GET /healthz", func(rw http.ResponseWriter, r *http.Request) {
135 106 rw.Header().Set("Content-Type", "text/plain")
136 fmt.Fprintf(rw, "ok\nkey=%s\n", s.Key.Fingerprint())
107 fmt.Fprintf(rw, "ok\nkey=%s\nusers=%d\n", s.Key.Fingerprint(), len(s.Identity.Snapshot().Users))
137 108 })
138 109
139 // Git transport. Registered without a method so git's POSTs reach it.
110 // Anonymous, read-only git. Registered without a method so git's POSTs reach it.
140 111 mux.Handle("/git/", s.Proxy)
141
142 112 return mux
143 113 }
144 114
145 func (s *Server) Handler() http.Handler {
146 return logging(s.Log, s.mux)
147 }
115 func (s *Server) Handler() http.Handler { return s.mux }
148 116
149 func (s *Server) Close() error {
150 var first error
151 if s.Auth != nil {
152 if err := s.Auth.Close(); err != nil {
153 first = err
117 // Run starts both listeners and blocks until ctx is cancelled.
118 func (s *Server) Run(ctx context.Context) error {
119 sshLn, err := net.Listen("tcp", s.Cfg.SSHAddr)
120 if err != nil {
121 return fmt.Errorf("ssh listen %s: %w", s.Cfg.SSHAddr, err)
122 }
123 httpLn, err := net.Listen("tcp", s.Cfg.HTTPAddr)
124 if err != nil {
125 sshLn.Close()
126 return fmt.Errorf("http listen %s: %w", s.Cfg.HTTPAddr, err)
127 }
128
129 hs := &http.Server{
130 Handler: s.Handler(),
131 ReadHeaderTimeout: 15 * time.Second,
132 IdleTimeout: 120 * time.Second,
133 }
134 errCh := make(chan error, 2)
135 go func() {
136 if err := s.SSH.Serve(sshLn); err != nil {
137 errCh <- fmt.Errorf("ssh serve: %w", err)
154 138 }
139 }()
140 go func() {
141 if err := hs.Serve(httpLn); err != nil && !errors.Is(err, http.ErrServerClosed) {
142 errCh <- fmt.Errorf("http serve: %w", err)
143 }
144 }()
145
146 s.Log.Info("listening", "ssh", sshLn.Addr().String(), "http", httpLn.Addr().String(),
147 "data", s.Cfg.DataDir, "users", len(s.Identity.Snapshot().Users))
148
149 select {
150 case <-ctx.Done():
151 sh, cancel := context.WithTimeout(context.Background(), 20*time.Second)
152 defer cancel()
153 s.SSH.Close()
154 hs.Shutdown(sh)
155 return nil
156 case err := <-errCh:
157 s.SSH.Close()
158 hs.Close()
159 return err
155 160 }
161 }
162
163 func (s *Server) Close() error {
156 164 if s.Index != nil {
157 if err := s.Index.Close(); err != nil && first == nil {
158 first = err
159 }
165 return s.Index.Close()
160 166 }
161 return first
167 return nil
162 168 }
163 169
164 // RebuildIndex reconstructs the derived index from the repo store.
165 170 func (s *Server) RebuildIndex() (int, error) { return s.Index.Rebuild(s.Repos) }
166 171
167 func logging(log *slog.Logger, next http.Handler) http.Handler {
168 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
169 // Git is chatty; only log the interesting requests.
170 if !isGitNoise(r) {
171 log.Debug("request", "method", r.Method, "path", r.URL.Path)
172 }
173 next.ServeHTTP(w, r)
174 })
175 }
176
177 func isGitNoise(r *http.Request) bool {
178 return len(r.URL.Path) > 5 && r.URL.Path[:5] == "/git/"
172 // MaxInputLabel is used by logging to show the effective push cap.
173 func (s *Server) MaxInputLabel() string {
174 return strconv.FormatInt(s.Cfg.ReceiveMaxBytes, 10)
179 175 }