~gitpocket/gitpocket

1

feat(basin): configurable ssh user and deploy docs

commit
49e889a370f80c1bded8e0cb4f963b200551e5f9 browse tree
parent
01b3aeb2e07c85beb16397502eddb8f44bd1e7a0
author
james <j@cpl0.zip>
signature
none
date
2026-10-01 20:39 UTC

6 files changed, +47 −12

README.md modified

@@ -19,14 +19,45 @@ The data directory holds `forge.db` and a `repos/<user>/<name>.git` tree of bare
1919
2020 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.
2121
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+
2237 ## SSH
2338
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.
2552
2653 ## HTTPS
2754
2855 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.
2956
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+
3061 ## Profiles
3162
3263 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() {
1515 flag.Usage = func() {
1616 fmt.Fprintln(os.Stderr, `usage: forge [-root dir] command
1717
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
1919 keys user home type key sshd AuthorizedKeysCommand
2020 shell user forced command for ssh git access
2121 invite print a single use registration code
@@ -77,11 +77,12 @@ func serve(root string, args []string) {
7777 listen := fs.String("listen", "127.0.0.1:3200", "address to listen on")
7878 host := fs.String("host", "localhost", "public hostname used in clone urls")
7979 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")
8081 fs.Parse(args)
8182 store := mustStore(root)
8283 srv := &http.Server{
8384 Addr: *listen,
84- Handler: newServer(store, root, *host, *open).handler(),
85+ Handler: newServer(store, root, *host, *open, *sshUser).handler(),
8586 ReadHeaderTimeout: 10 * time.Second,
8687 }
8788 log.Printf("forge listening on %s for %s", *listen, *host)

static/style.css modified

@@ -140,7 +140,7 @@ tr:target td{background:#fff8c5}
140140 .code .lnlinks{text-decoration:none}
141141 .code .lnlinks:hover{text-decoration:underline}
142142 .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}
144144 .code .lntd:last-child{padding-left:.6rem}
145145 .code .line:target,.code .line:target .cl{background:#fff8c5}
146146 table.hunk{width:100%;border:1px solid var(--line)}

templates/keys.html modified

@@ -4,7 +4,7 @@
44 <form method="post" action="/settings/keys" class="stack">
55 <label>SSH public key <textarea name="key" rows="3" required placeholder="ssh-ed25519 AAAA..."></textarea></label>
66 <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>
88 <div class="actions"><button class="btn primary">Add key ▸</button></div>
99 </form></div>
1010 {{end}}

templates/repo.html modified

@@ -2,7 +2,7 @@
22 {{$p := .Data.Page}}
33 {{if $p.Empty}}
44 <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}}
66 git push -u origin {{$p.Ref}}</pre>
77 {{else}}
88 <div class="summary">
@@ -19,7 +19,7 @@ git push -u origin {{$p.Ref}}</pre>
1919 <strong>read-only</strong>
2020 <div><a href="https://{{.Host}}/{{$p.Repo.Path}}">https://{{.Host}}/{{$p.Repo.Path}}</a></div>
2121 {{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}}
2323 </div>
2424 </div>
2525 <div class="readme">

web.go modified

@@ -37,6 +37,7 @@ type Server struct {
3737 store *Store
3838 root string
3939 host string
40+ sshUser string
4041 open bool
4142 pages map[string]*template.Template
4243 markdown goldmark.Markdown
@@ -44,16 +45,17 @@ type Server struct {
4445 }
4546
4647 type page struct {
47- Title string
48- User *User
49- Host string
48+ Title string
49+ User *User
50+ Host string
51+ SSHUser string
5052 Error string
5153 Math bool
5254 Data any
5355 }
5456
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)}
5759 funcs := template.FuncMap{
5860 "short": func(h string) string {
5961 if len(h) > 10 {
@@ -92,6 +94,7 @@ func newServer(store *Store, root, host string, open bool) *Server {
9294
9395 func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, p page) {
9496 p.Host = s.host
97+ p.SSHUser = s.sshUser
9598 if p.User == nil {
9699 p.User = s.current(r)
97100 }