Commit 2e6467a72f
Verified · cmc
cmd/gitbayd/adminusers.go added +91
| @@ -0,0 +1,91 @@ | ||
| 1 | package main | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | ||
| 6 | "github.com/spf13/cobra" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/config" | |
| 9 | "gitbay.org/gitbay/internal/store" | |
| 10 | ) | |
| 11 | ||
| 12 | func withUser(use, short string, run func(st *store.Store, u store.User) error) *cobra.Command { | |
| 13 | return &cobra.Command{ | |
| 14 | Use: use + " <username>", | |
| 15 | Short: short, | |
| 16 | Args: cobra.ExactArgs(1), | |
| 17 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 18 | cfg, err := config.Load(configPath) | |
| 19 | if err != nil { | |
| 20 | return err | |
| 21 | } | |
| 22 | st, err := openStore(cfg) | |
| 23 | if err != nil { | |
| 24 | return err | |
| 25 | } | |
| 26 | defer st.Close() | |
| 27 | u, err := st.UserByUsername(args[0]) | |
| 28 | if err != nil { | |
| 29 | return fmt.Errorf("no user %q", args[0]) | |
| 30 | } | |
| 31 | return run(st, u) | |
| 32 | }, | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | func adminUserDisableCmd() *cobra.Command { | |
| 37 | return withUser("disable", "suspend an account: keys and sessions refused until re-enabled", | |
| 38 | func(st *store.Store, u store.User) error { | |
| 39 | if err := st.SetUserDisabled(u.ID, true); err != nil { | |
| 40 | return err | |
| 41 | } | |
| 42 | st.Audit(0, "admin user.disabled", map[string]any{"user": u.Username}) | |
| 43 | fmt.Printf("disabled %s: SSH, web sessions, and API tokens are refused; nothing was deleted\n", u.Username) | |
| 44 | return nil | |
| 45 | }) | |
| 46 | } | |
| 47 | ||
| 48 | func adminUserEnableCmd() *cobra.Command { | |
| 49 | return withUser("enable", "restore a suspended account", | |
| 50 | func(st *store.Store, u store.User) error { | |
| 51 | if err := st.SetUserDisabled(u.ID, false); err != nil { | |
| 52 | return err | |
| 53 | } | |
| 54 | st.Audit(0, "admin user.enabled", map[string]any{"user": u.Username}) | |
| 55 | fmt.Printf("enabled %s\n", u.Username) | |
| 56 | return nil | |
| 57 | }) | |
| 58 | } | |
| 59 | ||
| 60 | func adminAuditCmd() *cobra.Command { | |
| 61 | var limit int | |
| 62 | cmd := &cobra.Command{ | |
| 63 | Use: "audit", | |
| 64 | Short: "print the security audit log, newest first", | |
| 65 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 66 | cfg, err := config.Load(configPath) | |
| 67 | if err != nil { | |
| 68 | return err | |
| 69 | } | |
| 70 | st, err := openStore(cfg) | |
| 71 | if err != nil { | |
| 72 | return err | |
| 73 | } | |
| 74 | defer st.Close() | |
| 75 | entries, err := st.AuditEntries(limit) | |
| 76 | if err != nil { | |
| 77 | return err | |
| 78 | } | |
| 79 | for _, e := range entries { | |
| 80 | actor := e.Actor | |
| 81 | if actor == "" { | |
| 82 | actor = "-" | |
| 83 | } | |
| 84 | fmt.Printf("%s\t%s\t%s\t%s\n", e.CreatedAt, actor, e.Action, e.Data) | |
| 85 | } | |
| 86 | return nil | |
| 87 | }, | |
| 88 | } | |
| 89 | cmd.Flags().IntVar(&limit, "limit", 100, "entries to print") | |
| 90 | return cmd | |
| 91 | } | |
cmd/gitbayd/main.go +6 −1
| @@ -245,7 +245,7 @@ func adminCmd() *cobra.Command { | ||
| 245 | 245 | Short: "host-local administration", |
| 246 | 246 | } |
| 247 | 247 | userCmd := &cobra.Command{Use: "user", Short: "manage users"} |
| 248 | userCmd.AddCommand(adminUserCreateCmd()) | |
| 248 | userCmd.AddCommand(adminUserCreateCmd(), adminUserDisableCmd(), adminUserEnableCmd()) | |
| 249 | 249 | emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"} |
| 250 | 250 | emailCmd.AddCommand(adminEmailVerifyCmd()) |
| 251 | 251 | admin.AddCommand( |
| @@ -255,6 +255,7 @@ func adminCmd() *cobra.Command { | ||
| 255 | 255 | backupCmd(), |
| 256 | 256 | gcCmd(), |
| 257 | 257 | statsCmd(), |
| 258 | adminAuditCmd(), | |
| 258 | 259 | ) |
| 259 | 260 | return admin |
| 260 | 261 | } |
| @@ -299,6 +300,7 @@ func adminInviteCmd() *cobra.Command { | ||
| 299 | 300 | if err := mail.Send(cfg, email, "your invite to "+host, body); err != nil { |
| 300 | 301 | return fmt.Errorf("invite stored but mail failed: %w (code: %s)", err, code) |
| 301 | 302 | } |
| 303 | st.Audit(0, "admin invite.issued", map[string]any{"email": email}) | |
| 302 | 304 | fmt.Printf("invite emailed to %s\n", email) |
| 303 | 305 | } else { |
| 304 | 306 | fmt.Printf("invite for %s (no SMTP configured; deliver it yourself):\n%s\n", email, code) |
| @@ -360,6 +362,7 @@ func adminUserCreateCmd() *cobra.Command { | ||
| 360 | 362 | } |
| 361 | 363 | fmt.Println("key", fp) |
| 362 | 364 | } |
| 365 | st.Audit(0, "admin user.created", map[string]any{"user": username}) | |
| 363 | 366 | fmt.Println("created user", username) |
| 364 | 367 | return nil |
| 365 | 368 | }, |
| @@ -391,8 +394,10 @@ func adminEmailVerifyCmd() *cobra.Command { | ||
| 391 | 394 | return fmt.Errorf("user %s: %w", args[0], err) |
| 392 | 395 | } |
| 393 | 396 | if err := st.VerifyEmail(u.ID, args[1], "admin"); err != nil { |
| 397 | st.Audit(0, "admin email.verify_failed", map[string]any{"user": args[0], "email": args[1]}) | |
| 394 | 398 | return fmt.Errorf("no address %s on user %s", args[1], args[0]) |
| 395 | 399 | } |
| 400 | st.Audit(0, "admin email.verified", map[string]any{"user": args[0], "email": args[1]}) | |
| 396 | 401 | fmt.Println("verified", args[1]) |
| 397 | 402 | return nil |
| 398 | 403 | }, |
cmd/gitbayd/system.go +1 −1
| @@ -93,7 +93,7 @@ func shellCmd() *cobra.Command { | ||
| 93 | 93 | fmt.Fprintf(os.Stderr, "gitbay control plane: interactive shells are not available.\nTry: ssh <host> help\n") |
| 94 | 94 | os.Exit(protocol.ExitUsage) |
| 95 | 95 | } |
| 96 | code := sshd.Exec(cfg, st, user, key.Scope, cmdline, os.Stdin, os.Stdout, os.Stderr) | |
| 96 | code := sshd.Exec(cfg, st, user, key.Scope, key.Fingerprint, cmdline, os.Stdin, os.Stdout, os.Stderr) | |
| 97 | 97 | st.Close() |
| 98 | 98 | os.Exit(code) |
| 99 | 99 | return nil |
docs/admin.org +20
| @@ -127,6 +127,26 @@ gitbayd admin invite --email b@example.org # mails a code; prints it if no | ||
| 127 | 127 | records which. Verified emails are what make commit signatures |
| 128 | 128 | meaningful — an unverified address never produces a =verified= badge. |
| 129 | 129 | |
| 130 | * Audit and account control | |
| 131 | ||
| 132 | The audit log is the security feed (events are the product feed): every | |
| 133 | successful mutating command with its argv and source credential (SSH key | |
| 134 | fingerprint or API), registrations, admin actions, force-pushes, and | |
| 135 | auth failures/throttling. Secrets never appear — they travel on stdin, | |
| 136 | never in argv. | |
| 137 | ||
| 138 | #+begin_src sh | |
| 139 | gitbayd admin audit [--limit n] # host-local | |
| 140 | ssh git@<host> audit [--limit n] # instance admins, SSH only | |
| 141 | gitbayd admin user disable <name> # suspend: SSH, web, API all refused; | |
| 142 | gitbayd admin user enable <name> # sessions dropped, nothing deleted | |
| 143 | #+end_src | |
| 144 | ||
| 145 | =limits.ssh_auth_rate= (10) throttles per-IP authentication *failures* | |
| 146 | per minute — successful auths never count and clear the slate. | |
| 147 | =limits.max_pack_bytes= is enforced as =receive.maxInputSize= on every | |
| 148 | push. | |
| 149 | ||
| 130 | 150 | * Maintenance |
| 131 | 151 | |
| 132 | 152 | #+begin_src sh |
e2e/audit_test.go added +97
| @@ -0,0 +1,97 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "crypto/rand" | |
| 5 | "os" | |
| 6 | "path/filepath" | |
| 7 | "strings" | |
| 8 | "testing" | |
| 9 | ) | |
| 10 | ||
| 11 | func TestAuditAndHardening(t *testing.T) { | |
| 12 | inst := startInstanceWith(t, "[limits]\nssh_auth_rate = 3\nmax_pack_bytes = 2000\n") | |
| 13 | adminKey := inst.newKey(t, "root") | |
| 14 | aliceKey := inst.newKey(t, "alice") | |
| 15 | bobKey := inst.newKey(t, "bob") | |
| 16 | inst.admin(t, "admin", "user", "create", "root", "--key", adminKey+".pub", "--admin") | |
| 17 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 18 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | |
| 19 | ||
| 20 | // Mutating commands land in the audit log with source fingerprints; | |
| 21 | // reads do not. Admin-only over SSH; host admin command works too. | |
| 22 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 23 | t.Fatal("repo create failed") | |
| 24 | } | |
| 25 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 { | |
| 26 | t.Fatal("grant failed") | |
| 27 | } | |
| 28 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "list"); code != 0 { | |
| 29 | t.Fatal("repo list failed") | |
| 30 | } | |
| 31 | if _, _, code := inst.ssh(t, aliceKey, "", "audit"); code != 4 { | |
| 32 | t.Fatal("non-admin read the audit log") | |
| 33 | } | |
| 34 | out, _, code := inst.ssh(t, adminKey, "", "audit", "--json") | |
| 35 | if code != 0 || !strings.Contains(out, "cmd repo create") || | |
| 36 | !strings.Contains(out, "cmd repo access grant") || | |
| 37 | !strings.Contains(out, `SHA256:`) || // key fingerprint as source | |
| 38 | !strings.Contains(out, "admin user.created") { | |
| 39 | t.Fatalf("audit content: %s", out) | |
| 40 | } | |
| 41 | if strings.Contains(out, "cmd repo list") { | |
| 42 | t.Fatal("read-only command audited") | |
| 43 | } | |
| 44 | if out := inst.admin(t, "admin", "audit", "--limit", "5"); !strings.Contains(out, "cmd repo") { | |
| 45 | t.Fatalf("host audit: %s", out) | |
| 46 | } | |
| 47 | ||
| 48 | // Disable: everything refused, sessions dropped, nothing deleted. | |
| 49 | inst.admin(t, "admin", "user", "disable", "bob") | |
| 50 | if _, errOut, code := inst.ssh(t, bobKey, "", "whoami"); code != 4 || !strings.Contains(errOut, "disabled") { | |
| 51 | t.Fatalf("disabled ssh: exit %d, %s", code, errOut) | |
| 52 | } | |
| 53 | inst.admin(t, "admin", "user", "enable", "bob") | |
| 54 | if _, _, code := inst.ssh(t, bobKey, "", "whoami"); code != 0 { | |
| 55 | t.Fatal("re-enabled user still refused") | |
| 56 | } | |
| 57 | ||
| 58 | // max_pack_bytes: an oversized push is refused by receive-pack. | |
| 59 | work := t.TempDir() | |
| 60 | env := inst.gitEnv(aliceKey) | |
| 61 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | |
| 62 | dir := filepath.Join(work, "w") | |
| 63 | big := make([]byte, 200_000) | |
| 64 | rand.Read(big) // incompressible: the pack must exceed max_pack_bytes | |
| 65 | os.WriteFile(filepath.Join(dir, "big.bin"), big, 0o644) | |
| 66 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 67 | mustGit(t, dir, env, "add", ".") | |
| 68 | mustGit(t, dir, env, "commit", "-q", "-m", "big") | |
| 69 | if out, code := gitRun(t, dir, env, "push", "origin", "main"); code == 0 || !strings.Contains(out, "max") { | |
| 70 | t.Fatalf("oversized push accepted: exit %d\n%s", code, out) | |
| 71 | } | |
| 72 | // A normal-sized push still works. | |
| 73 | mustGit(t, dir, env, "rm", "-q", "big.bin") | |
| 74 | os.WriteFile(filepath.Join(dir, "small.txt"), []byte("ok\n"), 0o644) | |
| 75 | mustGit(t, dir, env, "add", ".") | |
| 76 | mustGit(t, dir, env, "commit", "-q", "--amend", "-m", "small") | |
| 77 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 78 | ||
| 79 | // Auth rate limit, LAST because it locks out this whole IP: a burst | |
| 80 | // of unknown-key failures throttles further auth — even a valid key | |
| 81 | // — until the window passes. (Registration is closed, so unknown | |
| 82 | // keys fail auth.) The audit is read host-locally: SSH is locked. | |
| 83 | strangerKey := inst.newKey(t, "stranger") | |
| 84 | for i := 0; i < 5; i++ { | |
| 85 | inst.ssh(t, strangerKey, "", "whoami") | |
| 86 | } | |
| 87 | if _, _, code := inst.ssh(t, adminKey, "", "whoami"); code == 0 { | |
| 88 | t.Fatal("valid key not throttled after failure burst") | |
| 89 | } | |
| 90 | auditOut := inst.admin(t, "admin", "audit") | |
| 91 | if !strings.Contains(auditOut, "auth.failed") || !strings.Contains(auditOut, "auth.throttled") { | |
| 92 | t.Fatalf("burst not audited:\n%s", auditOut) | |
| 93 | } | |
| 94 | if strings.Count(auditOut, "auth.throttled") != 1 { | |
| 95 | t.Fatal("throttle audited more than once per window") | |
| 96 | } | |
| 97 | } | |
internal/control/audit.go added +44
| @@ -0,0 +1,44 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "io" | |
| 6 | "strconv" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/protocol" | |
| 9 | ) | |
| 10 | ||
| 11 | func init() { | |
| 12 | register(Command{Path: []string{"audit"}, | |
| 13 | Summary: "instance audit log (admins): audit [--limit <n>]", ReadOnly: true, SSHOnly: true, Run: runAudit}) | |
| 14 | } | |
| 15 | ||
| 16 | func runAudit(c *Ctx, args []string) int { | |
| 17 | if !c.User.IsAdmin { | |
| 18 | return c.fail(protocol.ExitDenied, "the audit log is for instance admins") | |
| 19 | } | |
| 20 | limit := 100 | |
| 21 | for i := 0; i < len(args); i++ { | |
| 22 | if args[i] == "--limit" && i+1 < len(args) { | |
| 23 | if n, err := strconv.Atoi(args[i+1]); err == nil && n > 0 && n <= 10000 { | |
| 24 | limit = n | |
| 25 | } | |
| 26 | i++ | |
| 27 | } else { | |
| 28 | return c.fail(protocol.ExitUsage, "usage: audit [--limit <n>]") | |
| 29 | } | |
| 30 | } | |
| 31 | entries, err := c.Store.AuditEntries(limit) | |
| 32 | if err != nil { | |
| 33 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 34 | } | |
| 35 | return c.emit(entries, func(w io.Writer) { | |
| 36 | for _, e := range entries { | |
| 37 | actor := e.Actor | |
| 38 | if actor == "" { | |
| 39 | actor = "-" | |
| 40 | } | |
| 41 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", e.CreatedAt, actor, e.Action, e.Data) | |
| 42 | } | |
| 43 | }) | |
| 44 | } | |
internal/control/control.go +14 −1
| @@ -30,6 +30,9 @@ type Ctx struct { | ||
| 30 | 30 | ViaAPI bool |
| 31 | 31 | // ReadOnly is set for read-scoped API tokens. |
| 32 | 32 | ReadOnly bool |
| 33 | // Source identifies the credential behind this session for the audit | |
| 34 | // log: an SSH key fingerprint, or "api" for token requests. | |
| 35 | Source string | |
| 33 | 36 | } |
| 34 | 37 | |
| 35 | 38 | type Command struct { |
| @@ -100,7 +103,17 @@ func Dispatch(c *Ctx, argv []string) int { | ||
| 100 | 103 | if !cmd.ReadsStdin { |
| 101 | 104 | c.Stdin = emptyReader{} |
| 102 | 105 | } |
| 103 | return cmd.Run(c, args) | |
| 106 | code := cmd.Run(c, args) | |
| 107 | // Every successful mutating command lands in the audit log. Argv is | |
| 108 | // safe to record by construction: secrets travel on stdin, never as | |
| 109 | // arguments. | |
| 110 | if code == protocol.ExitOK && !cmd.ReadOnly { | |
| 111 | c.Store.Audit(c.User.ID, "cmd "+joinPath(cmd.Path), map[string]any{ | |
| 112 | "argv": args, | |
| 113 | "source": c.Source, | |
| 114 | }) | |
| 115 | } | |
| 116 | return code | |
| 104 | 117 | } |
| 105 | 118 | |
| 106 | 119 | // pendingAllowed lists what an unverified self-registered account may do. |
internal/control/register.go +2
| @@ -161,6 +161,7 @@ func RegisterAccount(cfg config.Config, st *store.Store, pub ssh.PublicKey, user | ||
| 161 | 161 | } |
| 162 | 162 | return "", err.Error(), protocol.ExitUsage |
| 163 | 163 | } |
| 164 | st.Audit(0, "auth.registered", map[string]any{"user": username, "mode": "invite", "fingerprint": fp}) | |
| 164 | 165 | return fmt.Sprintf("welcome, %s — your account is active\n", username), "", protocol.ExitOK |
| 165 | 166 | |
| 166 | 167 | case "open": |
| @@ -174,6 +175,7 @@ func RegisterAccount(cfg config.Config, st *store.Store, pub ssh.PublicKey, user | ||
| 174 | 175 | if err := sendVerification(cfg, st, uid, email); err != nil { |
| 175 | 176 | return "", "sending verification mail: " + err.Error(), protocol.ExitFailure |
| 176 | 177 | } |
| 178 | st.Audit(uid, "auth.registered", map[string]any{"user": username, "mode": "open", "fingerprint": fp}) | |
| 177 | 179 | return fmt.Sprintf( |
| 178 | 180 | "account %s created. A verification code was sent to %s.\nActivate with:\n\n ssh git@%s email verify <code>\n", |
| 179 | 181 | username, email, siteHost(cfg)), "", protocol.ExitOK |
internal/gitutil/gitutil.go +7 −3
| @@ -31,12 +31,16 @@ func InitBare(path, defaultBranch, hooksPath string) error { | ||
| 31 | 31 | |
| 32 | 32 | // Transport streams one git transport service (upload-pack, receive-pack, |
| 33 | 33 | // upload-archive). extraEnv entries are appended to the process environment; |
| 34 | // hooks read the GITBAY_* variables from it. | |
| 35 | func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string) error { | |
| 34 | // hooks read the GITBAY_* variables from it. maxPack caps incoming pack | |
| 35 | // bytes on receive-pack (0 = unlimited). | |
| 36 | func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64) error { | |
| 36 | 37 | var args []string |
| 37 | 38 | switch service { |
| 38 | 39 | case "git-upload-pack", "git-receive-pack", "git-upload-archive": |
| 39 | args = []string{strings.TrimPrefix(service, "git-"), repoPath} | |
| 40 | if service == "git-receive-pack" && maxPack > 0 { | |
| 41 | args = []string{"-c", fmt.Sprintf("receive.maxInputSize=%d", maxPack)} | |
| 42 | } | |
| 43 | args = append(args, strings.TrimPrefix(service, "git-"), repoPath) | |
| 40 | 44 | default: |
| 41 | 45 | return fmt.Errorf("unknown service %q", service) |
| 42 | 46 | } |
internal/hookd/hookd.go +4
| @@ -185,6 +185,10 @@ func (s *Server) postReceive(req Request) { | ||
| 185 | 185 | } |
| 186 | 186 | // Any branch/tag update schedules the push mirrors. |
| 187 | 187 | s.st.MarkMirrorsDirty(req.RepoID, "push") |
| 188 | if u.IsForce { | |
| 189 | s.st.Audit(req.UserID, "push.forced", map[string]any{ | |
| 190 | "repo": req.RepoID, "ref": u.Ref, "old": u.Old, "new": u.New}) | |
| 191 | } | |
| 188 | 192 | mrs, err := s.st.OpenMRsBySource(req.RepoID, branch) |
| 189 | 193 | if err != nil { |
| 190 | 194 | slog.Error("post-receive: listing MRs", "err", err) |
internal/httpd/api.go +1
| @@ -50,6 +50,7 @@ func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) { | ||
| 50 | 50 | var stdout, stderr bytes.Buffer |
| 51 | 51 | ctx := &control.Ctx{ |
| 52 | 52 | User: user, |
| 53 | Source: "api", | |
| 53 | 54 | Scope: "full", // key scopes are an SSH concept; token scope is below |
| 54 | 55 | Store: s.st, |
| 55 | 56 | Cfg: s.cfg, |
internal/sshd/ratelimit.go added +91
| @@ -0,0 +1,91 @@ | ||
| 1 | package sshd | |
| 2 | ||
| 3 | import ( | |
| 4 | "net" | |
| 5 | "sync" | |
| 6 | "time" | |
| 7 | ) | |
| 8 | ||
| 9 | // rateLimiter throttles per-IP authentication FAILURES: successful auths | |
| 10 | // never count (a busy CLI makes many connections per minute) and clear the | |
| 11 | // IP's slate. limits.ssh_auth_rate failures per window lock the IP out | |
| 12 | // until the window passes. | |
| 13 | type rateLimiter struct { | |
| 14 | mu sync.Mutex | |
| 15 | limit int | |
| 16 | window time.Duration | |
| 17 | seen map[string]*ipWindow | |
| 18 | } | |
| 19 | ||
| 20 | type ipWindow struct { | |
| 21 | start time.Time | |
| 22 | count int | |
| 23 | audited bool | |
| 24 | } | |
| 25 | ||
| 26 | func newRateLimiter(limit int, window time.Duration) *rateLimiter { | |
| 27 | return &rateLimiter{limit: limit, window: window, seen: map[string]*ipWindow{}} | |
| 28 | } | |
| 29 | ||
| 30 | // allow reports whether ip may attempt authentication at all. | |
| 31 | func (r *rateLimiter) allow(ip string) bool { | |
| 32 | if r.limit <= 0 { | |
| 33 | return true | |
| 34 | } | |
| 35 | r.mu.Lock() | |
| 36 | defer r.mu.Unlock() | |
| 37 | now := time.Now() | |
| 38 | if len(r.seen) > 4096 { | |
| 39 | for k, w := range r.seen { | |
| 40 | if now.Sub(w.start) > r.window { | |
| 41 | delete(r.seen, k) | |
| 42 | } | |
| 43 | } | |
| 44 | } | |
| 45 | w := r.seen[ip] | |
| 46 | if w == nil || now.Sub(w.start) > r.window { | |
| 47 | delete(r.seen, ip) | |
| 48 | return true | |
| 49 | } | |
| 50 | return w.count < r.limit | |
| 51 | } | |
| 52 | ||
| 53 | // fail records an authentication failure for ip. | |
| 54 | func (r *rateLimiter) fail(ip string) { | |
| 55 | r.mu.Lock() | |
| 56 | defer r.mu.Unlock() | |
| 57 | now := time.Now() | |
| 58 | w := r.seen[ip] | |
| 59 | if w == nil || now.Sub(w.start) > r.window { | |
| 60 | r.seen[ip] = &ipWindow{start: now, count: 1} | |
| 61 | return | |
| 62 | } | |
| 63 | w.count++ | |
| 64 | } | |
| 65 | ||
| 66 | // success clears the IP's failure slate. | |
| 67 | func (r *rateLimiter) success(ip string) { | |
| 68 | r.mu.Lock() | |
| 69 | defer r.mu.Unlock() | |
| 70 | delete(r.seen, ip) | |
| 71 | } | |
| 72 | ||
| 73 | // firstThrottle reports true exactly once per throttled window, so the | |
| 74 | // audit log records a burst rather than every rejected attempt. | |
| 75 | func (r *rateLimiter) firstThrottle(ip string) bool { | |
| 76 | r.mu.Lock() | |
| 77 | defer r.mu.Unlock() | |
| 78 | w := r.seen[ip] | |
| 79 | if w == nil || w.audited { | |
| 80 | return false | |
| 81 | } | |
| 82 | w.audited = true | |
| 83 | return true | |
| 84 | } | |
| 85 | ||
| 86 | func remoteIP(addr net.Addr) string { | |
| 87 | if host, _, err := net.SplitHostPort(addr.String()); err == nil { | |
| 88 | return host | |
| 89 | } | |
| 90 | return addr.String() | |
| 91 | } | |
internal/sshd/sshd.go +27 −8
| @@ -15,6 +15,7 @@ import ( | ||
| 15 | 15 | "os" |
| 16 | 16 | "path/filepath" |
| 17 | 17 | "strconv" |
| 18 | "time" | |
| 18 | 19 | |
| 19 | 20 | "golang.org/x/crypto/ssh" |
| 20 | 21 | |
| @@ -28,13 +29,14 @@ import ( | ||
| 28 | 29 | ) |
| 29 | 30 | |
| 30 | 31 | type Server struct { |
| 31 | cfg config.Config | |
| 32 | st *store.Store | |
| 33 | sshCfg *ssh.ServerConfig | |
| 32 | cfg config.Config | |
| 33 | st *store.Store | |
| 34 | sshCfg *ssh.ServerConfig | |
| 35 | authLimiter *rateLimiter | |
| 34 | 36 | } |
| 35 | 37 | |
| 36 | 38 | func New(cfg config.Config, st *store.Store) (*Server, error) { |
| 37 | s := &Server{cfg: cfg, st: st} | |
| 39 | s := &Server{cfg: cfg, st: st, authLimiter: newRateLimiter(cfg.Limits.SSHAuthRate, time.Minute)} | |
| 38 | 40 | |
| 39 | 41 | sc := &ssh.ServerConfig{ |
| 40 | 42 | PublicKeyCallback: s.authenticate, |
| @@ -99,7 +101,15 @@ func generateHostKey(path string) error { | ||
| 99 | 101 | // username is ignored; identity comes from the key alone. When registration |
| 100 | 102 | // is open or invite-based, unknown keys are admitted to run exactly one |
| 101 | 103 | // command: register. |
| 102 | func (s *Server) authenticate(_ ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permissions, error) { | |
| 104 | func (s *Server) authenticate(meta ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permissions, error) { | |
| 105 | ip := remoteIP(meta.RemoteAddr()) | |
| 106 | if !s.authLimiter.allow(ip) { | |
| 107 | // One audit entry per throttled window, not per rejected attempt. | |
| 108 | if s.authLimiter.firstThrottle(ip) { | |
| 109 | s.st.Audit(0, "auth.throttled", map[string]any{"ip": ip, "rate": s.cfg.Limits.SSHAuthRate}) | |
| 110 | } | |
| 111 | return nil, fmt.Errorf("too many authentication attempts; try again shortly") | |
| 112 | } | |
| 103 | 113 | fp := ssh.FingerprintSHA256(pub) |
| 104 | 114 | key, err := s.st.SSHKeyByFingerprint(fp) |
| 105 | 115 | if err != nil { |
| @@ -108,11 +118,15 @@ func (s *Server) authenticate(_ ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permi | ||
| 108 | 118 | "anon-key": base64.StdEncoding.EncodeToString(pub.Marshal()), |
| 109 | 119 | }}, nil |
| 110 | 120 | } |
| 121 | s.authLimiter.fail(ip) | |
| 122 | s.st.Audit(0, "auth.failed", map[string]any{"ip": ip, "fingerprint": fp}) | |
| 111 | 123 | return nil, fmt.Errorf("unknown key %s", fp) |
| 112 | 124 | } |
| 125 | s.authLimiter.success(ip) | |
| 113 | 126 | return &ssh.Permissions{Extensions: map[string]string{ |
| 114 | 127 | "user-id": strconv.FormatInt(key.UserID, 10), |
| 115 | 128 | "key-id": strconv.FormatInt(key.ID, 10), |
| 129 | "key-fp": fp, | |
| 116 | 130 | "scope": key.Scope, |
| 117 | 131 | }}, nil |
| 118 | 132 | } |
| @@ -196,7 +210,7 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) | ||
| 196 | 210 | return protocol.ExitDenied |
| 197 | 211 | } |
| 198 | 212 | _ = s.st.TouchSSHKey(keyID) |
| 199 | return Exec(s.cfg, s.st, user, ext["scope"], cmdline, ch, ch, ch.Stderr()) | |
| 213 | return Exec(s.cfg, s.st, user, ext["scope"], ext["key-fp"], cmdline, ch, ch, ch.Stderr()) | |
| 200 | 214 | } |
| 201 | 215 | |
| 202 | 216 | // runAnonymous handles a session from an unregistered key: the register |
| @@ -226,8 +240,12 @@ func (s *Server) runAnonymous(ch ssh.Channel, keyB64, cmdline string) int { | ||
| 226 | 240 | // Exec runs one SSH exec command line for an authenticated key. It is the |
| 227 | 241 | // single dispatch path shared by the embedded listener and the system-sshd |
| 228 | 242 | // forced command (gitbayd shell). |
| 229 | func Exec(cfg config.Config, st *store.Store, user store.User, scope, cmdline string, | |
| 243 | func Exec(cfg config.Config, st *store.Store, user store.User, scope, source, cmdline string, | |
| 230 | 244 | stdin io.Reader, stdout, stderr io.Writer) int { |
| 245 | if user.Disabled { | |
| 246 | fmt.Fprintln(stderr, "this account is disabled; contact the instance admin") | |
| 247 | return protocol.ExitDenied | |
| 248 | } | |
| 231 | 249 | argv, err := protocol.Tokenize(cmdline) |
| 232 | 250 | if err != nil { |
| 233 | 251 | fmt.Fprintf(stderr, "cannot parse command: %v\n", err) |
| @@ -246,6 +264,7 @@ func Exec(cfg config.Config, st *store.Store, user store.User, scope, cmdline st | ||
| 246 | 264 | ctx := &control.Ctx{ |
| 247 | 265 | User: user, |
| 248 | 266 | Scope: scope, |
| 267 | Source: source, | |
| 249 | 268 | Store: st, |
| 250 | 269 | Cfg: cfg, |
| 251 | 270 | Stdin: stdin, |
| @@ -315,7 +334,7 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a | ||
| 315 | 334 | hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10), |
| 316 | 335 | hookd.EnvUserID + "=" + strconv.FormatInt(user.ID, 10), |
| 317 | 336 | } |
| 318 | if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env); err != nil { | |
| 337 | if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, cfg.Limits.MaxPackBytes); err != nil { | |
| 319 | 338 | return protocol.ExitFailure |
| 320 | 339 | } |
| 321 | 340 | return protocol.ExitOK |
internal/store/audit.go added +47
| @@ -0,0 +1,47 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import "encoding/json" | |
| 4 | ||
| 5 | // Audit appends to the security feed. Events are the product feed; this | |
| 6 | // records who did what, from where, for an operator. actorID 0 means the | |
| 7 | // host admin (gitbayd admin commands) or an unauthenticated source. | |
| 8 | func (s *Store) Audit(actorID int64, action string, data map[string]any) { | |
| 9 | var actor any | |
| 10 | if actorID != 0 { | |
| 11 | actor = actorID | |
| 12 | } | |
| 13 | raw, err := json.Marshal(data) | |
| 14 | if err != nil { | |
| 15 | raw = []byte("{}") | |
| 16 | } | |
| 17 | s.DB.Exec("INSERT INTO audit_log (actor_id, action, data_json) VALUES (?, ?, ?)", | |
| 18 | actor, action, string(raw)) | |
| 19 | } | |
| 20 | ||
| 21 | type AuditEntry struct { | |
| 22 | ID int64 `json:"id"` | |
| 23 | Actor string `json:"actor,omitempty"` | |
| 24 | Action string `json:"action"` | |
| 25 | Data string `json:"data"` | |
| 26 | CreatedAt string `json:"created_at"` | |
| 27 | } | |
| 28 | ||
| 29 | func (s *Store) AuditEntries(limit int) ([]AuditEntry, error) { | |
| 30 | rows, err := s.DB.Query(` | |
| 31 | SELECT a.id, COALESCE(u.username, ''), a.action, a.data_json, a.created_at | |
| 32 | FROM audit_log a LEFT JOIN users u ON u.id = a.actor_id | |
| 33 | ORDER BY a.id DESC LIMIT ?`, limit) | |
| 34 | if err != nil { | |
| 35 | return nil, err | |
| 36 | } | |
| 37 | defer rows.Close() | |
| 38 | var out []AuditEntry | |
| 39 | for rows.Next() { | |
| 40 | var e AuditEntry | |
| 41 | if err := rows.Scan(&e.ID, &e.Actor, &e.Action, &e.Data, &e.CreatedAt); err != nil { | |
| 42 | return nil, err | |
| 43 | } | |
| 44 | out = append(out, e) | |
| 45 | } | |
| 46 | return out, rows.Err() | |
| 47 | } | |
internal/store/migrations/0018_user_disable.down.sql added +1
| @@ -0,0 +1 @@ | ||
| 1 | ALTER TABLE users DROP COLUMN disabled; | |
internal/store/migrations/0018_user_disable.up.sql added +1
| @@ -0,0 +1 @@ | ||
| 1 | ALTER TABLE users ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0; | |
internal/store/users.go +30 −6
| @@ -12,6 +12,7 @@ type User struct { | ||
| 12 | 12 | Username string |
| 13 | 13 | IsAdmin bool |
| 14 | 14 | Pending bool // self-registered, email not yet verified |
| 15 | Disabled bool // administratively suspended | |
| 15 | 16 | } |
| 16 | 17 | |
| 17 | 18 | type SSHKey struct { |
| @@ -47,27 +48,50 @@ func (s *Store) CreateUser(username string, isAdmin bool) (int64, error) { | ||
| 47 | 48 | |
| 48 | 49 | func (s *Store) UserByUsername(name string) (User, error) { |
| 49 | 50 | var u User |
| 50 | var admin, pending int | |
| 51 | err := s.DB.QueryRow("SELECT id, username, is_admin, pending FROM users WHERE username = ?", name). | |
| 52 | Scan(&u.ID, &u.Username, &admin, &pending) | |
| 51 | var admin, pending, disabled int | |
| 52 | err := s.DB.QueryRow("SELECT id, username, is_admin, pending, disabled FROM users WHERE username = ?", name). | |
| 53 | Scan(&u.ID, &u.Username, &admin, &pending, &disabled) | |
| 53 | 54 | if errors.Is(err, sql.ErrNoRows) { |
| 54 | 55 | return u, ErrNotFound |
| 55 | 56 | } |
| 56 | 57 | u.IsAdmin = admin != 0 |
| 57 | 58 | u.Pending = pending != 0 |
| 59 | u.Disabled = disabled != 0 | |
| 58 | 60 | return u, err |
| 59 | 61 | } |
| 60 | 62 | |
| 63 | // SetUserDisabled suspends or restores an account. Disabling also drops | |
| 64 | // the user's web sessions; their keys and tokens stay registered but are | |
| 65 | // refused at every entry point until re-enabled. | |
| 66 | func (s *Store) SetUserDisabled(userID int64, disabled bool) error { | |
| 67 | v := 0 | |
| 68 | if disabled { | |
| 69 | v = 1 | |
| 70 | } | |
| 71 | res, err := s.DB.Exec("UPDATE users SET disabled = ? WHERE id = ?", v, userID) | |
| 72 | if err != nil { | |
| 73 | return err | |
| 74 | } | |
| 75 | if n, _ := res.RowsAffected(); n == 0 { | |
| 76 | return ErrNotFound | |
| 77 | } | |
| 78 | if disabled { | |
| 79 | _, err = s.DB.Exec("DELETE FROM web_sessions WHERE user_id = ?", userID) | |
| 80 | } | |
| 81 | return err | |
| 82 | } | |
| 83 | ||
| 61 | 84 | func (s *Store) UserByID(id int64) (User, error) { |
| 62 | 85 | var u User |
| 63 | var admin, pending int | |
| 64 | err := s.DB.QueryRow("SELECT id, username, is_admin, pending FROM users WHERE id = ?", id). | |
| 65 | Scan(&u.ID, &u.Username, &admin, &pending) | |
| 86 | var admin, pending, disabled int | |
| 87 | err := s.DB.QueryRow("SELECT id, username, is_admin, pending, disabled FROM users WHERE id = ?", id). | |
| 88 | Scan(&u.ID, &u.Username, &admin, &pending, &disabled) | |
| 66 | 89 | if errors.Is(err, sql.ErrNoRows) { |
| 67 | 90 | return u, ErrNotFound |
| 68 | 91 | } |
| 69 | 92 | u.IsAdmin = admin != 0 |
| 70 | 93 | u.Pending = pending != 0 |
| 94 | u.Disabled = disabled != 0 | |
| 71 | 95 | return u, err |
| 72 | 96 | } |
| 73 | 97 | |