Commit c0b0f1fca2
Verified · cmc
Layout: unified · split
cmd/forged/main.go +144 −4
| @@ -4,14 +4,33 @@ package main | |||
| 4 | 4 | ||
| 5 | import ( | 5 | import ( |
| 6 | "fmt" | 6 | "fmt" |
| 7 | "log/slog" | ||
| 8 | "net" | ||
| 7 | "os" | 9 | "os" |
| 10 | "path/filepath" | ||
| 11 | "strconv" | ||
| 8 | 12 | ||
| 9 | "github.com/spf13/cobra" | 13 | "github.com/spf13/cobra" |
| 14 | "golang.org/x/crypto/ssh" | ||
| 10 | 15 | ||
| 11 | "github.com/krazywarez/forge/internal/config" | 16 | "github.com/krazywarez/forge/internal/config" |
| 17 | "github.com/krazywarez/forge/internal/policy" | ||
| 18 | "github.com/krazywarez/forge/internal/sshd" | ||
| 12 | "github.com/krazywarez/forge/internal/store" | 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 | var configPath string | 34 | var configPath string |
| 16 | 35 | ||
| 17 | func main() { | 36 | func main() { |
| @@ -64,7 +83,29 @@ func serveCmd() *cobra.Command { | |||
| 64 | Use: "serve", | 83 | Use: "serve", |
| 65 | Short: "run the ssh, http, and git listeners", | 84 | Short: "run the ssh, http, and git listeners", |
| 66 | RunE: func(cmd *cobra.Command, args []string) error { | 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 | Use: use, | 150 | Use: use, |
| 110 | Short: short, | 151 | Short: short, |
| 111 | RunE: func(cmd *cobra.Command, args []string) error { | 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 | admin.AddCommand( | 161 | admin.AddCommand( |
| 117 | notImplemented("user", "create and manage users"), | 162 | userCmd, |
| 163 | emailCmd, | ||
| 118 | notImplemented("invite", "issue registration invites"), | 164 | notImplemented("invite", "issue registration invites"), |
| 119 | notImplemented("email", "verify user emails"), | ||
| 120 | notImplemented("backup", "consistent backup: repos first, then database"), | 165 | notImplemented("backup", "consistent backup: repos first, then database"), |
| 121 | notImplemented("gc", "run git gc across repositories"), | 166 | notImplemented("gc", "run git gc across repositories"), |
| 122 | notImplemented("stats", "instance statistics"), | 167 | notImplemented("stats", "instance statistics"), |
| 123 | ) | 168 | ) |
| 124 | return admin | 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 | require ( | 5 | require ( |
| 6 | github.com/BurntSushi/toml v1.6.0 | 6 | github.com/BurntSushi/toml v1.6.0 |
| 7 | github.com/spf13/cobra v1.10.2 | 7 | github.com/spf13/cobra v1.10.2 |
| 8 | golang.org/x/crypto v0.55.0 | ||
| 8 | modernc.org/sqlite v1.57.0 | 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 | github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= | 23 | github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= |
| 24 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | 24 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= |
| 25 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= | 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 | golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= | 28 | golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= |
| 27 | golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= | 29 | golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= |
| 28 | golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= | 30 | golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= |
| 29 | golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= | 31 | golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| 30 | golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= | 32 | golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= |
| 31 | golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= | 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 | golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= | 36 | golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= |
| 33 | golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= | 37 | golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= |
| 34 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | 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 | } | ||