feat(basin): configurable ssh user and deploy docs
6 files changed, +47 −12
- README.md modified +32 −1
- main.go modified +3 −2
- static/style.css modified +1 −1
- templates/keys.html modified +1 −1
- templates/repo.html modified +2 −2
- web.go modified +8 −5
README.md modified
| @@ -19,14 +19,45 @@ The data directory holds `forge.db` and a `repos/<user>/<name>.git` tree of bare | ||
| 19 | 19 | |
| 20 | 20 | Every read of git data shells out to the plumbing with a 30 second timeout rather than parsing packfiles in process. Tree listings use `ls-tree -l`, history uses `log` with a NUL separated format, and commit pages parse the unified output of `diff-tree -p --root -M` into hunks for rendering. This keeps the server stateless and lets large repositories inherit git's own performance, at the cost of one process spawn per page section. |
| 21 | 21 | |
| 22 | +## Deployment | |
| 23 | + | |
| 24 | +Nothing in the binary refers to a particular host. The public name comes from `-host`, the data directory from `-root`, the ssh account shown in clone urls from `-ssh-user` and the listen address from `-listen`, so a deployment is one static binary, one directory and a reverse proxy. A minimal unit for systemd runs it as the same unix user that sshd will hand git connections to: | |
| 25 | + | |
| 26 | +``` | |
| 27 | +[Service] | |
| 28 | +User=git | |
| 29 | +ExecStart=/usr/local/bin/forge -root /srv/forge serve -listen 127.0.0.1:3200 -host git.example.org | |
| 30 | +Restart=always | |
| 31 | +ProtectSystem=strict | |
| 32 | +ReadWritePaths=/srv/forge | |
| 33 | +``` | |
| 34 | + | |
| 35 | +Put nginx or caddy in front with TLS, forwarding `Host`, `X-Forwarded-For` and `X-Forwarded-Proto`, and raise the body size limit so large pushes over https get through. The address in `X-Forwarded-For` is what the login rate limiter keys on, so the proxy must set it rather than append to it. | |
| 36 | + | |
| 22 | 37 | ## SSH |
| 23 | 38 | |
| 24 | -Pushes over ssh reuse the host sshd. Point `AuthorizedKeysCommand` for the `git` user at `forge -root /srv/forge keys %u %h %t %k`, which looks the presented key up by SHA256 fingerprint and answers with a forced command of `forge shell <user>`. That forced command reads `SSH_ORIGINAL_COMMAND`, checks ownership and visibility against the database, and execs `git upload-pack` or `git receive-pack` directly, so nothing stays resident between connections. The lookup opens the database read only and runs as root, while the forced command runs as the `git` user who owns the repositories. | |
| 39 | +Pushes over ssh reuse the host sshd. Add a block such as the following, where the key lookup runs as root and the forced command runs as the login user: | |
| 40 | + | |
| 41 | +``` | |
| 42 | +Match User git | |
| 43 | + AuthenticationMethods publickey | |
| 44 | + AuthorizedKeysFile none | |
| 45 | + AuthorizedKeysCommand /usr/local/bin/forge -root /srv/forge keys %u %h %t %k | |
| 46 | + AuthorizedKeysCommandUser root | |
| 47 | + PermitTTY no | |
| 48 | + DisableForwarding yes | |
| 49 | +``` | |
| 50 | + | |
| 51 | +The lookup command finds the presented key by SHA256 fingerprint and answers with a forced command of `forge shell <user>`. That forced command reads `SSH_ORIGINAL_COMMAND`, checks ownership and visibility against the database, and execs `git upload-pack` or `git receive-pack` directly, so nothing stays resident between connections. The lookup opens the database read only and runs as root, while the forced command runs as the `git` user who owns the repositories. | |
| 25 | 52 | |
| 26 | 53 | ## HTTPS |
| 27 | 54 | |
| 28 | 55 | Anonymous clients may fetch public repositories. Private repositories and all pushes require basic auth with either the account password or an access token created in settings. Token hashes are stored as SHA256, passwords as bcrypt. The web forms rely on the standard library cross origin protection, so a POST without a same origin `Sec-Fetch-Site` or `Origin` header from a browser is refused while non browser git clients pass. |
| 29 | 56 | |
| 57 | +## Signatures | |
| 58 | + | |
| 59 | +Commits signed with ssh keys (`git config gpg.format ssh`) are verified against an allowed-signers file that the server regenerates from every registered key whenever a key is added or removed. The principal pattern is a wildcard, so the committer email does not have to match anything, and the fingerprint git reports is mapped back to the account that registered the key, which is what the `Verified ~user` badge on log, commit and pull request pages means. A valid signature from a key nobody registered shows as unverified. | |
| 60 | + | |
| 30 | 61 | ## Profiles |
| 31 | 62 | |
| 32 | 63 | Each user has a bio, location, workplace, website and an avatar. Avatar uploads are decoded on the server, cropped to the square the settings page chose with a small canvas script, resampled to 256 pixels and stored as PNG, so the served image never contains the original file's metadata or dimensions. Without JavaScript the crop falls back to the centred square. |
main.go modified
| @@ -15,7 +15,7 @@ func main() { | ||
| 15 | 15 | flag.Usage = func() { |
| 16 | 16 | fmt.Fprintln(os.Stderr, `usage: forge [-root dir] command |
| 17 | 17 | |
| 18 | - serve [-listen addr] [-host name] [-open] run the web server | |
| 18 | + serve [-listen addr] [-host name] [-open] [-ssh-user name] run the web server | |
| 19 | 19 | keys user home type key sshd AuthorizedKeysCommand |
| 20 | 20 | shell user forced command for ssh git access |
| 21 | 21 | invite print a single use registration code |
| @@ -77,11 +77,12 @@ func serve(root string, args []string) { | ||
| 77 | 77 | listen := fs.String("listen", "127.0.0.1:3200", "address to listen on") |
| 78 | 78 | host := fs.String("host", "localhost", "public hostname used in clone urls") |
| 79 | 79 | open := fs.Bool("open", false, "allow registration without an invite") |
| 80 | + sshUser := fs.String("ssh-user", "git", "unix user that serves git over ssh, shown in clone urls") | |
| 80 | 81 | fs.Parse(args) |
| 81 | 82 | store := mustStore(root) |
| 82 | 83 | srv := &http.Server{ |
| 83 | 84 | Addr: *listen, |
| 84 | - Handler: newServer(store, root, *host, *open).handler(), | |
| 85 | + Handler: newServer(store, root, *host, *open, *sshUser).handler(), | |
| 85 | 86 | ReadHeaderTimeout: 10 * time.Second, |
| 86 | 87 | } |
| 87 | 88 | log.Printf("forge listening on %s for %s", *listen, *host) |
static/style.css modified
| @@ -140,7 +140,7 @@ tr:target td{background:#fff8c5} | ||
| 140 | 140 | .code .lnlinks{text-decoration:none} |
| 141 | 141 | .code .lnlinks:hover{text-decoration:underline} |
| 142 | 142 | .code .lnt,.code .ln{color:var(--link);margin-right:0;padding:0 .5rem 0 .6rem} |
| 143 | -.code .lntd a{color:var(--link);text-decoration:underline} | |
| 143 | +.code .lntd a{color:var(--link);text-decoration:none} | |
| 144 | 144 | .code .lntd:last-child{padding-left:.6rem} |
| 145 | 145 | .code .line:target,.code .line:target .cl{background:#fff8c5} |
| 146 | 146 | table.hunk{width:100%;border:1px solid var(--line)} |
templates/keys.html modified
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | <form method="post" action="/settings/keys" class="stack"> |
| 5 | 5 | <label>SSH public key <textarea name="key" rows="3" required placeholder="ssh-ed25519 AAAA..."></textarea></label> |
| 6 | 6 | <label>Title <input name="title" placeholder="Defaults to the key comment"></label> |
| 7 | -<small>Push with <code>git@{{.Host}}:~{{.User.Name}}/repo</code> once the key is registered.</small> | |
| 7 | +<small>Push with <code>{{.SSHUser}}@{{.Host}}:~{{.User.Name}}/repo</code> once the key is registered.</small> | |
| 8 | 8 | <div class="actions"><button class="btn primary">Add key ▸</button></div> |
| 9 | 9 | </form></div> |
| 10 | 10 | {{end}} |
templates/repo.html modified
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | {{$p := .Data.Page}} |
| 3 | 3 | {{if $p.Empty}} |
| 4 | 4 | <p>This repository is empty. Push something to it:</p> |
| 5 | -<pre>git remote add origin git@{{.Host}}:{{$p.Repo.Path}} | |
| 5 | +<pre>git remote add origin {{.SSHUser}}@{{.Host}}:{{$p.Repo.Path}} | |
| 6 | 6 | git push -u origin {{$p.Ref}}</pre> |
| 7 | 7 | {{else}} |
| 8 | 8 | <div class="summary"> |
| @@ -19,7 +19,7 @@ git push -u origin {{$p.Ref}}</pre> | ||
| 19 | 19 | <strong>read-only</strong> |
| 20 | 20 | <div><a href="https://{{.Host}}/{{$p.Repo.Path}}">https://{{.Host}}/{{$p.Repo.Path}}</a></div> |
| 21 | 21 | {{if $p.Owner}}<strong>read/write</strong> |
| 22 | -<div>git@{{.Host}}:{{$p.Repo.Path}}</div>{{end}} | |
| 22 | +<div>{{.SSHUser}}@{{.Host}}:{{$p.Repo.Path}}</div>{{end}} | |
| 23 | 23 | </div> |
| 24 | 24 | </div> |
| 25 | 25 | <div class="readme"> |
web.go modified
| @@ -37,6 +37,7 @@ type Server struct { | ||
| 37 | 37 | store *Store |
| 38 | 38 | root string |
| 39 | 39 | host string |
| 40 | + sshUser string | |
| 40 | 41 | open bool |
| 41 | 42 | pages map[string]*template.Template |
| 42 | 43 | markdown goldmark.Markdown |
| @@ -44,16 +45,17 @@ type Server struct { | ||
| 44 | 45 | } |
| 45 | 46 | |
| 46 | 47 | type page struct { |
| 47 | - Title string | |
| 48 | - User *User | |
| 49 | - Host string | |
| 48 | + Title string | |
| 49 | + User *User | |
| 50 | + Host string | |
| 51 | + SSHUser string | |
| 50 | 52 | Error string |
| 51 | 53 | Math bool |
| 52 | 54 | Data any |
| 53 | 55 | } |
| 54 | 56 | |
| 55 | -func newServer(store *Store, root, host string, open bool) *Server { | |
| 56 | - s := &Server{store: store, root: root, host: host, open: open, pages: map[string]*template.Template{}, limiter: newLimiter(10, 5*time.Minute)} | |
| 57 | +func newServer(store *Store, root, host string, open bool, sshUser string) *Server { | |
| 58 | + s := &Server{store: store, root: root, host: host, open: open, sshUser: sshUser, pages: map[string]*template.Template{}, limiter: newLimiter(10, 5*time.Minute)} | |
| 57 | 59 | funcs := template.FuncMap{ |
| 58 | 60 | "short": func(h string) string { |
| 59 | 61 | if len(h) > 10 { |
| @@ -92,6 +94,7 @@ func newServer(store *Store, root, host string, open bool) *Server { | ||
| 92 | 94 | |
| 93 | 95 | func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, p page) { |
| 94 | 96 | p.Host = s.host |
| 97 | + p.SSHUser = s.sshUser | |
| 95 | 98 | if p.User == nil { |
| 96 | 99 | p.User = s.current(r) |
| 97 | 100 | } |