hookd: authenticate the hook socket !494
15 files changed, +321 −4
Layout: unified · split
.gitbay/wiki/Architecture/03-Deployment.org +1 −1
| @@ -16,7 +16,7 @@ statements in this document about the host rest on those files. | |||
| 16 | | 80/tcp | HTTP | gitbayd | on with ACME| none; ACME HTTP-01 and redirect only | =cmd/gitbayd/main.go= | | 16 | | 80/tcp | HTTP | gitbayd | on with ACME| none; ACME HTTP-01 and redirect only | =cmd/gitbayd/main.go= | |
| 17 | | 9418/tcp | git:// | gitbayd | off | none; public repositories only | =internal/gitd= | | 17 | | 9418/tcp | git:// | gitbayd | off | none; public repositories only | =internal/gitd= | |
| 18 | | 2222/tcp | SSH (operator) | host sshd | on | public key, no passwords, fail2ban | =deploy/cloud-init.yaml= | | 18 | | 2222/tcp | SSH (operator) | host sshd | on | public key, no passwords, fail2ban | =deploy/cloud-init.yaml= | |
| 19 | | =<root>/hook.sock= | Unix socket | gitbayd | on | filesystem permissions only | =internal/hookd/hookd.go= | | 19 | | =<root>/hook.sock= | Unix socket | gitbayd | on | mode 0600; peer uid must be the daemon's (Linux); per-push token | =internal/hookd/hookd.go= | |
| 20 | 20 | ||
| 21 | With =ssh.mode = system= the host's sshd serves port 22 instead and | 21 | With =ssh.mode = system= the host's sshd serves port 22 instead and |
| 22 | invokes =gitbayd authorized-keys= and =gitbayd shell= | 22 | invokes =gitbayd authorized-keys= and =gitbayd shell= |
.gitbay/wiki/Architecture/04-Trust-Boundaries.org +2 −2
| @@ -22,7 +22,7 @@ | |||
| 22 | | TB2 | Z0 → Z1 HTTPS | page requests, form posts, API calls, fetches, LFS | TLS; session cookie or bearer token; =checkOrigin= on posts; CSP and security headers (=internal/httpd/routes.go=); smart HTTP is fetch-only (=smart.go=) | | 22 | | TB2 | Z0 → Z1 HTTPS | page requests, form posts, API calls, fetches, LFS | TLS; session cookie or bearer token; =checkOrigin= on posts; CSP and security headers (=internal/httpd/routes.go=); smart HTTP is fetch-only (=smart.go=) | |
| 23 | | TB3 | identity → data | every command | =Dispatch= gates, then =resolveRepo= with =policy= predicates; unreadable repositories are indistinguishable from missing ones ([[file:05-Identity-and-Access.org][5]]) | | 23 | | TB3 | identity → data | every command | =Dispatch= gates, then =resolveRepo= with =policy= predicates; unreadable repositories are indistinguishable from missing ones ([[file:05-Identity-and-Access.org][5]]) | |
| 24 | | TB4 | Z1 → Z3 git | argv, repository path, stdin packs | argv built by code, never a shell; repository path from the database, not the request (=internal/gitutil=) | | 24 | | TB4 | Z1 → Z3 git | argv, repository path, stdin packs | argv built by code, never a shell; repository path from the database, not the request (=internal/gitutil=) | |
| 25 | | TB5 | Z3 → Z1 hook socket | ref updates, repository id, user id, key scope, commit objects | the daemon decides with =policy.CheckPush= and =sig.VerifyCommit= (=internal/hookd/hookd.go=). The socket trusts the ids in the request, so access to the socket is equivalent to acting as any user; it is reachable only through the =gitbay= user's filesystem | | 25 | | TB5 | Z3 → Z1 hook socket | ref updates, repository id, user id, key scope, push token, commit objects | the socket is mode 0600 and, on Linux, refuses a peer whose uid is not the daemon's; a request must carry the token sshd minted for its receive-pack (stored hashed in =push_tokens=) and name the same repository, account and scope. The daemon then decides with =policy.CheckPush= and =sig.VerifyCommit= (=internal/hookd/hookd.go=) | |
| 26 | | TB6 | Z4 ↔ Z1 runner channel | build claims (with secrets for trusted builds), logs, results | runner-scoped SSH key; claims limited to attached repositories; secrets only when the build is trusted (=internal/control/build.go=) | | 26 | | TB6 | Z4 ↔ Z1 runner channel | build claims (with secrets for trusted builds), logs, results | runner-scoped SSH key; claims limited to attached repositories; secrets only when the build is trusted (=internal/control/build.go=) | |
| 27 | | TB7 | Z5 → Z4 container | build steps, workspace, build home | rootless podman, operator-provisioned image, cgroup limits; the build home is shared per repository and the network is open (#255, #260) | | 27 | | TB7 | Z5 → Z4 container | build steps, workspace, build home | rootless podman, operator-provisioned image, cgroup limits; the build home is shared per repository and the network is open (#255, #260) | |
| 28 | | TB8 | Z1 → Z0 outbound | webhooks, mirrors, mail, push | address checks on user-supplied URLs; HMAC on webhooks; no redirects ([[file:03-Deployment.org][3]]) | | 28 | | TB8 | Z1 → Z0 outbound | webhooks, mirrors, mail, push | address checks on user-supplied URLs; HMAC on webhooks; no redirects ([[file:03-Deployment.org][3]]) | |
| @@ -55,7 +55,7 @@ above. | |||
| 55 | checks, archive and pull-mirror refusals and the owner's storage | 55 | checks, archive and pull-mirror refusals and the owner's storage |
| 56 | quota (=sshd.go=). TB3. | 56 | quota (=sshd.go=). TB3. |
| 57 | 2. =git receive-pack= runs with the hook socket path, repository id, | 57 | 2. =git receive-pack= runs with the hook socket path, repository id, |
| 58 | user id and key scope in its environment (=sshd.go=). TB4. | 58 | user id and key scope in its environment, and a push token (=sshd.go=). TB4. |
| 59 | 3. git runs =pre-receive=, which is =gitbayd hook pre-receive=. It | 59 | 3. git runs =pre-receive=, which is =gitbayd hook pre-receive=. It |
| 60 | reads the ref updates, computes ancestry in git's quarantine | 60 | reads the ref updates, computes ancestry in git's quarantine |
| 61 | environment and asks the daemon over the socket | 61 | environment and asks the daemon over the socket |
.gitbay/wiki/Architecture/10-Known-Gaps.org −1
| @@ -20,7 +20,6 @@ what the 2026-09-27 review found; remove a row when its issue closes. | |||
| 20 | | #274 | Backups | The local backup archive is not encrypted | medium | | 20 | | #274 | Backups | The local backup archive is not encrypted | medium | |
| 21 | | #275 | Audit | Refused writes are not audited; the audit table is writable by the daemon user | medium | | 21 | | #275 | Audit | Refused writes are not audited; the audit table is writable by the daemon user | medium | |
| 22 | | #298 | SSRF | =repo import --from= fetches without an address check | medium | | 22 | | #298 | SSRF | =repo import --from= fetches without an address check | medium | |
| 23 | | #282 | Hook socket | Anything that can open =hook.sock= can act as any user | medium | | ||
| 24 | | #297 | Credentials | A browser session can mint tokens and keys that outlive it | low | | 23 | | #297 | Credentials | A browser session can mint tokens and keys that outlive it | low | |
| 25 | 24 | ||
| 26 | * Questions an auditor will ask that have no answer yet | 25 | * Questions an auditor will ask that have no answer yet |
CHANGELOG.org +6
| @@ -48,6 +48,12 @@ must add =--scope full=. Existing tokens keep their scope. | |||
| 48 | syncs, and each records why (#279). | 48 | syncs, and each records why (#279). |
| 49 | - Webhook and mirror targets in 100.64.0.0/10 or on a multicast | 49 | - Webhook and mirror targets in 100.64.0.0/10 or on a multicast |
| 50 | address are refused, as private addresses are (#279). | 50 | address are refused, as private addresses are (#279). |
| 51 | - The hook socket is mode 0600 and, on Linux, refuses a peer with | ||
| 52 | another uid; each receive-pack gets its own token from sshd, stored | ||
| 53 | hashed (migration 0063), and the hook must present it before the | ||
| 54 | daemon acts. *Operators:* deploy with no push in flight, since a | ||
| 55 | receive-pack started by the old daemon has no token and its | ||
| 56 | post-receive will be refused by the new one (#282). | ||
| 51 | 57 | ||
| 52 | * v1.36.0 — 2026-09-23 | 58 | * v1.36.0 — 2026-09-23 |
| 53 | 59 | ||
cmd/gitbayd/hook.go +1
| @@ -172,6 +172,7 @@ func hookCmd() *cobra.Command { | |||
| 172 | RepoID: repoID, | 172 | RepoID: repoID, |
| 173 | UserID: userID, | 173 | UserID: userID, |
| 174 | Scope: os.Getenv(hookd.EnvScope), | 174 | Scope: os.Getenv(hookd.EnvScope), |
| 175 | Token: os.Getenv(hookd.EnvToken), | ||
| 175 | Updates: updates, | 176 | Updates: updates, |
| 176 | }, func(emit func(hookd.RawCommit) error) error { | 177 | }, func(emit func(hookd.RawCommit) error) error { |
| 177 | return streamIncomingCommits(updates, emit) | 178 | return streamIncomingCommits(updates, emit) |
internal/hookd/hookd.go +36
| @@ -36,6 +36,10 @@ const ( | |||
| 36 | EnvRepoID = "GITBAY_REPO_ID" | 36 | EnvRepoID = "GITBAY_REPO_ID" |
| 37 | EnvUserID = "GITBAY_USER_ID" | 37 | EnvUserID = "GITBAY_USER_ID" |
| 38 | EnvScope = "GITBAY_KEY_SCOPE" | 38 | EnvScope = "GITBAY_KEY_SCOPE" |
| 39 | // EnvToken names the receive-pack this hook runs under. sshd mints | ||
| 40 | // it per push; hookd answers only a request carrying a live one | ||
| 41 | // whose repository, account and scope match the request's. | ||
| 42 | EnvToken = "GITBAY_PUSH_TOKEN" | ||
| 39 | ) | 43 | ) |
| 40 | 44 | ||
| 41 | type Request struct { | 45 | type Request struct { |
| @@ -46,6 +50,7 @@ type Request struct { | |||
| 46 | // the key belongs to, and a deploy key grants nothing outside its | 50 | // the key belongs to, and a deploy key grants nothing outside its |
| 47 | // binding, so anything acting on another repository needs this too. | 51 | // binding, so anything acting on another repository needs this too. |
| 48 | Scope string `json:"scope"` | 52 | Scope string `json:"scope"` |
| 53 | Token string `json:"token"` | ||
| 49 | Updates []policy.RefUpdate `json:"updates"` | 54 | Updates []policy.RefUpdate `json:"updates"` |
| 50 | } | 55 | } |
| 51 | 56 | ||
| @@ -94,6 +99,12 @@ func Serve(cfg config.Config, st *store.Store) (func() error, error) { | |||
| 94 | if err != nil { | 99 | if err != nil { |
| 95 | return nil, err | 100 | return nil, err |
| 96 | } | 101 | } |
| 102 | // Listen creates the socket under the process umask. Hooks run as | ||
| 103 | // the daemon's own user; nobody else has a reason to connect. | ||
| 104 | if err := os.Chmod(path, 0o600); err != nil { | ||
| 105 | ln.Close() | ||
| 106 | return nil, err | ||
| 107 | } | ||
| 97 | s := &Server{cfg: cfg, st: st} | 108 | s := &Server{cfg: cfg, st: st} |
| 98 | go func() { | 109 | go func() { |
| 99 | for { | 110 | for { |
| @@ -111,11 +122,20 @@ func (s *Server) handle(conn net.Conn) { | |||
| 111 | defer conn.Close() | 122 | defer conn.Close() |
| 112 | dec := json.NewDecoder(conn) | 123 | dec := json.NewDecoder(conn) |
| 113 | enc := json.NewEncoder(conn) | 124 | enc := json.NewEncoder(conn) |
| 125 | if err := checkPeer(conn); err != nil { | ||
| 126 | slog.Warn("hook socket: refused connection", "err", err) | ||
| 127 | enc.Encode(Response{Allow: false, Message: "hook socket: " + err.Error()}) | ||
| 128 | return | ||
| 129 | } | ||
| 114 | var req Request | 130 | var req Request |
| 115 | if err := dec.Decode(&req); err != nil { | 131 | if err := dec.Decode(&req); err != nil { |
| 116 | enc.Encode(Response{Allow: false, Message: "bad hook request"}) | 132 | enc.Encode(Response{Allow: false, Message: "bad hook request"}) |
| 117 | return | 133 | return |
| 118 | } | 134 | } |
| 135 | if msg := s.authorize(req); msg != "" { | ||
| 136 | enc.Encode(Response{Allow: false, Message: msg}) | ||
| 137 | return | ||
| 138 | } | ||
| 119 | switch req.Hook { | 139 | switch req.Hook { |
| 120 | case "pre-receive": | 140 | case "pre-receive": |
| 121 | s.preReceive(req, dec, enc) | 141 | s.preReceive(req, dec, enc) |
| @@ -127,6 +147,22 @@ func (s *Server) handle(conn net.Conn) { | |||
| 127 | } | 147 | } |
| 128 | } | 148 | } |
| 129 | 149 | ||
| 150 | // authorize ties a request to a receive-pack sshd started: its token | ||
| 151 | // must be live and name the same repository, account and key scope. | ||
| 152 | func (s *Server) authorize(req Request) string { | ||
| 153 | if req.Token == "" { | ||
| 154 | return "push not started by this server" | ||
| 155 | } | ||
| 156 | tok, err := s.st.PushTokenByHash(store.HashToken(req.Token)) | ||
| 157 | if err != nil { | ||
| 158 | return "push not started by this server" | ||
| 159 | } | ||
| 160 | if tok.RepoID != req.RepoID || tok.UserID != req.UserID || tok.Scope != req.Scope { | ||
| 161 | return "push token does not match this request" | ||
| 162 | } | ||
| 163 | return "" | ||
| 164 | } | ||
| 165 | |||
| 130 | func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) { | 166 | func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) { |
| 131 | repo, err := s.st.RepoByID(req.RepoID) | 167 | repo, err := s.st.RepoByID(req.RepoID) |
| 132 | if err != nil { | 168 | if err != nil { |
internal/hookd/peercred_linux.go added +38
| @@ -0,0 +1,38 @@ | |||
| 1 | //go:build linux | ||
| 2 | |||
| 3 | package hookd | ||
| 4 | |||
| 5 | import ( | ||
| 6 | "errors" | ||
| 7 | "fmt" | ||
| 8 | "net" | ||
| 9 | "os" | ||
| 10 | "syscall" | ||
| 11 | ) | ||
| 12 | |||
| 13 | // checkPeer refuses a connection from any uid but the daemon's: git, | ||
| 14 | // and so every hook, runs as the daemon's user. | ||
| 15 | func checkPeer(conn net.Conn) error { | ||
| 16 | uc, ok := conn.(*net.UnixConn) | ||
| 17 | if !ok { | ||
| 18 | return fmt.Errorf("not a unix socket connection") | ||
| 19 | } | ||
| 20 | raw, err := uc.SyscallConn() | ||
| 21 | if err != nil { | ||
| 22 | return err | ||
| 23 | } | ||
| 24 | var cred *syscall.Ucred | ||
| 25 | var credErr error | ||
| 26 | if err := raw.Control(func(fd uintptr) { | ||
| 27 | cred, credErr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED) | ||
| 28 | }); err != nil { | ||
| 29 | return err | ||
| 30 | } | ||
| 31 | if credErr != nil { | ||
| 32 | return credErr | ||
| 33 | } | ||
| 34 | if int(cred.Uid) != os.Getuid() { | ||
| 35 | return errors.New("peer uid not permitted") | ||
| 36 | } | ||
| 37 | return nil | ||
| 38 | } | ||
internal/hookd/peercred_other.go added +9
| @@ -0,0 +1,9 @@ | |||
| 1 | //go:build !linux | ||
| 2 | |||
| 3 | package hookd | ||
| 4 | |||
| 5 | import "net" | ||
| 6 | |||
| 7 | // checkPeer reads peer credentials on Linux only; elsewhere the | ||
| 8 | // socket's 0600 mode is the boundary. | ||
| 9 | func checkPeer(net.Conn) error { return nil } | ||
internal/hookd/socket_test.go added +105
| @@ -0,0 +1,105 @@ | |||
| 1 | package hookd | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "path/filepath" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | |||
| 9 | "gitbay.org/gitbay/internal/config" | ||
| 10 | "gitbay.org/gitbay/internal/store" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func serveSocket(t *testing.T) (sock string, st *store.Store, repoID, uid int64) { | ||
| 14 | t.Helper() | ||
| 15 | st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db")) | ||
| 16 | if err != nil { | ||
| 17 | t.Fatal(err) | ||
| 18 | } | ||
| 19 | t.Cleanup(func() { st.Close() }) | ||
| 20 | if err := st.MigrateUp(); err != nil { | ||
| 21 | t.Fatal(err) | ||
| 22 | } | ||
| 23 | if uid, err = st.CreateUser("alice", false); err != nil { | ||
| 24 | t.Fatal(err) | ||
| 25 | } | ||
| 26 | if repoID, err = st.CreateRepo("user", uid, "app", "public"); err != nil { | ||
| 27 | t.Fatal(err) | ||
| 28 | } | ||
| 29 | var cfg config.Config | ||
| 30 | cfg.Server.Root = t.TempDir() | ||
| 31 | stop, err := Serve(cfg, st) | ||
| 32 | if err != nil { | ||
| 33 | t.Fatal(err) | ||
| 34 | } | ||
| 35 | t.Cleanup(func() { stop() }) | ||
| 36 | return SocketPath(cfg.Server.Root), st, repoID, uid | ||
| 37 | } | ||
| 38 | |||
| 39 | func TestSocketIsOwnerOnly(t *testing.T) { | ||
| 40 | sock, _, _, _ := serveSocket(t) | ||
| 41 | fi, err := os.Stat(sock) | ||
| 42 | if err != nil { | ||
| 43 | t.Fatal(err) | ||
| 44 | } | ||
| 45 | if fi.Mode().Perm() != 0o600 { | ||
| 46 | t.Fatalf("mode %v, want 0600", fi.Mode().Perm()) | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | // A request speaks for a receive-pack sshd started, and only for the | ||
| 51 | // repository, account and scope that push was started with (#282). | ||
| 52 | func TestHookRequestNeedsItsPushToken(t *testing.T) { | ||
| 53 | sock, st, repoID, uid := serveSocket(t) | ||
| 54 | req := Request{Hook: "pre-receive", RepoID: repoID, UserID: uid, Scope: "full"} | ||
| 55 | |||
| 56 | resp, err := Ask(sock, req, nil) | ||
| 57 | if err != nil { | ||
| 58 | t.Fatal(err) | ||
| 59 | } | ||
| 60 | if resp.Allow || !strings.Contains(resp.Message, "not started by this server") { | ||
| 61 | t.Fatalf("no token: %+v", resp) | ||
| 62 | } | ||
| 63 | |||
| 64 | token, err := st.CreatePushToken(repoID, uid, "full") | ||
| 65 | if err != nil { | ||
| 66 | t.Fatal(err) | ||
| 67 | } | ||
| 68 | req.Token = token | ||
| 69 | if resp, err = Ask(sock, req, nil); err != nil || !resp.Allow { | ||
| 70 | t.Fatalf("with token: %+v, %v", resp, err) | ||
| 71 | } | ||
| 72 | |||
| 73 | other, err := st.CreateUser("mallory", false) | ||
| 74 | if err != nil { | ||
| 75 | t.Fatal(err) | ||
| 76 | } | ||
| 77 | forged := req | ||
| 78 | forged.UserID = other | ||
| 79 | if resp, err = Ask(sock, forged, nil); err != nil || resp.Allow { | ||
| 80 | t.Fatalf("token for another account: %+v, %v", resp, err) | ||
| 81 | } | ||
| 82 | |||
| 83 | otherRepo, err := st.CreateRepo("user", uid, "lib", "public") | ||
| 84 | if err != nil { | ||
| 85 | t.Fatal(err) | ||
| 86 | } | ||
| 87 | forged = req | ||
| 88 | forged.RepoID = otherRepo | ||
| 89 | if resp, err = Ask(sock, forged, nil); err != nil || resp.Allow { | ||
| 90 | t.Fatalf("token for another repository: %+v, %v", resp, err) | ||
| 91 | } | ||
| 92 | |||
| 93 | forged = req | ||
| 94 | forged.Scope = "read" | ||
| 95 | if resp, err = Ask(sock, forged, nil); err != nil || resp.Allow { | ||
| 96 | t.Fatalf("token for another scope: %+v, %v", resp, err) | ||
| 97 | } | ||
| 98 | |||
| 99 | if err := st.DeletePushToken(token); err != nil { | ||
| 100 | t.Fatal(err) | ||
| 101 | } | ||
| 102 | if resp, err = Ask(sock, req, nil); err != nil || resp.Allow { | ||
| 103 | t.Fatalf("finished push: %+v, %v", resp, err) | ||
| 104 | } | ||
| 105 | } | ||
internal/sshd/sshd.go +10
| @@ -575,6 +575,16 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a | |||
| 575 | } | 575 | } |
| 576 | } | 576 | } |
| 577 | } | 577 | } |
| 578 | if write { | ||
| 579 | // hookd answers only a hook that names this receive-pack. | ||
| 580 | token, err := st.CreatePushToken(repo.ID, user.ID, scope) | ||
| 581 | if err != nil { | ||
| 582 | fmt.Fprintln(stderr, "internal error") | ||
| 583 | return protocol.ExitFailure | ||
| 584 | } | ||
| 585 | defer st.DeletePushToken(token) | ||
| 586 | env = append(env, hookd.EnvToken+"="+token) | ||
| 587 | } | ||
| 578 | if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack, revoked); err != nil { | 588 | if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack, revoked); err != nil { |
| 579 | return protocol.ExitFailure | 589 | return protocol.ExitFailure |
| 580 | } | 590 | } |
internal/store/migrations/0063_push_tokens.down.sql added +1
| @@ -0,0 +1 @@ | |||
| 1 | DROP TABLE push_tokens; | ||
internal/store/migrations/0063_push_tokens.up.sql added +10
| @@ -0,0 +1,10 @@ | |||
| 1 | -- One row per receive-pack in flight. The hook names its push by the | ||
| 2 | -- token; hookd answers only a live one. Only the SHA-256 is stored. | ||
| 3 | CREATE TABLE push_tokens ( | ||
| 4 | token_hash TEXT PRIMARY KEY, | ||
| 5 | repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | ||
| 6 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | ||
| 7 | scope TEXT NOT NULL, | ||
| 8 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | ||
| 9 | expires_at TEXT NOT NULL | ||
| 10 | ); | ||
internal/store/pushtokens.go added +52
| @@ -0,0 +1,52 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "database/sql" | ||
| 5 | "errors" | ||
| 6 | "time" | ||
| 7 | ) | ||
| 8 | |||
| 9 | // PushToken is the receive-pack a hook request speaks for. | ||
| 10 | type PushToken struct { | ||
| 11 | RepoID int64 | ||
| 12 | UserID int64 | ||
| 13 | Scope string | ||
| 14 | } | ||
| 15 | |||
| 16 | // pushTokenTTL bounds a row whose receive-pack died before deleting it. | ||
| 17 | const pushTokenTTL = 24 * time.Hour | ||
| 18 | |||
| 19 | // CreatePushToken records a token for one receive-pack and returns it. | ||
| 20 | func (s *Store) CreatePushToken(repoID, userID int64, scope string) (string, error) { | ||
| 21 | token, hash, err := NewToken() | ||
| 22 | if err != nil { | ||
| 23 | return "", err | ||
| 24 | } | ||
| 25 | _, err = s.DB.Exec( | ||
| 26 | "INSERT INTO push_tokens (token_hash, repo_id, user_id, scope, expires_at) VALUES (?, ?, ?, ?, ?)", | ||
| 27 | hash, repoID, userID, scope, fmtTime(time.Now().Add(pushTokenTTL))) | ||
| 28 | if err != nil { | ||
| 29 | return "", err | ||
| 30 | } | ||
| 31 | return token, nil | ||
| 32 | } | ||
| 33 | |||
| 34 | // PushTokenByHash looks up a live token by its stored hash. ErrNotFound | ||
| 35 | // covers both an absent row and one that has expired. | ||
| 36 | func (s *Store) PushTokenByHash(hash string) (PushToken, error) { | ||
| 37 | var t PushToken | ||
| 38 | err := s.DB.QueryRow( | ||
| 39 | "SELECT repo_id, user_id, scope FROM push_tokens WHERE token_hash = ? AND expires_at > ?", | ||
| 40 | hash, fmtTime(time.Now())).Scan(&t.RepoID, &t.UserID, &t.Scope) | ||
| 41 | if errors.Is(err, sql.ErrNoRows) { | ||
| 42 | return PushToken{}, ErrNotFound | ||
| 43 | } | ||
| 44 | return t, err | ||
| 45 | } | ||
| 46 | |||
| 47 | // DeletePushToken removes a token by its raw value, once its receive-pack | ||
| 48 | // is done with it. | ||
| 49 | func (s *Store) DeletePushToken(token string) error { | ||
| 50 | _, err := s.DB.Exec("DELETE FROM push_tokens WHERE token_hash = ?", HashToken(token)) | ||
| 51 | return err | ||
| 52 | } | ||
internal/store/pushtokens_test.go added +49
| @@ -0,0 +1,49 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "testing" | ||
| 6 | "time" | ||
| 7 | ) | ||
| 8 | |||
| 9 | func TestPushTokens(t *testing.T) { | ||
| 10 | s := open(t) | ||
| 11 | if err := s.MigrateUp(); err != nil { | ||
| 12 | t.Fatal(err) | ||
| 13 | } | ||
| 14 | uid, err := s.CreateUser("alice", false) | ||
| 15 | if err != nil { | ||
| 16 | t.Fatal(err) | ||
| 17 | } | ||
| 18 | repoID, err := s.CreateRepo("user", uid, "app", "public") | ||
| 19 | if err != nil { | ||
| 20 | t.Fatal(err) | ||
| 21 | } | ||
| 22 | token, err := s.CreatePushToken(repoID, uid, "full") | ||
| 23 | if err != nil { | ||
| 24 | t.Fatal(err) | ||
| 25 | } | ||
| 26 | got, err := s.PushTokenByHash(HashToken(token)) | ||
| 27 | if err != nil || got != (PushToken{RepoID: repoID, UserID: uid, Scope: "full"}) { | ||
| 28 | t.Fatalf("lookup = %+v, %v", got, err) | ||
| 29 | } | ||
| 30 | if err := s.DeletePushToken(token); err != nil { | ||
| 31 | t.Fatal(err) | ||
| 32 | } | ||
| 33 | if _, err := s.PushTokenByHash(HashToken(token)); !errors.Is(err, ErrNotFound) { | ||
| 34 | t.Fatalf("after delete: %v", err) | ||
| 35 | } | ||
| 36 | |||
| 37 | // A token whose receive-pack never cleaned up is swept after a day. | ||
| 38 | stale, err := s.CreatePushToken(repoID, uid, "full") | ||
| 39 | if err != nil { | ||
| 40 | t.Fatal(err) | ||
| 41 | } | ||
| 42 | swept, err := s.Sweep(Retention{}, time.Now().Add(25*time.Hour)) | ||
| 43 | if err != nil || swept["push_tokens"] != 1 { | ||
| 44 | t.Fatalf("sweep = %v, %v", swept, err) | ||
| 45 | } | ||
| 46 | if _, err := s.PushTokenByHash(HashToken(stale)); !errors.Is(err, ErrNotFound) { | ||
| 47 | t.Fatalf("after sweep: %v", err) | ||
| 48 | } | ||
| 49 | } | ||
internal/store/retention.go +1
| @@ -51,6 +51,7 @@ func (s *Store) Sweep(r Retention, now time.Time) (Swept, error) { | |||
| 51 | {"web_sessions", "expires_at <= ?"}, | 51 | {"web_sessions", "expires_at <= ?"}, |
| 52 | {"login_tokens", "expires_at <= ?"}, | 52 | {"login_tokens", "expires_at <= ?"}, |
| 53 | {"email_tokens", "expires_at <= ?"}, | 53 | {"email_tokens", "expires_at <= ?"}, |
| 54 | {"push_tokens", "expires_at <= ?"}, | ||
| 54 | } | 55 | } |
| 55 | for _, e := range expired { | 56 | for _, e := range expired { |
| 56 | n, err := s.deleteBy("DELETE FROM "+e.table+" WHERE "+e.where, fmtTime(now)) | 57 | n, err := s.deleteBy("DELETE FROM "+e.table+" WHERE "+e.where, fmtTime(now)) |