Commit c0b0f1fca2
Verified · cmc
Layout: unified · split
cmd/forged/main.go +144 −4
| @@ -4,14 +4,33 @@ package main | ||
| 4 | 4 | |
| 5 | 5 | import ( |
| 6 | 6 | "fmt" |
| 7 | "log/slog" | |
| 8 | "net" | |
| 7 | 9 | "os" |
| 10 | "path/filepath" | |
| 11 | "strconv" | |
| 8 | 12 | |
| 9 | 13 | "github.com/spf13/cobra" |
| 14 | "golang.org/x/crypto/ssh" | |
| 10 | 15 | |
| 11 | 16 | "github.com/krazywarez/forge/internal/config" |
| 17 | "github.com/krazywarez/forge/internal/policy" | |
| 18 | "github.com/krazywarez/forge/internal/sshd" | |
| 12 | 19 | "github.com/krazywarez/forge/internal/store" |
| 13 | 20 | ) |
| 14 | 21 | |
| 22 | func openStore(cfg config.Config) (*store.Store, error) { | |
| 23 | s, err := store.Open(filepath.Join(cfg.Server.Root, "forge.db")) | |
| 24 | if err != nil { | |
| 25 | return nil, err | |
| 26 | } | |
| 27 | if err := s.MigrateUp(); err != nil { | |
| 28 | s.Close() | |
| 29 | return nil, err | |
| 30 | } | |
| 31 | return s, nil | |
| 32 | } | |
| 33 | ||
| 15 | 34 | var configPath string |
| 16 | 35 | |
| 17 | 36 | func main() { |
| @@ -64,7 +83,29 @@ func serveCmd() *cobra.Command { | ||
| 64 | 83 | Use: "serve", |
| 65 | 84 | Short: "run the ssh, http, and git listeners", |
| 66 | 85 | RunE: func(cmd *cobra.Command, args []string) error { |
| 67 | return fmt.Errorf("not implemented (M1)") | |
| 86 | cfg, err := config.Load(configPath) | |
| 87 | if err != nil { | |
| 88 | return err | |
| 89 | } | |
| 90 | st, err := openStore(cfg) | |
| 91 | if err != nil { | |
| 92 | return err | |
| 93 | } | |
| 94 | defer st.Close() | |
| 95 | ||
| 96 | if cfg.SSH.Mode != "embedded" { | |
| 97 | return fmt.Errorf("ssh.mode = %q not implemented (M9)", cfg.SSH.Mode) | |
| 98 | } | |
| 99 | srv, err := sshd.New(cfg, st) | |
| 100 | if err != nil { | |
| 101 | return err | |
| 102 | } | |
| 103 | ln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.SSH.Port))) | |
| 104 | if err != nil { | |
| 105 | return err | |
| 106 | } | |
| 107 | slog.Info("ssh listening", "addr", ln.Addr()) | |
| 108 | return srv.Serve(ln) | |
| 68 | 109 | }, |
| 69 | 110 | } |
| 70 | 111 | } |
| @@ -109,17 +150,116 @@ func adminCmd() *cobra.Command { | ||
| 109 | 150 | Use: use, |
| 110 | 151 | Short: short, |
| 111 | 152 | RunE: func(cmd *cobra.Command, args []string) error { |
| 112 | return fmt.Errorf("not implemented (M1)") | |
| 153 | return fmt.Errorf("not implemented") | |
| 113 | 154 | }, |
| 114 | 155 | } |
| 115 | 156 | } |
| 157 | userCmd := &cobra.Command{Use: "user", Short: "manage users"} | |
| 158 | userCmd.AddCommand(adminUserCreateCmd()) | |
| 159 | emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"} | |
| 160 | emailCmd.AddCommand(adminEmailVerifyCmd()) | |
| 116 | 161 | admin.AddCommand( |
| 117 | notImplemented("user", "create and manage users"), | |
| 162 | userCmd, | |
| 163 | emailCmd, | |
| 118 | 164 | notImplemented("invite", "issue registration invites"), |
| 119 | notImplemented("email", "verify user emails"), | |
| 120 | 165 | notImplemented("backup", "consistent backup: repos first, then database"), |
| 121 | 166 | notImplemented("gc", "run git gc across repositories"), |
| 122 | 167 | notImplemented("stats", "instance statistics"), |
| 123 | 168 | ) |
| 124 | 169 | return admin |
| 125 | 170 | } |
| 171 | ||
| 172 | func adminUserCreateCmd() *cobra.Command { | |
| 173 | var keyPath, email string | |
| 174 | var verified, isAdmin bool | |
| 175 | cmd := &cobra.Command{ | |
| 176 | Use: "create <username>", | |
| 177 | Short: "create a user (host-local bootstrap; the only path in closed mode)", | |
| 178 | Args: cobra.ExactArgs(1), | |
| 179 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 180 | username := args[0] | |
| 181 | if err := policy.ValidateOwnerName(username); err != nil { | |
| 182 | return err | |
| 183 | } | |
| 184 | cfg, err := config.Load(configPath) | |
| 185 | if err != nil { | |
| 186 | return err | |
| 187 | } | |
| 188 | st, err := openStore(cfg) | |
| 189 | if err != nil { | |
| 190 | return err | |
| 191 | } | |
| 192 | defer st.Close() | |
| 193 | ||
| 194 | uid, err := st.CreateUser(username, isAdmin) | |
| 195 | if err != nil { | |
| 196 | return err | |
| 197 | } | |
| 198 | if email != "" { | |
| 199 | verifiedBy := "" | |
| 200 | if verified { | |
| 201 | verifiedBy = "admin" | |
| 202 | } | |
| 203 | if err := st.AddEmail(uid, email, verifiedBy, true); err != nil { | |
| 204 | return err | |
| 205 | } | |
| 206 | } | |
| 207 | if keyPath != "" { | |
| 208 | raw, err := os.ReadFile(keyPath) | |
| 209 | if err != nil { | |
| 210 | return err | |
| 211 | } | |
| 212 | pub, _, _, _, err := ssh.ParseAuthorizedKey(raw) | |
| 213 | if err != nil { | |
| 214 | return fmt.Errorf("%s: not a public key in authorized_keys format: %w", keyPath, err) | |
| 215 | } | |
| 216 | fp := ssh.FingerprintSHA256(pub) | |
| 217 | if err := st.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full"); err != nil { | |
| 218 | return err | |
| 219 | } | |
| 220 | fmt.Println("key", fp) | |
| 221 | } | |
| 222 | fmt.Println("created user", username) | |
| 223 | return nil | |
| 224 | }, | |
| 225 | } | |
| 226 | cmd.Flags().StringVar(&keyPath, "key", "", "path to an SSH public key to register") | |
| 227 | cmd.Flags().StringVar(&email, "email", "", "primary email address") | |
| 228 | cmd.Flags().BoolVar(&verified, "verified", false, "mark the email verified (admin assertion)") | |
| 229 | cmd.Flags().BoolVar(&isAdmin, "admin", false, "grant instance admin") | |
| 230 | return cmd | |
| 231 | } | |
| 232 | ||
| 233 | func adminEmailVerifyCmd() *cobra.Command { | |
| 234 | return &cobra.Command{ | |
| 235 | Use: "verify <username> <address>", | |
| 236 | Short: "mark an email verified by admin assertion", | |
| 237 | Args: cobra.ExactArgs(2), | |
| 238 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 239 | cfg, err := config.Load(configPath) | |
| 240 | if err != nil { | |
| 241 | return err | |
| 242 | } | |
| 243 | st, err := openStore(cfg) | |
| 244 | if err != nil { | |
| 245 | return err | |
| 246 | } | |
| 247 | defer st.Close() | |
| 248 | u, err := st.UserByUsername(args[0]) | |
| 249 | if err != nil { | |
| 250 | return fmt.Errorf("user %s: %w", args[0], err) | |
| 251 | } | |
| 252 | res, err := st.DB.Exec( | |
| 253 | `UPDATE emails SET verified_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), verified_by = 'admin' | |
| 254 | WHERE user_id = ? AND address = ?`, u.ID, args[1]) | |
| 255 | if err != nil { | |
| 256 | return err | |
| 257 | } | |
| 258 | if n, _ := res.RowsAffected(); n == 0 { | |
| 259 | return fmt.Errorf("no address %s on user %s", args[1], args[0]) | |
| 260 | } | |
| 261 | fmt.Println("verified", args[1]) | |
| 262 | return nil | |
| 263 | }, | |
| 264 | } | |
| 265 | } | |
e2e/ssh_test.go added +218
| @@ -0,0 +1,218 @@ | ||
| 1 | // Package e2e drives a real forged with the real ssh and git clients. | |
| 2 | package e2e | |
| 3 | ||
| 4 | import ( | |
| 5 | "encoding/json" | |
| 6 | "fmt" | |
| 7 | "net" | |
| 8 | "os" | |
| 9 | "os/exec" | |
| 10 | "path/filepath" | |
| 11 | "strings" | |
| 12 | "testing" | |
| 13 | "time" | |
| 14 | ) | |
| 15 | ||
| 16 | type instance struct { | |
| 17 | forged string // path to built binary | |
| 18 | root string | |
| 19 | config string | |
| 20 | port int | |
| 21 | proc *exec.Cmd | |
| 22 | sshDir string // per-user client keys live here | |
| 23 | } | |
| 24 | ||
| 25 | func buildForged(t *testing.T) string { | |
| 26 | t.Helper() | |
| 27 | bin := filepath.Join(t.TempDir(), "forged") | |
| 28 | cmd := exec.Command("go", "build", "-o", bin, "github.com/krazywarez/forge/cmd/forged") | |
| 29 | cmd.Dir = ".." | |
| 30 | if out, err := cmd.CombinedOutput(); err != nil { | |
| 31 | t.Fatalf("build forged: %v\n%s", err, out) | |
| 32 | } | |
| 33 | return bin | |
| 34 | } | |
| 35 | ||
| 36 | func freePort(t *testing.T) int { | |
| 37 | t.Helper() | |
| 38 | ln, err := net.Listen("tcp", "127.0.0.1:0") | |
| 39 | if err != nil { | |
| 40 | t.Fatal(err) | |
| 41 | } | |
| 42 | defer ln.Close() | |
| 43 | return ln.Addr().(*net.TCPAddr).Port | |
| 44 | } | |
| 45 | ||
| 46 | func startInstance(t *testing.T) *instance { | |
| 47 | t.Helper() | |
| 48 | inst := &instance{ | |
| 49 | forged: buildForged(t), | |
| 50 | root: t.TempDir(), | |
| 51 | port: freePort(t), | |
| 52 | sshDir: t.TempDir(), | |
| 53 | } | |
| 54 | inst.config = filepath.Join(inst.root, "config.toml") | |
| 55 | cfg := fmt.Sprintf(` | |
| 56 | [server] | |
| 57 | root = %q | |
| 58 | site_url = "https://forge.test" | |
| 59 | [ssh] | |
| 60 | port = %d | |
| 61 | `, inst.root, inst.port) | |
| 62 | if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil { | |
| 63 | t.Fatal(err) | |
| 64 | } | |
| 65 | ||
| 66 | inst.proc = exec.Command(inst.forged, "--config", inst.config, "serve") | |
| 67 | inst.proc.Stderr = os.Stderr | |
| 68 | if err := inst.proc.Start(); err != nil { | |
| 69 | t.Fatal(err) | |
| 70 | } | |
| 71 | t.Cleanup(func() { | |
| 72 | inst.proc.Process.Kill() | |
| 73 | inst.proc.Wait() | |
| 74 | }) | |
| 75 | ||
| 76 | // Wait for the listener. | |
| 77 | deadline := time.Now().Add(10 * time.Second) | |
| 78 | for { | |
| 79 | conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", inst.port), 200*time.Millisecond) | |
| 80 | if err == nil { | |
| 81 | conn.Close() | |
| 82 | return inst | |
| 83 | } | |
| 84 | if time.Now().After(deadline) { | |
| 85 | t.Fatal("forged did not start listening") | |
| 86 | } | |
| 87 | time.Sleep(50 * time.Millisecond) | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | // admin runs a forged admin command against the instance's database. | |
| 92 | func (i *instance) admin(t *testing.T, args ...string) string { | |
| 93 | t.Helper() | |
| 94 | cmd := exec.Command(i.forged, append([]string{"--config", i.config}, args...)...) | |
| 95 | out, err := cmd.CombinedOutput() | |
| 96 | if err != nil { | |
| 97 | t.Fatalf("forged %v: %v\n%s", args, err, out) | |
| 98 | } | |
| 99 | return string(out) | |
| 100 | } | |
| 101 | ||
| 102 | // newKey generates a client keypair and returns the private key path. | |
| 103 | func (i *instance) newKey(t *testing.T, name string) string { | |
| 104 | t.Helper() | |
| 105 | priv := filepath.Join(i.sshDir, name) | |
| 106 | cmd := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", name, "-f", priv) | |
| 107 | if out, err := cmd.CombinedOutput(); err != nil { | |
| 108 | t.Fatalf("ssh-keygen: %v\n%s", err, out) | |
| 109 | } | |
| 110 | return priv | |
| 111 | } | |
| 112 | ||
| 113 | // ssh runs the real OpenSSH client against the instance with the given key. | |
| 114 | func (i *instance) ssh(t *testing.T, key string, stdin string, args ...string) (string, string, int) { | |
| 115 | t.Helper() | |
| 116 | base := []string{ | |
| 117 | "-p", fmt.Sprint(i.port), | |
| 118 | "-i", key, | |
| 119 | "-o", "IdentitiesOnly=yes", | |
| 120 | "-o", "StrictHostKeyChecking=no", | |
| 121 | "-o", "UserKnownHostsFile=" + filepath.Join(i.sshDir, "known_hosts"), | |
| 122 | "-o", "BatchMode=yes", | |
| 123 | "git@127.0.0.1", | |
| 124 | } | |
| 125 | cmd := exec.Command("ssh", append(base, args...)...) | |
| 126 | if stdin != "" { | |
| 127 | cmd.Stdin = strings.NewReader(stdin) | |
| 128 | } | |
| 129 | var out, errOut strings.Builder | |
| 130 | cmd.Stdout = &out | |
| 131 | cmd.Stderr = &errOut | |
| 132 | err := cmd.Run() | |
| 133 | code := 0 | |
| 134 | if ee, ok := err.(*exec.ExitError); ok { | |
| 135 | code = ee.ExitCode() | |
| 136 | } else if err != nil { | |
| 137 | t.Fatalf("ssh: %v", err) | |
| 138 | } | |
| 139 | return out.String(), errOut.String(), code | |
| 140 | } | |
| 141 | ||
| 142 | func TestControlPlaneOverBareSSH(t *testing.T) { | |
| 143 | inst := startInstance(t) | |
| 144 | ||
| 145 | aliceKey := inst.newKey(t, "alice") | |
| 146 | inst.admin(t, "admin", "user", "create", "alice", | |
| 147 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 148 | ||
| 149 | // whoami --json from bare OpenSSH. | |
| 150 | out, errOut, code := inst.ssh(t, aliceKey, "", "whoami", "--json") | |
| 151 | if code != 0 { | |
| 152 | t.Fatalf("whoami exit %d, stderr: %s", code, errOut) | |
| 153 | } | |
| 154 | var env struct { | |
| 155 | ProtocolVersion int `json:"protocol_version"` | |
| 156 | Data struct { | |
| 157 | Username string `json:"username"` | |
| 158 | KeyScope string `json:"key_scope"` | |
| 159 | } `json:"data"` | |
| 160 | } | |
| 161 | if err := json.Unmarshal([]byte(out), &env); err != nil { | |
| 162 | t.Fatalf("whoami output not JSON: %v\n%s", err, out) | |
| 163 | } | |
| 164 | if env.Data.Username != "alice" || env.ProtocolVersion != 1 || env.Data.KeyScope != "full" { | |
| 165 | t.Fatalf("whoami = %+v", env) | |
| 166 | } | |
| 167 | ||
| 168 | // Unknown key is refused at auth. | |
| 169 | strangerKey := inst.newKey(t, "stranger") | |
| 170 | _, _, code = inst.ssh(t, strangerKey, "", "whoami") | |
| 171 | if code == 0 { | |
| 172 | t.Fatal("unknown key was authenticated") | |
| 173 | } | |
| 174 | ||
| 175 | // keys add over stdin, then list shows both. | |
| 176 | secondKey := inst.newKey(t, "alice2") | |
| 177 | pub, _ := os.ReadFile(secondKey + ".pub") | |
| 178 | out, errOut, code = inst.ssh(t, aliceKey, string(pub), "keys", "add", "--scope", "git") | |
| 179 | if code != 0 { | |
| 180 | t.Fatalf("keys add exit %d, stderr: %s", code, errOut) | |
| 181 | } | |
| 182 | out, _, code = inst.ssh(t, aliceKey, "", "keys", "list") | |
| 183 | if code != 0 || len(strings.Split(strings.TrimSpace(out), "\n")) != 2 { | |
| 184 | t.Fatalf("keys list exit %d:\n%s", code, out) | |
| 185 | } | |
| 186 | ||
| 187 | // The git-scoped key authenticates but is denied control commands. | |
| 188 | out, errOut, code = inst.ssh(t, secondKey, "", "whoami") | |
| 189 | if code != 4 { | |
| 190 | t.Fatalf("git-scoped whoami: exit %d (want 4), stdout %q stderr %q", code, out, errOut) | |
| 191 | } | |
| 192 | if !strings.Contains(errOut, "does not allow control commands") { | |
| 193 | t.Fatalf("scope denial message missing: %q", errOut) | |
| 194 | } | |
| 195 | ||
| 196 | // Duplicate key registration: bob cannot claim alice's key, and the | |
| 197 | // message is the exact spec text, naming no account. | |
| 198 | bobKey := inst.newKey(t, "bob") | |
| 199 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | |
| 200 | alicePub, _ := os.ReadFile(aliceKey + ".pub") | |
| 201 | _, errOut, code = inst.ssh(t, bobKey, string(alicePub), "keys", "add") | |
| 202 | if code != 2 { | |
| 203 | t.Fatalf("duplicate key add: exit %d, want 2", code) | |
| 204 | } | |
| 205 | want := "that key is already registered to another account; remove it there first or use a different key" | |
| 206 | if !strings.Contains(errOut, want) { | |
| 207 | t.Fatalf("duplicate key message = %q, want %q", errOut, want) | |
| 208 | } | |
| 209 | if strings.Contains(errOut, "alice") { | |
| 210 | t.Fatalf("duplicate key message leaks account name: %q", errOut) | |
| 211 | } | |
| 212 | ||
| 213 | // Arguments with spaces survive the tokenizer round trip. | |
| 214 | _, errOut, code = inst.ssh(t, aliceKey, "", "keys", "remove", "'no such fingerprint'") | |
| 215 | if code != 3 { | |
| 216 | t.Fatalf("keys remove with spaced arg: exit %d (want 3), stderr %q", code, errOut) | |
| 217 | } | |
| 218 | } | |
go.mod +1
| @@ -5,6 +5,7 @@ go 1.27.0 | ||
| 5 | 5 | require ( |
| 6 | 6 | github.com/BurntSushi/toml v1.6.0 |
| 7 | 7 | github.com/spf13/cobra v1.10.2 |
| 8 | golang.org/x/crypto v0.55.0 | |
| 8 | 9 | modernc.org/sqlite v1.57.0 |
| 9 | 10 | ) |
| 10 | 11 | |
go.sum +4
| @@ -23,12 +23,16 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT | ||
| 23 | 23 | github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= |
| 24 | 24 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= |
| 25 | 25 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= |
| 26 | golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= | |
| 27 | golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= | |
| 26 | 28 | golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= |
| 27 | 29 | golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= |
| 28 | 30 | golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= |
| 29 | 31 | golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| 30 | 32 | golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= |
| 31 | 33 | golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| 34 | golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= | |
| 35 | golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= | |
| 32 | 36 | golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= |
| 33 | 37 | golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= |
| 34 | 38 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
internal/control/control.go added +140
| @@ -0,0 +1,140 @@ | ||
| 1 | // Package control implements the forge control commands executed over SSH. | |
| 2 | // Every command here is reachable from bare OpenSSH: argv in, JSON or plain | |
| 3 | // text on stdout, diagnostics on stderr, exit code out. | |
| 4 | package control | |
| 5 | ||
| 6 | import ( | |
| 7 | "encoding/json" | |
| 8 | "fmt" | |
| 9 | "io" | |
| 10 | "slices" | |
| 11 | ||
| 12 | "github.com/krazywarez/forge/internal/config" | |
| 13 | "github.com/krazywarez/forge/internal/protocol" | |
| 14 | "github.com/krazywarez/forge/internal/store" | |
| 15 | ) | |
| 16 | ||
| 17 | type Ctx struct { | |
| 18 | User store.User | |
| 19 | Scope string // scope of the key that authenticated this session | |
| 20 | Store *store.Store | |
| 21 | Cfg config.Config | |
| 22 | Stdin io.Reader | |
| 23 | Stdout io.Writer | |
| 24 | Stderr io.Writer | |
| 25 | JSON bool | |
| 26 | } | |
| 27 | ||
| 28 | type Command struct { | |
| 29 | Path []string // e.g. ["keys", "add"] | |
| 30 | Summary string | |
| 31 | ReadsStdin bool | |
| 32 | Run func(c *Ctx, args []string) int | |
| 33 | } | |
| 34 | ||
| 35 | var registry []Command | |
| 36 | ||
| 37 | func register(cmd Command) { registry = append(registry, cmd) } | |
| 38 | ||
| 39 | // Commands returns the registry, for the bare-ssh reachability test. | |
| 40 | func Commands() []Command { return registry } | |
| 41 | ||
| 42 | // Lookup resolves argv to a command by longest path match, returning the | |
| 43 | // command and the remaining arguments. | |
| 44 | func Lookup(argv []string) (Command, []string, bool) { | |
| 45 | best := -1 | |
| 46 | var found Command | |
| 47 | for _, cmd := range registry { | |
| 48 | if len(cmd.Path) <= len(argv) && slices.Equal(cmd.Path, argv[:len(cmd.Path)]) && len(cmd.Path) > best { | |
| 49 | best = len(cmd.Path) | |
| 50 | found = cmd | |
| 51 | } | |
| 52 | } | |
| 53 | if best < 0 { | |
| 54 | return Command{}, nil, false | |
| 55 | } | |
| 56 | return found, argv[best:], true | |
| 57 | } | |
| 58 | ||
| 59 | // Dispatch runs argv for an authenticated session. The dispatcher — not the | |
| 60 | // handlers — enforces key scope: control commands require a full-scope key. | |
| 61 | func Dispatch(c *Ctx, argv []string) int { | |
| 62 | if len(argv) == 0 { | |
| 63 | return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help") | |
| 64 | } | |
| 65 | cmd, rest, ok := Lookup(argv) | |
| 66 | if !ok { | |
| 67 | return c.fail(protocol.ExitUsage, "unknown command %q", argv[0]) | |
| 68 | } | |
| 69 | if c.Scope != "full" { | |
| 70 | return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope) | |
| 71 | } | |
| 72 | // Strip the global --json flag wherever it appears. | |
| 73 | args := rest[:0:0] | |
| 74 | for _, a := range rest { | |
| 75 | if a == "--json" { | |
| 76 | c.JSON = true | |
| 77 | continue | |
| 78 | } | |
| 79 | args = append(args, a) | |
| 80 | } | |
| 81 | if !cmd.ReadsStdin { | |
| 82 | c.Stdin = emptyReader{} | |
| 83 | } | |
| 84 | return cmd.Run(c, args) | |
| 85 | } | |
| 86 | ||
| 87 | type emptyReader struct{} | |
| 88 | ||
| 89 | func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF } | |
| 90 | ||
| 91 | // emit writes data as the command result: a JSON envelope under --json, | |
| 92 | // otherwise via the plain formatter. | |
| 93 | func (c *Ctx) emit(data any, plain func(w io.Writer)) int { | |
| 94 | if c.JSON { | |
| 95 | enc := json.NewEncoder(c.Stdout) | |
| 96 | enc.SetEscapeHTML(false) | |
| 97 | if err := enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Data: data}); err != nil { | |
| 98 | return protocol.ExitFailure | |
| 99 | } | |
| 100 | return protocol.ExitOK | |
| 101 | } | |
| 102 | plain(c.Stdout) | |
| 103 | return protocol.ExitOK | |
| 104 | } | |
| 105 | ||
| 106 | func (c *Ctx) fail(code int, format string, args ...any) int { | |
| 107 | msg := fmt.Sprintf(format, args...) | |
| 108 | if c.JSON { | |
| 109 | enc := json.NewEncoder(c.Stdout) | |
| 110 | enc.SetEscapeHTML(false) | |
| 111 | enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Error: msg}) | |
| 112 | } else { | |
| 113 | fmt.Fprintln(c.Stderr, msg) | |
| 114 | } | |
| 115 | return code | |
| 116 | } | |
| 117 | ||
| 118 | func init() { | |
| 119 | register(Command{ | |
| 120 | Path: []string{"help"}, | |
| 121 | Summary: "list available commands", | |
| 122 | Run: func(c *Ctx, args []string) int { | |
| 123 | for _, cmd := range registry { | |
| 124 | fmt.Fprintf(c.Stdout, "%-24s %s\n", joinPath(cmd.Path), cmd.Summary) | |
| 125 | } | |
| 126 | return protocol.ExitOK | |
| 127 | }, | |
| 128 | }) | |
| 129 | } | |
| 130 | ||
| 131 | func joinPath(p []string) string { | |
| 132 | out := "" | |
| 133 | for i, s := range p { | |
| 134 | if i > 0 { | |
| 135 | out += " " | |
| 136 | } | |
| 137 | out += s | |
| 138 | } | |
| 139 | return out | |
| 140 | } | |
internal/control/control_test.go added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | ||
| 7 | "github.com/krazywarez/forge/internal/protocol" | |
| 8 | ) | |
| 9 | ||
| 10 | // TestEveryCommandReachableFromBareSSH asserts that each registered command's | |
| 11 | // path, rendered exactly as a user would type it after `ssh <host>`, resolves | |
| 12 | // back to that command through the tokenizer and Lookup. This is the guard | |
| 13 | // that keeps the forge CLI optional. | |
| 14 | func TestEveryCommandReachableFromBareSSH(t *testing.T) { | |
| 15 | cmds := Commands() | |
| 16 | if len(cmds) == 0 { | |
| 17 | t.Fatal("no commands registered") | |
| 18 | } | |
| 19 | for _, cmd := range cmds { | |
| 20 | line := strings.Join(cmd.Path, " ") | |
| 21 | argv, err := protocol.Tokenize(line) | |
| 22 | if err != nil { | |
| 23 | t.Errorf("command %q not tokenizable: %v", line, err) | |
| 24 | continue | |
| 25 | } | |
| 26 | got, rest, ok := Lookup(argv) | |
| 27 | if !ok { | |
| 28 | t.Errorf("command %q not found by Lookup", line) | |
| 29 | continue | |
| 30 | } | |
| 31 | if strings.Join(got.Path, " ") != line || len(rest) != 0 { | |
| 32 | t.Errorf("Lookup(%q) resolved to %q with rest %v", line, strings.Join(got.Path, " "), rest) | |
| 33 | } | |
| 34 | if cmd.Run == nil { | |
| 35 | t.Errorf("command %q has no Run", line) | |
| 36 | } | |
| 37 | if cmd.Summary == "" { | |
| 38 | t.Errorf("command %q has no summary", line) | |
| 39 | } | |
| 40 | } | |
| 41 | } | |
| 42 | ||
| 43 | func TestLookupLongestMatch(t *testing.T) { | |
| 44 | // "keys list" must not resolve to a hypothetical shorter prefix and | |
| 45 | // unknown commands must not match. | |
| 46 | if _, _, ok := Lookup([]string{"keys"}); ok { | |
| 47 | t.Error("bare \"keys\" resolved; group prefixes must not be runnable") | |
| 48 | } | |
| 49 | if _, _, ok := Lookup([]string{"nope"}); ok { | |
| 50 | t.Error("unknown command resolved") | |
| 51 | } | |
| 52 | cmd, rest, ok := Lookup([]string{"keys", "list", "--json"}) | |
| 53 | if !ok || strings.Join(cmd.Path, " ") != "keys list" || len(rest) != 1 { | |
| 54 | t.Errorf("Lookup keys list --json = %v %v %v", cmd.Path, rest, ok) | |
| 55 | } | |
| 56 | } | |
internal/control/identity.go added +133
| @@ -0,0 +1,133 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "errors" | |
| 5 | "fmt" | |
| 6 | "io" | |
| 7 | ||
| 8 | "golang.org/x/crypto/ssh" | |
| 9 | ||
| 10 | "github.com/krazywarez/forge/internal/protocol" | |
| 11 | "github.com/krazywarez/forge/internal/store" | |
| 12 | ) | |
| 13 | ||
| 14 | func init() { | |
| 15 | register(Command{ | |
| 16 | Path: []string{"whoami"}, | |
| 17 | Summary: "show the authenticated account", | |
| 18 | Run: runWhoami, | |
| 19 | }) | |
| 20 | register(Command{ | |
| 21 | Path: []string{"keys", "list"}, | |
| 22 | Summary: "list registered SSH keys", | |
| 23 | Run: runKeysList, | |
| 24 | }) | |
| 25 | register(Command{ | |
| 26 | Path: []string{"keys", "add"}, | |
| 27 | Summary: "register an SSH public key (authorized_keys format on stdin) [--scope full|git]", | |
| 28 | ReadsStdin: true, | |
| 29 | Run: runKeysAdd, | |
| 30 | }) | |
| 31 | register(Command{ | |
| 32 | Path: []string{"keys", "remove"}, | |
| 33 | Summary: "remove an SSH key by fingerprint", | |
| 34 | Run: runKeysRemove, | |
| 35 | }) | |
| 36 | } | |
| 37 | ||
| 38 | func runWhoami(c *Ctx, args []string) int { | |
| 39 | if len(args) != 0 { | |
| 40 | return c.fail(protocol.ExitUsage, "usage: whoami [--json]") | |
| 41 | } | |
| 42 | type out struct { | |
| 43 | Username string `json:"username"` | |
| 44 | Admin bool `json:"admin"` | |
| 45 | KeyScope string `json:"key_scope"` | |
| 46 | } | |
| 47 | d := out{Username: c.User.Username, Admin: c.User.IsAdmin, KeyScope: c.Scope} | |
| 48 | return c.emit(d, func(w io.Writer) { | |
| 49 | fmt.Fprintln(w, d.Username) | |
| 50 | }) | |
| 51 | } | |
| 52 | ||
| 53 | func runKeysList(c *Ctx, args []string) int { | |
| 54 | if len(args) != 0 { | |
| 55 | return c.fail(protocol.ExitUsage, "usage: keys list [--json]") | |
| 56 | } | |
| 57 | keys, err := c.Store.ListSSHKeys(c.User.ID) | |
| 58 | if err != nil { | |
| 59 | return c.fail(protocol.ExitFailure, "listing keys: %v", err) | |
| 60 | } | |
| 61 | type out struct { | |
| 62 | Fingerprint string `json:"fingerprint"` | |
| 63 | Algo string `json:"algo"` | |
| 64 | Scope string `json:"scope"` | |
| 65 | } | |
| 66 | var ds []out | |
| 67 | for _, k := range keys { | |
| 68 | ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope}) | |
| 69 | } | |
| 70 | return c.emit(ds, func(w io.Writer) { | |
| 71 | for _, d := range ds { | |
| 72 | fmt.Fprintf(w, "%s\t%s\t%s\n", d.Fingerprint, d.Algo, d.Scope) | |
| 73 | } | |
| 74 | }) | |
| 75 | } | |
| 76 | ||
| 77 | func runKeysAdd(c *Ctx, args []string) int { | |
| 78 | scope := "full" | |
| 79 | for i := 0; i < len(args); i++ { | |
| 80 | switch args[i] { | |
| 81 | case "--scope": | |
| 82 | if i+1 >= len(args) { | |
| 83 | return c.fail(protocol.ExitUsage, "--scope requires a value") | |
| 84 | } | |
| 85 | scope = args[i+1] | |
| 86 | i++ | |
| 87 | default: | |
| 88 | return c.fail(protocol.ExitUsage, "usage: keys add [--scope full|git] < key.pub") | |
| 89 | } | |
| 90 | } | |
| 91 | if scope != "full" && scope != "git" { | |
| 92 | // deploy:* scopes are granted via repo settings, not self-service. | |
| 93 | return c.fail(protocol.ExitUsage, "scope must be full or git") | |
| 94 | } | |
| 95 | raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10)) | |
| 96 | if err != nil { | |
| 97 | return c.fail(protocol.ExitFailure, "reading key: %v", err) | |
| 98 | } | |
| 99 | pub, _, _, _, err := ssh.ParseAuthorizedKey(raw) | |
| 100 | if err != nil { | |
| 101 | return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err) | |
| 102 | } | |
| 103 | fp := ssh.FingerprintSHA256(pub) | |
| 104 | if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), scope); err != nil { | |
| 105 | if errors.Is(err, store.ErrDuplicateKey) { | |
| 106 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 107 | } | |
| 108 | return c.fail(protocol.ExitFailure, "adding key: %v", err) | |
| 109 | } | |
| 110 | type out struct { | |
| 111 | Fingerprint string `json:"fingerprint"` | |
| 112 | Scope string `json:"scope"` | |
| 113 | } | |
| 114 | d := out{fp, scope} | |
| 115 | return c.emit(d, func(w io.Writer) { | |
| 116 | fmt.Fprintf(w, "added %s (%s)\n", d.Fingerprint, d.Scope) | |
| 117 | }) | |
| 118 | } | |
| 119 | ||
| 120 | func runKeysRemove(c *Ctx, args []string) int { | |
| 121 | if len(args) != 1 { | |
| 122 | return c.fail(protocol.ExitUsage, "usage: keys remove <fingerprint>") | |
| 123 | } | |
| 124 | if err := c.Store.RemoveSSHKey(c.User.ID, args[0]); err != nil { | |
| 125 | if errors.Is(err, store.ErrNotFound) { | |
| 126 | return c.fail(protocol.ExitNotFound, "no key with fingerprint %s on your account", args[0]) | |
| 127 | } | |
| 128 | return c.fail(protocol.ExitFailure, "removing key: %v", err) | |
| 129 | } | |
| 130 | return c.emit(map[string]string{"removed": args[0]}, func(w io.Writer) { | |
| 131 | fmt.Fprintf(w, "removed %s\n", args[0]) | |
| 132 | }) | |
| 133 | } | |
internal/protocol/tokenize.go added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | package protocol | |
| 2 | ||
| 3 | import ( | |
| 4 | "errors" | |
| 5 | "fmt" | |
| 6 | "strings" | |
| 7 | ) | |
| 8 | ||
| 9 | // Tokenize splits an SSH exec command string into argv using POSIX | |
| 10 | // shell-word rules: whitespace separates words; single quotes preserve | |
| 11 | // everything literally; double quotes preserve everything except backslash | |
| 12 | // before \, ", or $; a bare backslash escapes the next character. There is | |
| 13 | // no expansion of any kind — no globbing, variables, or substitution. The | |
| 14 | // client's shell has already applied one layer of quoting before the string | |
| 15 | // reaches us. | |
| 16 | func Tokenize(s string) ([]string, error) { | |
| 17 | var argv []string | |
| 18 | var cur strings.Builder | |
| 19 | inWord := false | |
| 20 | ||
| 21 | i := 0 | |
| 22 | for i < len(s) { | |
| 23 | c := s[i] | |
| 24 | switch { | |
| 25 | case c == ' ' || c == '\t' || c == '\n': | |
| 26 | if inWord { | |
| 27 | argv = append(argv, cur.String()) | |
| 28 | cur.Reset() | |
| 29 | inWord = false | |
| 30 | } | |
| 31 | i++ | |
| 32 | case c == '\'': | |
| 33 | inWord = true | |
| 34 | end := strings.IndexByte(s[i+1:], '\'') | |
| 35 | if end < 0 { | |
| 36 | return nil, errors.New("unterminated single quote") | |
| 37 | } | |
| 38 | cur.WriteString(s[i+1 : i+1+end]) | |
| 39 | i += end + 2 | |
| 40 | case c == '"': | |
| 41 | inWord = true | |
| 42 | i++ | |
| 43 | closed := false | |
| 44 | for i < len(s) { | |
| 45 | c = s[i] | |
| 46 | if c == '"' { | |
| 47 | closed = true | |
| 48 | i++ | |
| 49 | break | |
| 50 | } | |
| 51 | if c == '\\' && i+1 < len(s) { | |
| 52 | switch s[i+1] { | |
| 53 | case '\\', '"', '$', '`': | |
| 54 | cur.WriteByte(s[i+1]) | |
| 55 | i += 2 | |
| 56 | continue | |
| 57 | } | |
| 58 | } | |
| 59 | cur.WriteByte(c) | |
| 60 | i++ | |
| 61 | } | |
| 62 | if !closed { | |
| 63 | return nil, errors.New("unterminated double quote") | |
| 64 | } | |
| 65 | case c == '\\': | |
| 66 | if i+1 >= len(s) { | |
| 67 | return nil, errors.New("trailing backslash") | |
| 68 | } | |
| 69 | inWord = true | |
| 70 | cur.WriteByte(s[i+1]) | |
| 71 | i += 2 | |
| 72 | case c == '$' || c == '`' || c == ';' || c == '&' || c == '|' || | |
| 73 | c == '<' || c == '>' || c == '(' || c == ')' || c == '*' || | |
| 74 | c == '?' || c == '[' || c == '#' || c == '~': | |
| 75 | // Unquoted shell metacharacters are rejected outright rather | |
| 76 | // than passed through: there is no shell here, and silently | |
| 77 | // treating them as literals would mask client quoting bugs. | |
| 78 | return nil, fmt.Errorf("unquoted shell metacharacter %q", c) | |
| 79 | default: | |
| 80 | inWord = true | |
| 81 | cur.WriteByte(c) | |
| 82 | i++ | |
| 83 | } | |
| 84 | } | |
| 85 | if inWord { | |
| 86 | argv = append(argv, cur.String()) | |
| 87 | } | |
| 88 | return argv, nil | |
| 89 | } | |
internal/protocol/tokenize_test.go added +94
| @@ -0,0 +1,94 @@ | ||
| 1 | package protocol | |
| 2 | ||
| 3 | import ( | |
| 4 | "reflect" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ) | |
| 8 | ||
| 9 | func TestTokenize(t *testing.T) { | |
| 10 | cases := []struct { | |
| 11 | in string | |
| 12 | want []string | |
| 13 | }{ | |
| 14 | {`whoami --json`, []string{"whoami", "--json"}}, | |
| 15 | {`repo create krz/newthing --private`, []string{"repo", "create", "krz/newthing", "--private"}}, | |
| 16 | {`git-upload-pack '/krz/hutch.git'`, []string{"git-upload-pack", "/krz/hutch.git"}}, | |
| 17 | {`issue create --title 'a b c'`, []string{"issue", "create", "--title", "a b c"}}, | |
| 18 | {`issue create --title "a \"b\" c"`, []string{"issue", "create", "--title", `a "b" c`}}, | |
| 19 | {`a\ b`, []string{"a b"}}, | |
| 20 | {`'it''s'`, []string{"its"}}, | |
| 21 | {`"don't"`, []string{"don't"}}, | |
| 22 | {" spaced \t out ", []string{"spaced", "out"}}, | |
| 23 | {`""`, []string{""}}, | |
| 24 | {``, nil}, | |
| 25 | {`--message "line1\nliteral"`, []string{"--message", `line1\nliteral`}}, | |
| 26 | } | |
| 27 | for _, tc := range cases { | |
| 28 | got, err := Tokenize(tc.in) | |
| 29 | if err != nil { | |
| 30 | t.Errorf("Tokenize(%q) error: %v", tc.in, err) | |
| 31 | continue | |
| 32 | } | |
| 33 | if !reflect.DeepEqual(got, tc.want) { | |
| 34 | t.Errorf("Tokenize(%q) = %#v, want %#v", tc.in, got, tc.want) | |
| 35 | } | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | func TestTokenizeRejects(t *testing.T) { | |
| 40 | bad := []string{ | |
| 41 | `echo $(rm -rf /)`, | |
| 42 | "`id`", | |
| 43 | `a; b`, | |
| 44 | `a | b`, | |
| 45 | `a > f`, | |
| 46 | `a & b`, | |
| 47 | `'unterminated`, | |
| 48 | `"unterminated`, | |
| 49 | `trailing\`, | |
| 50 | `glob *`, | |
| 51 | `~root`, | |
| 52 | } | |
| 53 | for _, in := range bad { | |
| 54 | if got, err := Tokenize(in); err == nil { | |
| 55 | t.Errorf("Tokenize(%q) = %#v, want error", in, got) | |
| 56 | } | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | // shellQuote quotes one word the way a POSIX client shell would. | |
| 61 | func shellQuote(w string) string { | |
| 62 | return "'" + strings.ReplaceAll(w, "'", `'\''`) + "'" | |
| 63 | } | |
| 64 | ||
| 65 | // FuzzTokenizeRoundTrip checks that any argv, single-quoted as a client | |
| 66 | // shell would emit it, tokenizes back to the identical argv. | |
| 67 | func FuzzTokenizeRoundTrip(f *testing.F) { | |
| 68 | f.Add("whoami", "--json", "") | |
| 69 | f.Add("issue create", "--title", "a 'quoted' \"title\" with $pecial\\chars") | |
| 70 | f.Add("répo", "\t", "\n\n") | |
| 71 | f.Fuzz(func(t *testing.T, a, b, c string) { | |
| 72 | want := []string{a, b, c} | |
| 73 | quoted := make([]string, len(want)) | |
| 74 | for i, w := range want { | |
| 75 | quoted[i] = shellQuote(w) | |
| 76 | } | |
| 77 | got, err := Tokenize(strings.Join(quoted, " ")) | |
| 78 | if err != nil { | |
| 79 | t.Fatalf("Tokenize error on %q: %v", strings.Join(quoted, " "), err) | |
| 80 | } | |
| 81 | if !reflect.DeepEqual(got, want) { | |
| 82 | t.Fatalf("round trip: got %#v, want %#v", got, want) | |
| 83 | } | |
| 84 | }) | |
| 85 | } | |
| 86 | ||
| 87 | // FuzzTokenizeNoPanic feeds arbitrary bytes; Tokenize must return, never panic. | |
| 88 | func FuzzTokenizeNoPanic(f *testing.F) { | |
| 89 | f.Add(`repo create 'x`) | |
| 90 | f.Add(`\\\'\"`) | |
| 91 | f.Fuzz(func(t *testing.T, s string) { | |
| 92 | _, _ = Tokenize(s) | |
| 93 | }) | |
| 94 | } | |
internal/sshd/sshd.go added +209
| @@ -0,0 +1,209 @@ | ||
| 1 | // Package sshd implements the embedded SSH listener: public-key auth against | |
| 2 | // registered keys, then dispatch to git transport or control commands. | |
| 3 | package sshd | |
| 4 | ||
| 5 | import ( | |
| 6 | "crypto/ed25519" | |
| 7 | "crypto/rand" | |
| 8 | "encoding/pem" | |
| 9 | "errors" | |
| 10 | "fmt" | |
| 11 | "log/slog" | |
| 12 | "net" | |
| 13 | "os" | |
| 14 | "path/filepath" | |
| 15 | "strconv" | |
| 16 | "strings" | |
| 17 | ||
| 18 | "golang.org/x/crypto/ssh" | |
| 19 | ||
| 20 | "github.com/krazywarez/forge/internal/config" | |
| 21 | "github.com/krazywarez/forge/internal/control" | |
| 22 | "github.com/krazywarez/forge/internal/protocol" | |
| 23 | "github.com/krazywarez/forge/internal/store" | |
| 24 | ) | |
| 25 | ||
| 26 | type Server struct { | |
| 27 | cfg config.Config | |
| 28 | st *store.Store | |
| 29 | sshCfg *ssh.ServerConfig | |
| 30 | } | |
| 31 | ||
| 32 | func New(cfg config.Config, st *store.Store) (*Server, error) { | |
| 33 | s := &Server{cfg: cfg, st: st} | |
| 34 | ||
| 35 | sc := &ssh.ServerConfig{ | |
| 36 | PublicKeyCallback: s.authenticate, | |
| 37 | ServerVersion: "SSH-2.0-forged", | |
| 38 | } | |
| 39 | signers, err := loadHostKeys(cfg) | |
| 40 | if err != nil { | |
| 41 | return nil, err | |
| 42 | } | |
| 43 | for _, sg := range signers { | |
| 44 | sc.AddHostKey(sg) | |
| 45 | } | |
| 46 | s.sshCfg = sc | |
| 47 | return s, nil | |
| 48 | } | |
| 49 | ||
| 50 | // loadHostKeys loads the configured host keys, or generates an ed25519 key | |
| 51 | // under server.root/ssh/ when none are configured. | |
| 52 | func loadHostKeys(cfg config.Config) ([]ssh.Signer, error) { | |
| 53 | paths := cfg.SSH.HostKeys | |
| 54 | if len(paths) == 0 { | |
| 55 | p := filepath.Join(cfg.Server.Root, "ssh", "host_ed25519") | |
| 56 | if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) { | |
| 57 | if err := generateHostKey(p); err != nil { | |
| 58 | return nil, fmt.Errorf("generating host key: %w", err) | |
| 59 | } | |
| 60 | slog.Info("generated ssh host key", "path", p) | |
| 61 | } | |
| 62 | paths = []string{p} | |
| 63 | } | |
| 64 | var signers []ssh.Signer | |
| 65 | for _, p := range paths { | |
| 66 | raw, err := os.ReadFile(p) | |
| 67 | if err != nil { | |
| 68 | return nil, fmt.Errorf("host key %s: %w", p, err) | |
| 69 | } | |
| 70 | sg, err := ssh.ParsePrivateKey(raw) | |
| 71 | if err != nil { | |
| 72 | return nil, fmt.Errorf("host key %s: %w", p, err) | |
| 73 | } | |
| 74 | signers = append(signers, sg) | |
| 75 | } | |
| 76 | return signers, nil | |
| 77 | } | |
| 78 | ||
| 79 | func generateHostKey(path string) error { | |
| 80 | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 81 | return err | |
| 82 | } | |
| 83 | _, priv, err := ed25519.GenerateKey(rand.Reader) | |
| 84 | if err != nil { | |
| 85 | return err | |
| 86 | } | |
| 87 | block, err := ssh.MarshalPrivateKey(priv, "") | |
| 88 | if err != nil { | |
| 89 | return err | |
| 90 | } | |
| 91 | return os.WriteFile(path, pem.EncodeToMemory(block), 0o600) | |
| 92 | } | |
| 93 | ||
| 94 | // authenticate resolves the presented key to a registered account. The SSH | |
| 95 | // username is ignored; identity comes from the key alone. | |
| 96 | func (s *Server) authenticate(_ ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permissions, error) { | |
| 97 | fp := ssh.FingerprintSHA256(pub) | |
| 98 | key, err := s.st.SSHKeyByFingerprint(fp) | |
| 99 | if err != nil { | |
| 100 | return nil, fmt.Errorf("unknown key %s", fp) | |
| 101 | } | |
| 102 | return &ssh.Permissions{Extensions: map[string]string{ | |
| 103 | "user-id": strconv.FormatInt(key.UserID, 10), | |
| 104 | "key-id": strconv.FormatInt(key.ID, 10), | |
| 105 | "scope": key.Scope, | |
| 106 | }}, nil | |
| 107 | } | |
| 108 | ||
| 109 | // Serve accepts connections on ln until it is closed. | |
| 110 | func (s *Server) Serve(ln net.Listener) error { | |
| 111 | for { | |
| 112 | conn, err := ln.Accept() | |
| 113 | if err != nil { | |
| 114 | return err | |
| 115 | } | |
| 116 | go s.handleConn(conn) | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 120 | func (s *Server) handleConn(conn net.Conn) { | |
| 121 | defer conn.Close() | |
| 122 | sconn, chans, reqs, err := ssh.NewServerConn(conn, s.sshCfg) | |
| 123 | if err != nil { | |
| 124 | return | |
| 125 | } | |
| 126 | defer sconn.Close() | |
| 127 | go ssh.DiscardRequests(reqs) | |
| 128 | ||
| 129 | for newCh := range chans { | |
| 130 | if newCh.ChannelType() != "session" { | |
| 131 | newCh.Reject(ssh.UnknownChannelType, "only session channels are supported") | |
| 132 | continue | |
| 133 | } | |
| 134 | ch, chReqs, err := newCh.Accept() | |
| 135 | if err != nil { | |
| 136 | continue | |
| 137 | } | |
| 138 | go s.handleSession(sconn, ch, chReqs) | |
| 139 | } | |
| 140 | } | |
| 141 | ||
| 142 | func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) { | |
| 143 | defer ch.Close() | |
| 144 | for req := range reqs { | |
| 145 | switch req.Type { | |
| 146 | case "exec": | |
| 147 | var payload struct{ Command string } | |
| 148 | if err := ssh.Unmarshal(req.Payload, &payload); err != nil { | |
| 149 | req.Reply(false, nil) | |
| 150 | continue | |
| 151 | } | |
| 152 | req.Reply(true, nil) | |
| 153 | code := s.runExec(sconn, ch, payload.Command) | |
| 154 | sendExit(ch, code) | |
| 155 | return | |
| 156 | case "shell": | |
| 157 | req.Reply(true, nil) | |
| 158 | fmt.Fprintf(ch, "forge control plane: interactive shells are not available.\nTry: ssh %s help\n", s.cfg.Server.SiteURL) | |
| 159 | sendExit(ch, protocol.ExitUsage) | |
| 160 | return | |
| 161 | case "pty-req", "env": | |
| 162 | // Harmless; accept and ignore. | |
| 163 | req.Reply(true, nil) | |
| 164 | default: | |
| 165 | req.Reply(false, nil) | |
| 166 | } | |
| 167 | } | |
| 168 | } | |
| 169 | ||
| 170 | func sendExit(ch ssh.Channel, code int) { | |
| 171 | var msg = struct{ Status uint32 }{uint32(code)} | |
| 172 | ch.SendRequest("exit-status", false, ssh.Marshal(&msg)) | |
| 173 | } | |
| 174 | ||
| 175 | func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) int { | |
| 176 | ext := sconn.Permissions.Extensions | |
| 177 | userID, _ := strconv.ParseInt(ext["user-id"], 10, 64) | |
| 178 | keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64) | |
| 179 | user, err := s.st.UserByID(userID) | |
| 180 | if err != nil { | |
| 181 | fmt.Fprintln(ch.Stderr(), "account no longer exists") | |
| 182 | return protocol.ExitDenied | |
| 183 | } | |
| 184 | _ = s.st.TouchSSHKey(keyID) | |
| 185 | ||
| 186 | if name, _, ok := strings.Cut(cmdline, " "); ok || name != "" { | |
| 187 | switch name { | |
| 188 | case "git-upload-pack", "git-receive-pack", "git-upload-archive": | |
| 189 | fmt.Fprintln(ch.Stderr(), "git transport not implemented (M2)") | |
| 190 | return protocol.ExitFailure | |
| 191 | } | |
| 192 | } | |
| 193 | ||
| 194 | argv, err := protocol.Tokenize(cmdline) | |
| 195 | if err != nil { | |
| 196 | fmt.Fprintf(ch.Stderr(), "cannot parse command: %v\n", err) | |
| 197 | return protocol.ExitUsage | |
| 198 | } | |
| 199 | ctx := &control.Ctx{ | |
| 200 | User: user, | |
| 201 | Scope: ext["scope"], | |
| 202 | Store: s.st, | |
| 203 | Cfg: s.cfg, | |
| 204 | Stdin: ch, | |
| 205 | Stdout: ch, | |
| 206 | Stderr: ch.Stderr(), | |
| 207 | } | |
| 208 | return control.Dispatch(ctx, argv) | |
| 209 | } | |
internal/store/users.go added +185
| @@ -0,0 +1,185 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 4 | "database/sql" | |
| 5 | "errors" | |
| 6 | "fmt" | |
| 7 | "strings" | |
| 8 | ) | |
| 9 | ||
| 10 | type User struct { | |
| 11 | ID int64 | |
| 12 | Username string | |
| 13 | IsAdmin bool | |
| 14 | } | |
| 15 | ||
| 16 | type SSHKey struct { | |
| 17 | ID int64 | |
| 18 | UserID int64 | |
| 19 | Fingerprint string | |
| 20 | Algo string | |
| 21 | Blob []byte | |
| 22 | Scope string | |
| 23 | } | |
| 24 | ||
| 25 | // ErrDuplicateKey carries the exact user-facing message from the spec. It | |
| 26 | // deliberately does not name the owning account (enumeration oracle). | |
| 27 | var ErrDuplicateKey = errors.New("that key is already registered to another account; remove it there first or use a different key") | |
| 28 | ||
| 29 | var ErrNotFound = errors.New("not found") | |
| 30 | ||
| 31 | func (s *Store) CreateUser(username string, isAdmin bool) (int64, error) { | |
| 32 | res, err := s.DB.Exec("INSERT INTO users (username, is_admin) VALUES (?, ?)", username, boolInt(isAdmin)) | |
| 33 | if err != nil { | |
| 34 | if isUniqueErr(err) { | |
| 35 | return 0, fmt.Errorf("username %q is taken", username) | |
| 36 | } | |
| 37 | return 0, err | |
| 38 | } | |
| 39 | return res.LastInsertId() | |
| 40 | } | |
| 41 | ||
| 42 | func (s *Store) UserByUsername(name string) (User, error) { | |
| 43 | var u User | |
| 44 | var admin int | |
| 45 | err := s.DB.QueryRow("SELECT id, username, is_admin FROM users WHERE username = ?", name). | |
| 46 | Scan(&u.ID, &u.Username, &admin) | |
| 47 | if errors.Is(err, sql.ErrNoRows) { | |
| 48 | return u, ErrNotFound | |
| 49 | } | |
| 50 | u.IsAdmin = admin != 0 | |
| 51 | return u, err | |
| 52 | } | |
| 53 | ||
| 54 | func (s *Store) UserByID(id int64) (User, error) { | |
| 55 | var u User | |
| 56 | var admin int | |
| 57 | err := s.DB.QueryRow("SELECT id, username, is_admin FROM users WHERE id = ?", id). | |
| 58 | Scan(&u.ID, &u.Username, &admin) | |
| 59 | if errors.Is(err, sql.ErrNoRows) { | |
| 60 | return u, ErrNotFound | |
| 61 | } | |
| 62 | u.IsAdmin = admin != 0 | |
| 63 | return u, err | |
| 64 | } | |
| 65 | ||
| 66 | // AddSSHKey registers a key and bumps the key epoch in one transaction. | |
| 67 | func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, scope string) error { | |
| 68 | tx, err := s.DB.Begin() | |
| 69 | if err != nil { | |
| 70 | return err | |
| 71 | } | |
| 72 | defer tx.Rollback() | |
| 73 | if _, err := tx.Exec( | |
| 74 | "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope) VALUES (?, ?, ?, ?, ?)", | |
| 75 | userID, fingerprint, algo, blob, scope); err != nil { | |
| 76 | if isUniqueErr(err) { | |
| 77 | return ErrDuplicateKey | |
| 78 | } | |
| 79 | return err | |
| 80 | } | |
| 81 | if err := bumpKeyEpoch(tx); err != nil { | |
| 82 | return err | |
| 83 | } | |
| 84 | return tx.Commit() | |
| 85 | } | |
| 86 | ||
| 87 | // RemoveSSHKey removes a key owned by userID and bumps the key epoch. | |
| 88 | func (s *Store) RemoveSSHKey(userID int64, fingerprint string) error { | |
| 89 | tx, err := s.DB.Begin() | |
| 90 | if err != nil { | |
| 91 | return err | |
| 92 | } | |
| 93 | defer tx.Rollback() | |
| 94 | res, err := tx.Exec("DELETE FROM ssh_keys WHERE user_id = ? AND fingerprint = ?", userID, fingerprint) | |
| 95 | if err != nil { | |
| 96 | return err | |
| 97 | } | |
| 98 | if n, _ := res.RowsAffected(); n == 0 { | |
| 99 | return ErrNotFound | |
| 100 | } | |
| 101 | if err := bumpKeyEpoch(tx); err != nil { | |
| 102 | return err | |
| 103 | } | |
| 104 | return tx.Commit() | |
| 105 | } | |
| 106 | ||
| 107 | func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) { | |
| 108 | var k SSHKey | |
| 109 | err := s.DB.QueryRow( | |
| 110 | "SELECT id, user_id, fingerprint, algo, blob, scope FROM ssh_keys WHERE fingerprint = ?", | |
| 111 | fingerprint).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope) | |
| 112 | if errors.Is(err, sql.ErrNoRows) { | |
| 113 | return k, ErrNotFound | |
| 114 | } | |
| 115 | return k, err | |
| 116 | } | |
| 117 | ||
| 118 | func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) { | |
| 119 | rows, err := s.DB.Query( | |
| 120 | "SELECT id, user_id, fingerprint, algo, blob, scope FROM ssh_keys WHERE user_id = ? ORDER BY id", | |
| 121 | userID) | |
| 122 | if err != nil { | |
| 123 | return nil, err | |
| 124 | } | |
| 125 | defer rows.Close() | |
| 126 | var keys []SSHKey | |
| 127 | for rows.Next() { | |
| 128 | var k SSHKey | |
| 129 | if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope); err != nil { | |
| 130 | return nil, err | |
| 131 | } | |
| 132 | keys = append(keys, k) | |
| 133 | } | |
| 134 | return keys, rows.Err() | |
| 135 | } | |
| 136 | ||
| 137 | // TouchSSHKey records key use; best-effort, callers ignore the error. | |
| 138 | func (s *Store) TouchSSHKey(id int64) error { | |
| 139 | _, err := s.DB.Exec( | |
| 140 | "UPDATE ssh_keys SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", id) | |
| 141 | return err | |
| 142 | } | |
| 143 | ||
| 144 | // AddEmail adds an address; verifiedBy is "" (unverified), "smtp", or "admin". | |
| 145 | func (s *Store) AddEmail(userID int64, address, verifiedBy string, primary bool) error { | |
| 146 | var vAt, vBy any | |
| 147 | if verifiedBy != "" { | |
| 148 | vAt = "now" | |
| 149 | vBy = verifiedBy | |
| 150 | } | |
| 151 | _, err := s.DB.Exec( | |
| 152 | `INSERT INTO emails (user_id, address, verified_at, verified_by, is_primary) | |
| 153 | VALUES (?, ?, CASE WHEN ? IS NULL THEN NULL ELSE strftime('%Y-%m-%dT%H:%M:%fZ','now') END, ?, ?)`, | |
| 154 | userID, address, vAt, vBy, boolInt(primary)) | |
| 155 | if isUniqueErr(err) { | |
| 156 | return fmt.Errorf("address %q is already in use", address) | |
| 157 | } | |
| 158 | return err | |
| 159 | } | |
| 160 | ||
| 161 | func (s *Store) KeyEpoch() (int64, error) { | |
| 162 | var v int64 | |
| 163 | err := s.DB.QueryRow("SELECT value FROM settings WHERE key = 'key_epoch'").Scan(&v) | |
| 164 | return v, err | |
| 165 | } | |
| 166 | ||
| 167 | type execer interface { | |
| 168 | Exec(query string, args ...any) (sql.Result, error) | |
| 169 | } | |
| 170 | ||
| 171 | func bumpKeyEpoch(tx execer) error { | |
| 172 | _, err := tx.Exec("UPDATE settings SET value = value + 1 WHERE key = 'key_epoch'") | |
| 173 | return err | |
| 174 | } | |
| 175 | ||
| 176 | func boolInt(b bool) int { | |
| 177 | if b { | |
| 178 | return 1 | |
| 179 | } | |
| 180 | return 0 | |
| 181 | } | |
| 182 | ||
| 183 | func isUniqueErr(err error) bool { | |
| 184 | return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") | |
| 185 | } | |