Commit 2965d74d76
Verified · cmc
Layout: unified · split
.gitignore added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | /forge | |
| 2 | /forged | |
| 3 | *.db | |
cmd/forge/main.go added +143
| @@ -0,0 +1,143 @@ | ||
| 1 | // forge is the client CLI. It speaks to a forge server over the system ssh | |
| 2 | // binary; it is ergonomics on top of a control plane that is fully usable | |
| 3 | // from bare OpenSSH. | |
| 4 | package main | |
| 5 | ||
| 6 | import ( | |
| 7 | "fmt" | |
| 8 | "os" | |
| 9 | ||
| 10 | "github.com/spf13/cobra" | |
| 11 | ||
| 12 | "github.com/krazywarez/forge/internal/protocol" | |
| 13 | ) | |
| 14 | ||
| 15 | func main() { | |
| 16 | root := &cobra.Command{ | |
| 17 | Use: "forge", | |
| 18 | Short: "CLI-first git forge client", | |
| 19 | SilenceUsage: true, | |
| 20 | SilenceErrors: true, | |
| 21 | } | |
| 22 | root.PersistentFlags().Bool("json", false, "machine-readable output") | |
| 23 | root.PersistentFlags().String("repo", "", "owner/name (default: inferred from the origin remote)") | |
| 24 | ||
| 25 | root.AddCommand( | |
| 26 | authCmd(), | |
| 27 | repoCmd(), | |
| 28 | issueCmd(), | |
| 29 | mrCmd(), | |
| 30 | webCmd(), | |
| 31 | adminCmd(), | |
| 32 | remoteCmd(), | |
| 33 | initCmd(), | |
| 34 | ) | |
| 35 | ||
| 36 | if err := root.Execute(); err != nil { | |
| 37 | fmt.Fprintln(os.Stderr, "forge:", err) | |
| 38 | os.Exit(protocol.ExitFailure) | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | // stub returns a leaf command that fails until its milestone lands. | |
| 43 | func stub(use, short string) *cobra.Command { | |
| 44 | return &cobra.Command{ | |
| 45 | Use: use, | |
| 46 | Short: short, | |
| 47 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 48 | return fmt.Errorf("not implemented") | |
| 49 | }, | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | func group(use, short string, subs ...*cobra.Command) *cobra.Command { | |
| 54 | c := &cobra.Command{Use: use, Short: short} | |
| 55 | c.AddCommand(subs...) | |
| 56 | return c | |
| 57 | } | |
| 58 | ||
| 59 | func authCmd() *cobra.Command { | |
| 60 | return group("auth", "identity: keys, emails, whoami", | |
| 61 | stub("whoami", "show the authenticated account"), | |
| 62 | group("keys", "manage SSH keys", | |
| 63 | stub("list", "list registered SSH keys"), | |
| 64 | stub("add", "register an SSH key"), | |
| 65 | stub("remove", "remove an SSH key"), | |
| 66 | ), | |
| 67 | group("pgp", "manage OpenPGP keys", | |
| 68 | stub("list", "list registered PGP keys"), | |
| 69 | stub("add", "register a PGP key"), | |
| 70 | stub("remove", "remove a PGP key"), | |
| 71 | ), | |
| 72 | group("email", "manage email addresses", | |
| 73 | stub("add", "add an address"), | |
| 74 | stub("verify", "confirm a verification code"), | |
| 75 | ), | |
| 76 | ) | |
| 77 | } | |
| 78 | ||
| 79 | func repoCmd() *cobra.Command { | |
| 80 | return group("repo", "create and manage repositories", | |
| 81 | stub("create", "create a repository"), | |
| 82 | stub("list", "list repositories"), | |
| 83 | stub("show", "show repository details"), | |
| 84 | stub("clone", "clone via ssh"), | |
| 85 | stub("rename", "rename a repository"), | |
| 86 | stub("delete", "delete a repository"), | |
| 87 | stub("fork", "fork a repository"), | |
| 88 | stub("import", "server-side mirror from a foreign URL"), | |
| 89 | stub("settings", "get or set repository settings"), | |
| 90 | ) | |
| 91 | } | |
| 92 | ||
| 93 | func issueCmd() *cobra.Command { | |
| 94 | return group("issue", "issues", | |
| 95 | stub("create", "open an issue"), | |
| 96 | stub("list", "list issues"), | |
| 97 | stub("show", "show an issue"), | |
| 98 | stub("comment", "comment on an issue"), | |
| 99 | stub("close", "close an issue"), | |
| 100 | stub("reopen", "reopen an issue"), | |
| 101 | stub("label", "add or remove labels"), | |
| 102 | stub("assign", "assign users"), | |
| 103 | ) | |
| 104 | } | |
| 105 | ||
| 106 | func mrCmd() *cobra.Command { | |
| 107 | return group("mr", "merge requests", | |
| 108 | stub("create", "open a merge request"), | |
| 109 | stub("list", "list merge requests"), | |
| 110 | stub("show", "show a merge request"), | |
| 111 | stub("diff", "show the diff"), | |
| 112 | stub("checkout", "fetch and check out the MR head locally"), | |
| 113 | stub("comment", "comment on a merge request"), | |
| 114 | stub("review", "approve or request changes"), | |
| 115 | stub("merge", "merge (fast-forward or merge-commit)"), | |
| 116 | stub("close", "close without merging"), | |
| 117 | ) | |
| 118 | } | |
| 119 | ||
| 120 | func webCmd() *cobra.Command { | |
| 121 | return group("web", "browser session", | |
| 122 | stub("login", "mint a one-time browser login URL over ssh"), | |
| 123 | ) | |
| 124 | } | |
| 125 | ||
| 126 | func adminCmd() *cobra.Command { | |
| 127 | return group("admin", "instance administration (admin accounts only)", | |
| 128 | stub("user", "manage users"), | |
| 129 | stub("invite", "issue registration invites"), | |
| 130 | stub("stats", "instance statistics"), | |
| 131 | ) | |
| 132 | } | |
| 133 | ||
| 134 | func remoteCmd() *cobra.Command { | |
| 135 | return group("remote", "local instance profiles (no server contact)", | |
| 136 | stub("add", "add a named forge instance"), | |
| 137 | stub("list", "list configured instances"), | |
| 138 | ) | |
| 139 | } | |
| 140 | ||
| 141 | func initCmd() *cobra.Command { | |
| 142 | return stub("init", "git init + repo create + set origin, in one step") | |
| 143 | } | |
cmd/forged/main.go added +125
| @@ -0,0 +1,125 @@ | ||
| 1 | // forged is the forge server daemon. The same binary also runs in hook mode | |
| 2 | // (invoked by git via core.hooksPath) and hosts the host-local admin commands. | |
| 3 | package main | |
| 4 | ||
| 5 | import ( | |
| 6 | "fmt" | |
| 7 | "os" | |
| 8 | ||
| 9 | "github.com/spf13/cobra" | |
| 10 | ||
| 11 | "github.com/krazywarez/forge/internal/config" | |
| 12 | "github.com/krazywarez/forge/internal/store" | |
| 13 | ) | |
| 14 | ||
| 15 | var configPath string | |
| 16 | ||
| 17 | func main() { | |
| 18 | root := &cobra.Command{ | |
| 19 | Use: "forged", | |
| 20 | Short: "forge server daemon", | |
| 21 | SilenceUsage: true, | |
| 22 | SilenceErrors: true, | |
| 23 | } | |
| 24 | root.PersistentFlags().StringVar(&configPath, "config", "/etc/forge/config.toml", "path to config file") | |
| 25 | ||
| 26 | root.AddCommand( | |
| 27 | checkConfigCmd(), | |
| 28 | serveCmd(), | |
| 29 | migrateCmd(), | |
| 30 | adminCmd(), | |
| 31 | ) | |
| 32 | ||
| 33 | if err := root.Execute(); err != nil { | |
| 34 | fmt.Fprintln(os.Stderr, "forged:", err) | |
| 35 | os.Exit(1) | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | func checkConfigCmd() *cobra.Command { | |
| 40 | var noHost bool | |
| 41 | cmd := &cobra.Command{ | |
| 42 | Use: "check-config", | |
| 43 | Short: "validate the configuration and exit", | |
| 44 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 45 | cfg, err := config.Load(configPath) | |
| 46 | if err != nil { | |
| 47 | return err | |
| 48 | } | |
| 49 | if !noHost { | |
| 50 | if err := cfg.CheckHost(); err != nil { | |
| 51 | return err | |
| 52 | } | |
| 53 | } | |
| 54 | fmt.Println("config ok") | |
| 55 | return nil | |
| 56 | }, | |
| 57 | } | |
| 58 | cmd.Flags().BoolVar(&noHost, "no-host-checks", false, "skip host environment probes (port binding, paths)") | |
| 59 | return cmd | |
| 60 | } | |
| 61 | ||
| 62 | func serveCmd() *cobra.Command { | |
| 63 | return &cobra.Command{ | |
| 64 | Use: "serve", | |
| 65 | Short: "run the ssh, http, and git listeners", | |
| 66 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 67 | return fmt.Errorf("not implemented (M1)") | |
| 68 | }, | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | func migrateCmd() *cobra.Command { | |
| 73 | var to int | |
| 74 | cmd := &cobra.Command{ | |
| 75 | Use: "migrate", | |
| 76 | Short: "apply schema migrations", | |
| 77 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 78 | cfg, err := config.Load(configPath) | |
| 79 | if err != nil { | |
| 80 | return err | |
| 81 | } | |
| 82 | s, err := store.Open(cfg.Server.Root + "/forge.db") | |
| 83 | if err != nil { | |
| 84 | return err | |
| 85 | } | |
| 86 | defer s.Close() | |
| 87 | if err := s.MigrateTo(to); err != nil { | |
| 88 | return err | |
| 89 | } | |
| 90 | v, err := s.Version() | |
| 91 | if err != nil { | |
| 92 | return err | |
| 93 | } | |
| 94 | fmt.Println("schema version", v) | |
| 95 | return nil | |
| 96 | }, | |
| 97 | } | |
| 98 | cmd.Flags().IntVar(&to, "to", -1, "target schema version (-1 = latest)") | |
| 99 | return cmd | |
| 100 | } | |
| 101 | ||
| 102 | func adminCmd() *cobra.Command { | |
| 103 | admin := &cobra.Command{ | |
| 104 | Use: "admin", | |
| 105 | Short: "host-local administration", | |
| 106 | } | |
| 107 | notImplemented := func(use, short string) *cobra.Command { | |
| 108 | return &cobra.Command{ | |
| 109 | Use: use, | |
| 110 | Short: short, | |
| 111 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 112 | return fmt.Errorf("not implemented (M1)") | |
| 113 | }, | |
| 114 | } | |
| 115 | } | |
| 116 | admin.AddCommand( | |
| 117 | notImplemented("user", "create and manage users"), | |
| 118 | notImplemented("invite", "issue registration invites"), | |
| 119 | notImplemented("email", "verify user emails"), | |
| 120 | notImplemented("backup", "consistent backup: repos first, then database"), | |
| 121 | notImplemented("gc", "run git gc across repositories"), | |
| 122 | notImplemented("stats", "instance statistics"), | |
| 123 | ) | |
| 124 | return admin | |
| 125 | } | |
go.mod added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | module github.com/krazywarez/forge | |
| 2 | ||
| 3 | go 1.27.0 | |
| 4 | ||
| 5 | require ( | |
| 6 | github.com/BurntSushi/toml v1.6.0 | |
| 7 | github.com/spf13/cobra v1.10.2 | |
| 8 | modernc.org/sqlite v1.57.0 | |
| 9 | ) | |
| 10 | ||
| 11 | require ( | |
| 12 | github.com/dustin/go-humanize v1.0.1 // indirect | |
| 13 | github.com/google/uuid v1.6.0 // indirect | |
| 14 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| 15 | github.com/mattn/go-isatty v0.0.24 // indirect | |
| 16 | github.com/ncruces/go-strftime v1.0.0 // indirect | |
| 17 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect | |
| 18 | github.com/spf13/pflag v1.0.9 // indirect | |
| 19 | golang.org/x/sys v0.47.0 // indirect | |
| 20 | modernc.org/libc v1.74.4 // indirect | |
| 21 | modernc.org/mathutil v1.7.1 // indirect | |
| 22 | modernc.org/memory v1.11.0 // indirect | |
| 23 | ) | |
go.sum added +62
| @@ -0,0 +1,62 @@ | ||
| 1 | github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= | |
| 2 | github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= | |
| 3 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | |
| 4 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= | |
| 5 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= | |
| 6 | github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= | |
| 7 | github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= | |
| 8 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | |
| 9 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | |
| 10 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= | |
| 11 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= | |
| 12 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= | |
| 13 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= | |
| 14 | github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= | |
| 15 | github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= | |
| 16 | github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= | |
| 17 | github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= | |
| 18 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= | |
| 19 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= | |
| 20 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= | |
| 21 | github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= | |
| 22 | github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= | |
| 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= | |
| 25 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= | |
| 26 | golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= | |
| 27 | 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= | |
| 29 | golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= | |
| 30 | golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= | |
| 31 | golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= | |
| 32 | 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= | |
| 34 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | |
| 35 | modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= | |
| 36 | modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= | |
| 37 | modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= | |
| 38 | modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= | |
| 39 | modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= | |
| 40 | modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= | |
| 41 | modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= | |
| 42 | modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= | |
| 43 | modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= | |
| 44 | modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= | |
| 45 | modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= | |
| 46 | modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= | |
| 47 | modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= | |
| 48 | modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= | |
| 49 | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= | |
| 50 | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= | |
| 51 | modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= | |
| 52 | modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= | |
| 53 | modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= | |
| 54 | modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= | |
| 55 | modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= | |
| 56 | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= | |
| 57 | modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= | |
| 58 | modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= | |
| 59 | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= | |
| 60 | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= | |
| 61 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= | |
| 62 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= | |
internal/config/config.go added +176
| @@ -0,0 +1,176 @@ | ||
| 1 | // Package config loads and validates the forged server configuration. | |
| 2 | package config | |
| 3 | ||
| 4 | import ( | |
| 5 | "errors" | |
| 6 | "fmt" | |
| 7 | "net" | |
| 8 | "os" | |
| 9 | "strconv" | |
| 10 | ||
| 11 | "github.com/BurntSushi/toml" | |
| 12 | ) | |
| 13 | ||
| 14 | type Config struct { | |
| 15 | Server Server `toml:"server"` | |
| 16 | SSH SSH `toml:"ssh"` | |
| 17 | HTTP HTTP `toml:"http"` | |
| 18 | GitDaemon GitDaemon `toml:"git_daemon"` | |
| 19 | Web Web `toml:"web"` | |
| 20 | Registration Registration `toml:"registration"` | |
| 21 | Limits Limits `toml:"limits"` | |
| 22 | Mail Mail `toml:"mail"` | |
| 23 | } | |
| 24 | ||
| 25 | type Server struct { | |
| 26 | Root string `toml:"root"` | |
| 27 | SiteURL string `toml:"site_url"` | |
| 28 | } | |
| 29 | ||
| 30 | type SSH struct { | |
| 31 | Mode string `toml:"mode"` // embedded | system | |
| 32 | Port int `toml:"port"` | |
| 33 | HostKeys []string `toml:"host_keys"` | |
| 34 | } | |
| 35 | ||
| 36 | type HTTP struct { | |
| 37 | Addr string `toml:"addr"` | |
| 38 | TLS string `toml:"tls"` // acme | files | off | |
| 39 | } | |
| 40 | ||
| 41 | type GitDaemon struct { | |
| 42 | Enabled bool `toml:"enabled"` | |
| 43 | Port int `toml:"port"` | |
| 44 | } | |
| 45 | ||
| 46 | type Web struct { | |
| 47 | Mode string `toml:"mode"` // view_only | accounts | |
| 48 | PasswordAuth bool `toml:"password_auth"` | |
| 49 | } | |
| 50 | ||
| 51 | type Registration struct { | |
| 52 | Mode string `toml:"mode"` // closed | invite | open | |
| 53 | } | |
| 54 | ||
| 55 | type Limits struct { | |
| 56 | MaxPackBytes int64 `toml:"max_pack_bytes"` | |
| 57 | MaxBlobBytes int64 `toml:"max_blob_bytes"` | |
| 58 | CloneTimeoutSec int `toml:"clone_timeout"` | |
| 59 | SSHAuthRate int `toml:"ssh_auth_rate"` | |
| 60 | } | |
| 61 | ||
| 62 | type Mail struct { | |
| 63 | SMTPHost string `toml:"smtp_host"` | |
| 64 | From string `toml:"from"` | |
| 65 | } | |
| 66 | ||
| 67 | // Default returns the configuration used when a key is absent from the file. | |
| 68 | func Default() Config { | |
| 69 | return Config{ | |
| 70 | Server: Server{Root: "/var/lib/forge"}, | |
| 71 | SSH: SSH{Mode: "embedded", Port: 22}, | |
| 72 | HTTP: HTTP{Addr: ":443", TLS: "acme"}, | |
| 73 | Web: Web{Mode: "view_only"}, | |
| 74 | Registration: Registration{ | |
| 75 | Mode: "closed", | |
| 76 | }, | |
| 77 | GitDaemon: GitDaemon{Port: 9418}, | |
| 78 | Limits: Limits{ | |
| 79 | MaxPackBytes: 2 << 30, // 2 GiB | |
| 80 | MaxBlobBytes: 100 << 20, | |
| 81 | CloneTimeoutSec: 3600, | |
| 82 | SSHAuthRate: 10, | |
| 83 | }, | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | // Load reads path, applies defaults, and validates. It does not probe the | |
| 88 | // host (see CheckHost) so it is safe in tests and on non-target machines. | |
| 89 | func Load(path string) (Config, error) { | |
| 90 | cfg := Default() | |
| 91 | md, err := toml.DecodeFile(path, &cfg) | |
| 92 | if err != nil { | |
| 93 | return cfg, err | |
| 94 | } | |
| 95 | if u := md.Undecoded(); len(u) > 0 { | |
| 96 | return cfg, fmt.Errorf("unknown config key %q", u[0].String()) | |
| 97 | } | |
| 98 | return cfg, cfg.Validate() | |
| 99 | } | |
| 100 | ||
| 101 | func oneOf(field, val string, allowed ...string) error { | |
| 102 | for _, a := range allowed { | |
| 103 | if val == a { | |
| 104 | return nil | |
| 105 | } | |
| 106 | } | |
| 107 | return fmt.Errorf("%s must be one of %v, got %q", field, allowed, val) | |
| 108 | } | |
| 109 | ||
| 110 | // Validate applies the static contradiction checks from the plan. | |
| 111 | func (c Config) Validate() error { | |
| 112 | var errs []error | |
| 113 | ||
| 114 | if c.Server.Root == "" { | |
| 115 | errs = append(errs, errors.New("server.root is required")) | |
| 116 | } | |
| 117 | if c.Server.SiteURL == "" { | |
| 118 | errs = append(errs, errors.New("server.site_url is required")) | |
| 119 | } | |
| 120 | if err := oneOf("ssh.mode", c.SSH.Mode, "embedded", "system"); err != nil { | |
| 121 | errs = append(errs, err) | |
| 122 | } | |
| 123 | if c.SSH.Port < 1 || c.SSH.Port > 65535 { | |
| 124 | errs = append(errs, fmt.Errorf("ssh.port %d out of range", c.SSH.Port)) | |
| 125 | } | |
| 126 | if err := oneOf("http.tls", c.HTTP.TLS, "acme", "files", "off"); err != nil { | |
| 127 | errs = append(errs, err) | |
| 128 | } | |
| 129 | if err := oneOf("web.mode", c.Web.Mode, "view_only", "accounts"); err != nil { | |
| 130 | errs = append(errs, err) | |
| 131 | } | |
| 132 | if err := oneOf("registration.mode", c.Registration.Mode, "closed", "invite", "open"); err != nil { | |
| 133 | errs = append(errs, err) | |
| 134 | } | |
| 135 | ||
| 136 | // Contradictions. | |
| 137 | if c.Registration.Mode != "closed" && c.Mail.SMTPHost == "" { | |
| 138 | errs = append(errs, fmt.Errorf( | |
| 139 | "registration.mode = %q requires [mail] smtp_host: email verification cannot run without SMTP", | |
| 140 | c.Registration.Mode)) | |
| 141 | } | |
| 142 | if c.SSH.Mode == "system" && c.Registration.Mode != "closed" { | |
| 143 | errs = append(errs, fmt.Errorf( | |
| 144 | "ssh.mode = \"system\" requires registration.mode = \"closed\": host sshd rejects unknown keys before the dispatcher runs, so registration by unknown key is impossible")) | |
| 145 | } | |
| 146 | if c.Web.PasswordAuth && c.Web.Mode == "view_only" { | |
| 147 | errs = append(errs, errors.New( | |
| 148 | "web.password_auth = true is meaningless with web.mode = \"view_only\": no login route exists")) | |
| 149 | } | |
| 150 | ||
| 151 | return errors.Join(errs...) | |
| 152 | } | |
| 153 | ||
| 154 | // CheckHost performs environment probes that only make sense on the target | |
| 155 | // machine: port availability for the embedded listener and root existence. | |
| 156 | func (c Config) CheckHost() error { | |
| 157 | var errs []error | |
| 158 | ||
| 159 | if st, err := os.Stat(c.Server.Root); err != nil { | |
| 160 | errs = append(errs, fmt.Errorf("server.root: %w", err)) | |
| 161 | } else if !st.IsDir() { | |
| 162 | errs = append(errs, fmt.Errorf("server.root %q is not a directory", c.Server.Root)) | |
| 163 | } | |
| 164 | ||
| 165 | if c.SSH.Mode == "embedded" { | |
| 166 | addr := net.JoinHostPort("", strconv.Itoa(c.SSH.Port)) | |
| 167 | ln, err := net.Listen("tcp", addr) | |
| 168 | if err != nil { | |
| 169 | errs = append(errs, fmt.Errorf("ssh.port %d is not bindable (already in use by another daemon?): %w", c.SSH.Port, err)) | |
| 170 | } else { | |
| 171 | ln.Close() | |
| 172 | } | |
| 173 | } | |
| 174 | ||
| 175 | return errors.Join(errs...) | |
| 176 | } | |
internal/config/config_test.go added +121
| @@ -0,0 +1,121 @@ | ||
| 1 | package config | |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "path/filepath" | |
| 6 | "strings" | |
| 7 | "testing" | |
| 8 | ) | |
| 9 | ||
| 10 | func writeConfig(t *testing.T, body string) string { | |
| 11 | t.Helper() | |
| 12 | p := filepath.Join(t.TempDir(), "config.toml") | |
| 13 | if err := os.WriteFile(p, []byte(body), 0o600); err != nil { | |
| 14 | t.Fatal(err) | |
| 15 | } | |
| 16 | return p | |
| 17 | } | |
| 18 | ||
| 19 | const minimal = ` | |
| 20 | [server] | |
| 21 | root = "/var/lib/forge" | |
| 22 | site_url = "https://forge.example" | |
| 23 | ` | |
| 24 | ||
| 25 | func TestLoadMinimal(t *testing.T) { | |
| 26 | cfg, err := Load(writeConfig(t, minimal)) | |
| 27 | if err != nil { | |
| 28 | t.Fatal(err) | |
| 29 | } | |
| 30 | // Defaults applied. | |
| 31 | if cfg.SSH.Mode != "embedded" || cfg.SSH.Port != 22 { | |
| 32 | t.Errorf("ssh defaults wrong: %+v", cfg.SSH) | |
| 33 | } | |
| 34 | if cfg.Web.Mode != "view_only" { | |
| 35 | t.Errorf("web default wrong: %+v", cfg.Web) | |
| 36 | } | |
| 37 | if cfg.Registration.Mode != "closed" { | |
| 38 | t.Errorf("registration default wrong: %+v", cfg.Registration) | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | func TestContradictions(t *testing.T) { | |
| 43 | cases := []struct { | |
| 44 | name string | |
| 45 | body string | |
| 46 | wantErr string | |
| 47 | }{ | |
| 48 | { | |
| 49 | "registration open without smtp", | |
| 50 | minimal + "\n[registration]\nmode = \"open\"\n", | |
| 51 | "requires [mail] smtp_host", | |
| 52 | }, | |
| 53 | { | |
| 54 | "system ssh with open registration", | |
| 55 | minimal + "\n[ssh]\nmode = \"system\"\n[registration]\nmode = \"open\"\n[mail]\nsmtp_host = \"mx.example\"\nfrom = \"forge@example\"\n", | |
| 56 | "requires registration.mode = \"closed\"", | |
| 57 | }, | |
| 58 | { | |
| 59 | "password auth in view_only", | |
| 60 | minimal + "\n[web]\nmode = \"view_only\"\npassword_auth = true\n", | |
| 61 | "password_auth", | |
| 62 | }, | |
| 63 | { | |
| 64 | "bad ssh mode", | |
| 65 | minimal + "\n[ssh]\nmode = \"tcp\"\n", | |
| 66 | "ssh.mode", | |
| 67 | }, | |
| 68 | { | |
| 69 | "unknown key", | |
| 70 | "[server]\nroot = \"/var/lib/forge\"\nsite_url = \"https://forge.example\"\nbogus = 1\n", | |
| 71 | "unknown config key", | |
| 72 | }, | |
| 73 | { | |
| 74 | "missing site_url", | |
| 75 | "[server]\nroot = \"/var/lib/forge\"\n", | |
| 76 | "site_url", | |
| 77 | }, | |
| 78 | } | |
| 79 | for _, tc := range cases { | |
| 80 | t.Run(tc.name, func(t *testing.T) { | |
| 81 | _, err := Load(writeConfig(t, tc.body)) | |
| 82 | if err == nil { | |
| 83 | t.Fatalf("expected error containing %q, got nil", tc.wantErr) | |
| 84 | } | |
| 85 | if !strings.Contains(err.Error(), tc.wantErr) { | |
| 86 | t.Fatalf("error %q does not contain %q", err, tc.wantErr) | |
| 87 | } | |
| 88 | }) | |
| 89 | } | |
| 90 | } | |
| 91 | ||
| 92 | func TestValidCombinations(t *testing.T) { | |
| 93 | cases := []struct { | |
| 94 | name string | |
| 95 | body string | |
| 96 | }{ | |
| 97 | { | |
| 98 | "invite with smtp", | |
| 99 | minimal + "\n[registration]\nmode = \"invite\"\n[mail]\nsmtp_host = \"mx.example\"\nfrom = \"forge@example\"\n", | |
| 100 | }, | |
| 101 | { | |
| 102 | "system ssh closed registration", | |
| 103 | minimal + "\n[ssh]\nmode = \"system\"\n", | |
| 104 | }, | |
| 105 | { | |
| 106 | "accounts web with password auth", | |
| 107 | minimal + "\n[web]\nmode = \"accounts\"\npassword_auth = true\n", | |
| 108 | }, | |
| 109 | { | |
| 110 | "closed registration, no smtp at all", | |
| 111 | minimal, | |
| 112 | }, | |
| 113 | } | |
| 114 | for _, tc := range cases { | |
| 115 | t.Run(tc.name, func(t *testing.T) { | |
| 116 | if _, err := Load(writeConfig(t, tc.body)); err != nil { | |
| 117 | t.Fatal(err) | |
| 118 | } | |
| 119 | }) | |
| 120 | } | |
| 121 | } | |
internal/policy/names.go added +60
| @@ -0,0 +1,60 @@ | ||
| 1 | // Package policy holds access-control and naming rules. | |
| 2 | package policy | |
| 3 | ||
| 4 | import ( | |
| 5 | "fmt" | |
| 6 | "regexp" | |
| 7 | ) | |
| 8 | ||
| 9 | // reservedNames are forbidden as usernames and org names because they are, or | |
| 10 | // will be, top-level web routes (the UI serves /<owner>/<name>). Any change to | |
| 11 | // the httpd mux's top-level routes must be reflected here; the httpd package | |
| 12 | // asserts this in its tests. | |
| 13 | var reservedNames = map[string]bool{ | |
| 14 | "admin": true, | |
| 15 | "api": true, | |
| 16 | "archive": true, | |
| 17 | "explore": true, | |
| 18 | "login": true, | |
| 19 | "logout": true, | |
| 20 | "new": true, | |
| 21 | "raw": true, | |
| 22 | "register": true, | |
| 23 | "settings": true, | |
| 24 | "static": true, | |
| 25 | } | |
| 26 | ||
| 27 | // namePat matches valid user, org, and repo names: lowercase alphanumerics, | |
| 28 | // dot, dash, underscore; must start with an alphanumeric. Dots are further | |
| 29 | // restricted by ValidateName to avoid "." / ".." and ".git" suffixes. | |
| 30 | var namePat = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`) | |
| 31 | ||
| 32 | // ValidateOwnerName checks a username or org name. | |
| 33 | func ValidateOwnerName(name string) error { | |
| 34 | if err := ValidateName(name); err != nil { | |
| 35 | return err | |
| 36 | } | |
| 37 | if reservedNames[name] { | |
| 38 | return fmt.Errorf("name %q is reserved", name) | |
| 39 | } | |
| 40 | return nil | |
| 41 | } | |
| 42 | ||
| 43 | // ValidateName checks a repo name (reserved words are allowed for repos; | |
| 44 | // routes are namespaced under the owner). | |
| 45 | func ValidateName(name string) error { | |
| 46 | if !namePat.MatchString(name) { | |
| 47 | return fmt.Errorf("invalid name %q: lowercase letters, digits, '.', '-', '_' only; must start with a letter or digit; max 63 chars", name) | |
| 48 | } | |
| 49 | if name == "." || name == ".." { | |
| 50 | return fmt.Errorf("invalid name %q", name) | |
| 51 | } | |
| 52 | if len(name) > 4 && name[len(name)-4:] == ".git" { | |
| 53 | return fmt.Errorf("invalid name %q: must not end in .git", name) | |
| 54 | } | |
| 55 | return nil | |
| 56 | } | |
| 57 | ||
| 58 | // Reserved reports whether name is a reserved route word. Exported so the | |
| 59 | // httpd tests can assert route/reserved-list agreement. | |
| 60 | func Reserved(name string) bool { return reservedNames[name] } | |
internal/policy/names_test.go added +42
| @@ -0,0 +1,42 @@ | ||
| 1 | package policy | |
| 2 | ||
| 3 | import "testing" | |
| 4 | ||
| 5 | func TestValidateOwnerName(t *testing.T) { | |
| 6 | valid := []string{"alice", "krz", "a", "user-1", "a.b_c", "0day"} | |
| 7 | for _, n := range valid { | |
| 8 | if err := ValidateOwnerName(n); err != nil { | |
| 9 | t.Errorf("ValidateOwnerName(%q) = %v, want nil", n, err) | |
| 10 | } | |
| 11 | } | |
| 12 | ||
| 13 | invalid := []string{ | |
| 14 | "", | |
| 15 | "Alice", // uppercase | |
| 16 | "-lead", // bad first char | |
| 17 | ".hidden", // bad first char | |
| 18 | "a b", // space | |
| 19 | "repo.git", // .git suffix | |
| 20 | "..", // | |
| 21 | "login", // reserved | |
| 22 | "admin", // reserved | |
| 23 | "static", // reserved | |
| 24 | "api", // reserved | |
| 25 | "register", // reserved | |
| 26 | } | |
| 27 | for _, n := range invalid { | |
| 28 | if err := ValidateOwnerName(n); err == nil { | |
| 29 | t.Errorf("ValidateOwnerName(%q) = nil, want error", n) | |
| 30 | } | |
| 31 | } | |
| 32 | } | |
| 33 | ||
| 34 | func TestRepoNameAllowsReservedWords(t *testing.T) { | |
| 35 | // Repo routes are namespaced under the owner, so reserved words are fine. | |
| 36 | if err := ValidateName("api"); err != nil { | |
| 37 | t.Errorf("ValidateName(\"api\") = %v, want nil", err) | |
| 38 | } | |
| 39 | if err := ValidateName("repo.git"); err == nil { | |
| 40 | t.Error("ValidateName(\"repo.git\") = nil, want error") | |
| 41 | } | |
| 42 | } | |
internal/protocol/protocol.go added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | // Package protocol defines the wire contract shared by the CLI and server: | |
| 2 | // exit codes, the JSON response envelope, and (later) the SSH command | |
| 3 | // tokenizer. | |
| 4 | package protocol | |
| 5 | ||
| 6 | // Version is the control-plane protocol version. It is embedded in every JSON | |
| 7 | // response envelope; clients check it opportunistically and refuse on major | |
| 8 | // mismatch. Bump the major on breaking envelope or command changes. | |
| 9 | const Version = 1 | |
| 10 | ||
| 11 | // Exit codes shared by the CLI and by control commands run over bare ssh. | |
| 12 | const ( | |
| 13 | ExitOK = 0 | |
| 14 | ExitFailure = 1 // general failure | |
| 15 | ExitUsage = 2 // usage error | |
| 16 | ExitNotFound = 3 | |
| 17 | ExitDenied = 4 | |
| 18 | ExitProtocol = 5 // server/protocol error | |
| 19 | ) | |
| 20 | ||
| 21 | // Envelope wraps every JSON response from a control command. | |
| 22 | type Envelope struct { | |
| 23 | ProtocolVersion int `json:"protocol_version"` | |
| 24 | Data any `json:"data,omitempty"` | |
| 25 | Error string `json:"error,omitempty"` | |
| 26 | } | |
internal/store/migrations/0001_init.down.sql added +22
| @@ -0,0 +1,22 @@ | ||
| 1 | DROP TABLE settings; | |
| 2 | DROP TABLE invites; | |
| 3 | DROP TABLE web_sessions; | |
| 4 | DROP TABLE audit_log; | |
| 5 | DROP TABLE events; | |
| 6 | DROP TABLE commit_signatures; | |
| 7 | DROP TABLE mr_reviews; | |
| 8 | DROP TABLE mr_comments; | |
| 9 | DROP TABLE merge_requests; | |
| 10 | DROP TABLE issue_assignees; | |
| 11 | DROP TABLE issue_labels; | |
| 12 | DROP TABLE labels; | |
| 13 | DROP TABLE issue_comments; | |
| 14 | DROP TABLE issues; | |
| 15 | DROP TABLE repo_access; | |
| 16 | DROP TABLE repos; | |
| 17 | DROP TABLE org_members; | |
| 18 | DROP TABLE orgs; | |
| 19 | DROP TABLE pgp_keys; | |
| 20 | DROP TABLE ssh_keys; | |
| 21 | DROP TABLE emails; | |
| 22 | DROP TABLE users; | |
internal/store/migrations/0001_init.up.sql added +210
| @@ -0,0 +1,210 @@ | ||
| 1 | CREATE TABLE users ( | |
| 2 | id INTEGER PRIMARY KEY, | |
| 3 | username TEXT NOT NULL UNIQUE, | |
| 4 | is_admin INTEGER NOT NULL DEFAULT 0, | |
| 5 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 6 | ); | |
| 7 | ||
| 8 | CREATE TABLE emails ( | |
| 9 | id INTEGER PRIMARY KEY, | |
| 10 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 11 | address TEXT NOT NULL UNIQUE, | |
| 12 | verified_at TEXT, | |
| 13 | verified_by TEXT CHECK (verified_by IN ('smtp','admin')), | |
| 14 | is_primary INTEGER NOT NULL DEFAULT 0, | |
| 15 | CHECK ((verified_at IS NULL) = (verified_by IS NULL)) | |
| 16 | ); | |
| 17 | CREATE INDEX emails_user ON emails(user_id); | |
| 18 | ||
| 19 | CREATE TABLE ssh_keys ( | |
| 20 | id INTEGER PRIMARY KEY, | |
| 21 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 22 | fingerprint TEXT NOT NULL UNIQUE, | |
| 23 | algo TEXT NOT NULL, | |
| 24 | blob BLOB NOT NULL, | |
| 25 | scope TEXT NOT NULL DEFAULT 'full', | |
| 26 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 27 | last_used_at TEXT | |
| 28 | ); | |
| 29 | CREATE INDEX ssh_keys_user ON ssh_keys(user_id); | |
| 30 | ||
| 31 | CREATE TABLE pgp_keys ( | |
| 32 | id INTEGER PRIMARY KEY, | |
| 33 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 34 | fingerprint TEXT NOT NULL UNIQUE, | |
| 35 | armored TEXT NOT NULL, | |
| 36 | uids_json TEXT NOT NULL DEFAULT '[]', | |
| 37 | expires_at TEXT, | |
| 38 | revoked_at TEXT, | |
| 39 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 40 | ); | |
| 41 | CREATE INDEX pgp_keys_user ON pgp_keys(user_id); | |
| 42 | ||
| 43 | CREATE TABLE orgs ( | |
| 44 | id INTEGER PRIMARY KEY, | |
| 45 | name TEXT NOT NULL UNIQUE, | |
| 46 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 47 | ); | |
| 48 | ||
| 49 | CREATE TABLE org_members ( | |
| 50 | org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, | |
| 51 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 52 | role TEXT NOT NULL CHECK (role IN ('member','admin')), | |
| 53 | PRIMARY KEY (org_id, user_id) | |
| 54 | ); | |
| 55 | ||
| 56 | CREATE TABLE repos ( | |
| 57 | id INTEGER PRIMARY KEY, | |
| 58 | owner_kind TEXT NOT NULL CHECK (owner_kind IN ('user','org')), | |
| 59 | owner_id INTEGER NOT NULL, | |
| 60 | name TEXT NOT NULL, | |
| 61 | visibility TEXT NOT NULL CHECK (visibility IN ('public','private')), | |
| 62 | default_branch TEXT NOT NULL DEFAULT 'main', | |
| 63 | fork_of INTEGER REFERENCES repos(id) ON DELETE SET NULL, | |
| 64 | issue_counter INTEGER NOT NULL DEFAULT 0, | |
| 65 | mr_counter INTEGER NOT NULL DEFAULT 0, | |
| 66 | settings_json TEXT NOT NULL DEFAULT '{}', | |
| 67 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 68 | UNIQUE (owner_kind, owner_id, name) | |
| 69 | ); | |
| 70 | ||
| 71 | CREATE TABLE repo_access ( | |
| 72 | repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 73 | subject_kind TEXT NOT NULL CHECK (subject_kind IN ('user','org')), | |
| 74 | subject_id INTEGER NOT NULL, | |
| 75 | role TEXT NOT NULL CHECK (role IN ('read','write','admin')), | |
| 76 | PRIMARY KEY (repo_id, subject_kind, subject_id) | |
| 77 | ); | |
| 78 | ||
| 79 | CREATE TABLE issues ( | |
| 80 | id INTEGER PRIMARY KEY, | |
| 81 | repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 82 | number INTEGER NOT NULL, | |
| 83 | author_id INTEGER NOT NULL REFERENCES users(id), | |
| 84 | title TEXT NOT NULL, | |
| 85 | body TEXT NOT NULL DEFAULT '', | |
| 86 | state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open','closed')), | |
| 87 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 88 | updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 89 | UNIQUE (repo_id, number) | |
| 90 | ); | |
| 91 | ||
| 92 | CREATE TABLE issue_comments ( | |
| 93 | id INTEGER PRIMARY KEY, | |
| 94 | issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 95 | author_id INTEGER NOT NULL REFERENCES users(id), | |
| 96 | body TEXT NOT NULL, | |
| 97 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 98 | ); | |
| 99 | CREATE INDEX issue_comments_issue ON issue_comments(issue_id); | |
| 100 | ||
| 101 | CREATE TABLE labels ( | |
| 102 | id INTEGER PRIMARY KEY, | |
| 103 | repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 104 | name TEXT NOT NULL, | |
| 105 | color TEXT NOT NULL DEFAULT '', | |
| 106 | UNIQUE (repo_id, name) | |
| 107 | ); | |
| 108 | ||
| 109 | CREATE TABLE issue_labels ( | |
| 110 | issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 111 | label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE, | |
| 112 | PRIMARY KEY (issue_id, label_id) | |
| 113 | ); | |
| 114 | ||
| 115 | CREATE TABLE issue_assignees ( | |
| 116 | issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 117 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 118 | PRIMARY KEY (issue_id, user_id) | |
| 119 | ); | |
| 120 | ||
| 121 | CREATE TABLE merge_requests ( | |
| 122 | id INTEGER PRIMARY KEY, | |
| 123 | repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 124 | number INTEGER NOT NULL, | |
| 125 | author_id INTEGER NOT NULL REFERENCES users(id), | |
| 126 | source_repo_id INTEGER REFERENCES repos(id) ON DELETE SET NULL, | |
| 127 | source_ref TEXT NOT NULL, | |
| 128 | target_ref TEXT NOT NULL, | |
| 129 | title TEXT NOT NULL, | |
| 130 | body TEXT NOT NULL DEFAULT '', | |
| 131 | state TEXT NOT NULL DEFAULT 'open' | |
| 132 | CHECK (state IN ('open','merged','closed','source_gone')), | |
| 133 | head_sha TEXT NOT NULL DEFAULT '', | |
| 134 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 135 | updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 136 | UNIQUE (repo_id, number) | |
| 137 | ); | |
| 138 | ||
| 139 | CREATE TABLE mr_comments ( | |
| 140 | id INTEGER PRIMARY KEY, | |
| 141 | mr_id INTEGER NOT NULL REFERENCES merge_requests(id) ON DELETE CASCADE, | |
| 142 | author_id INTEGER NOT NULL REFERENCES users(id), | |
| 143 | body TEXT NOT NULL, | |
| 144 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 145 | ); | |
| 146 | CREATE INDEX mr_comments_mr ON mr_comments(mr_id); | |
| 147 | ||
| 148 | CREATE TABLE mr_reviews ( | |
| 149 | id INTEGER PRIMARY KEY, | |
| 150 | mr_id INTEGER NOT NULL REFERENCES merge_requests(id) ON DELETE CASCADE, | |
| 151 | reviewer_id INTEGER NOT NULL REFERENCES users(id), | |
| 152 | verdict TEXT NOT NULL CHECK (verdict IN ('approve','request_changes','comment')), | |
| 153 | head_sha TEXT NOT NULL, | |
| 154 | stale INTEGER NOT NULL DEFAULT 0, | |
| 155 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 156 | ); | |
| 157 | CREATE INDEX mr_reviews_mr ON mr_reviews(mr_id); | |
| 158 | ||
| 159 | CREATE TABLE commit_signatures ( | |
| 160 | repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 161 | commit_sha TEXT NOT NULL, | |
| 162 | state TEXT NOT NULL CHECK (state IN ( | |
| 163 | 'verified','signed_unknown_key','signed_email_mismatch', | |
| 164 | 'signed_key_expired','signed_key_revoked', | |
| 165 | 'bad_signature','unsigned')), | |
| 166 | signer_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, | |
| 167 | key_fingerprint TEXT, | |
| 168 | key_epoch INTEGER NOT NULL, | |
| 169 | checked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 170 | PRIMARY KEY (repo_id, commit_sha) | |
| 171 | ); | |
| 172 | CREATE INDEX commit_signatures_fpr ON commit_signatures(key_fingerprint); | |
| 173 | ||
| 174 | CREATE TABLE events ( | |
| 175 | id INTEGER PRIMARY KEY, | |
| 176 | repo_id INTEGER REFERENCES repos(id) ON DELETE CASCADE, | |
| 177 | actor_id INTEGER REFERENCES users(id) ON DELETE SET NULL, | |
| 178 | kind TEXT NOT NULL, | |
| 179 | data_json TEXT NOT NULL DEFAULT '{}', | |
| 180 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 181 | ); | |
| 182 | CREATE INDEX events_repo ON events(repo_id, id); | |
| 183 | ||
| 184 | CREATE TABLE audit_log ( | |
| 185 | id INTEGER PRIMARY KEY, | |
| 186 | actor_id INTEGER REFERENCES users(id) ON DELETE SET NULL, | |
| 187 | action TEXT NOT NULL, | |
| 188 | data_json TEXT NOT NULL DEFAULT '{}', | |
| 189 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 190 | ); | |
| 191 | ||
| 192 | CREATE TABLE web_sessions ( | |
| 193 | token_hash TEXT PRIMARY KEY, | |
| 194 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 195 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 196 | expires_at TEXT NOT NULL | |
| 197 | ); | |
| 198 | ||
| 199 | CREATE TABLE invites ( | |
| 200 | code_hash TEXT PRIMARY KEY, | |
| 201 | email TEXT NOT NULL, | |
| 202 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 203 | used_at TEXT | |
| 204 | ); | |
| 205 | ||
| 206 | CREATE TABLE settings ( | |
| 207 | key TEXT PRIMARY KEY, | |
| 208 | value TEXT NOT NULL | |
| 209 | ); | |
| 210 | INSERT INTO settings (key, value) VALUES ('key_epoch', '1'); | |
internal/store/store.go added +167
| @@ -0,0 +1,167 @@ | ||
| 1 | // Package store owns SQLite access and schema migrations. | |
| 2 | package store | |
| 3 | ||
| 4 | import ( | |
| 5 | "database/sql" | |
| 6 | "embed" | |
| 7 | "fmt" | |
| 8 | "io/fs" | |
| 9 | "sort" | |
| 10 | "strconv" | |
| 11 | "strings" | |
| 12 | ||
| 13 | _ "modernc.org/sqlite" | |
| 14 | ) | |
| 15 | ||
| 16 | //go:embed migrations/*.sql | |
| 17 | var migrationFS embed.FS | |
| 18 | ||
| 19 | type Store struct { | |
| 20 | DB *sql.DB | |
| 21 | } | |
| 22 | ||
| 23 | // Open opens (creating if needed) the database at path with WAL mode and | |
| 24 | // foreign keys enforced. Use ":memory:" in tests. | |
| 25 | func Open(path string) (*Store, error) { | |
| 26 | dsn := path + "?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)" | |
| 27 | if path == ":memory:" { | |
| 28 | dsn = ":memory:?_pragma=foreign_keys(ON)" | |
| 29 | } | |
| 30 | db, err := sql.Open("sqlite", dsn) | |
| 31 | if err != nil { | |
| 32 | return nil, err | |
| 33 | } | |
| 34 | if err := db.Ping(); err != nil { | |
| 35 | db.Close() | |
| 36 | return nil, err | |
| 37 | } | |
| 38 | return &Store{DB: db}, nil | |
| 39 | } | |
| 40 | ||
| 41 | func (s *Store) Close() error { return s.DB.Close() } | |
| 42 | ||
| 43 | type migration struct { | |
| 44 | version int | |
| 45 | name string | |
| 46 | up string | |
| 47 | down string | |
| 48 | } | |
| 49 | ||
| 50 | func loadMigrations() ([]migration, error) { | |
| 51 | entries, err := fs.ReadDir(migrationFS, "migrations") | |
| 52 | if err != nil { | |
| 53 | return nil, err | |
| 54 | } | |
| 55 | byVersion := map[int]*migration{} | |
| 56 | for _, e := range entries { | |
| 57 | name := e.Name() | |
| 58 | // <version>_<name>.<up|down>.sql | |
| 59 | base, ok := strings.CutSuffix(name, ".sql") | |
| 60 | if !ok { | |
| 61 | return nil, fmt.Errorf("migration %q: not .sql", name) | |
| 62 | } | |
| 63 | var dir string | |
| 64 | if b, ok := strings.CutSuffix(base, ".up"); ok { | |
| 65 | base, dir = b, "up" | |
| 66 | } else if b, ok := strings.CutSuffix(base, ".down"); ok { | |
| 67 | base, dir = b, "down" | |
| 68 | } else { | |
| 69 | return nil, fmt.Errorf("migration %q: missing .up/.down", name) | |
| 70 | } | |
| 71 | verStr, rest, ok := strings.Cut(base, "_") | |
| 72 | if !ok { | |
| 73 | return nil, fmt.Errorf("migration %q: missing version prefix", name) | |
| 74 | } | |
| 75 | ver, err := strconv.Atoi(verStr) | |
| 76 | if err != nil { | |
| 77 | return nil, fmt.Errorf("migration %q: bad version: %w", name, err) | |
| 78 | } | |
| 79 | m := byVersion[ver] | |
| 80 | if m == nil { | |
| 81 | m = &migration{version: ver, name: rest} | |
| 82 | byVersion[ver] = m | |
| 83 | } | |
| 84 | sqlBytes, err := migrationFS.ReadFile("migrations/" + name) | |
| 85 | if err != nil { | |
| 86 | return nil, err | |
| 87 | } | |
| 88 | if dir == "up" { | |
| 89 | m.up = string(sqlBytes) | |
| 90 | } else { | |
| 91 | m.down = string(sqlBytes) | |
| 92 | } | |
| 93 | } | |
| 94 | var ms []migration | |
| 95 | for _, m := range byVersion { | |
| 96 | if m.up == "" || m.down == "" { | |
| 97 | return nil, fmt.Errorf("migration %d %q: missing up or down file", m.version, m.name) | |
| 98 | } | |
| 99 | ms = append(ms, *m) | |
| 100 | } | |
| 101 | sort.Slice(ms, func(i, j int) bool { return ms[i].version < ms[j].version }) | |
| 102 | for i, m := range ms { | |
| 103 | if m.version != i+1 { | |
| 104 | return nil, fmt.Errorf("migration versions not contiguous at %d", m.version) | |
| 105 | } | |
| 106 | } | |
| 107 | return ms, nil | |
| 108 | } | |
| 109 | ||
| 110 | // Version returns the current schema version (0 = empty database). | |
| 111 | func (s *Store) Version() (int, error) { | |
| 112 | var v int | |
| 113 | err := s.DB.QueryRow("PRAGMA user_version").Scan(&v) | |
| 114 | return v, err | |
| 115 | } | |
| 116 | ||
| 117 | // MigrateUp applies all pending migrations. | |
| 118 | func (s *Store) MigrateUp() error { return s.migrateTo(-1) } | |
| 119 | ||
| 120 | // MigrateTo migrates up or down to the given version. 0 empties the schema. | |
| 121 | func (s *Store) MigrateTo(target int) error { return s.migrateTo(target) } | |
| 122 | ||
| 123 | func (s *Store) migrateTo(target int) error { | |
| 124 | ms, err := loadMigrations() | |
| 125 | if err != nil { | |
| 126 | return err | |
| 127 | } | |
| 128 | if target < 0 { | |
| 129 | target = len(ms) | |
| 130 | } | |
| 131 | if target > len(ms) { | |
| 132 | return fmt.Errorf("no such schema version %d (max %d)", target, len(ms)) | |
| 133 | } | |
| 134 | cur, err := s.Version() | |
| 135 | if err != nil { | |
| 136 | return err | |
| 137 | } | |
| 138 | step := func(sqlText string, newVersion int) error { | |
| 139 | tx, err := s.DB.Begin() | |
| 140 | if err != nil { | |
| 141 | return err | |
| 142 | } | |
| 143 | defer tx.Rollback() | |
| 144 | if _, err := tx.Exec(sqlText); err != nil { | |
| 145 | return err | |
| 146 | } | |
| 147 | if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", newVersion)); err != nil { | |
| 148 | return err | |
| 149 | } | |
| 150 | return tx.Commit() | |
| 151 | } | |
| 152 | for cur < target { | |
| 153 | m := ms[cur] | |
| 154 | if err := step(m.up, m.version); err != nil { | |
| 155 | return fmt.Errorf("migration %d up: %w", m.version, err) | |
| 156 | } | |
| 157 | cur = m.version | |
| 158 | } | |
| 159 | for cur > target { | |
| 160 | m := ms[cur-1] | |
| 161 | if err := step(m.down, m.version-1); err != nil { | |
| 162 | return fmt.Errorf("migration %d down: %w", m.version, err) | |
| 163 | } | |
| 164 | cur = m.version - 1 | |
| 165 | } | |
| 166 | return nil | |
| 167 | } | |
internal/store/store_test.go added +98
| @@ -0,0 +1,98 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 4 | "path/filepath" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ) | |
| 8 | ||
| 9 | func open(t *testing.T) *Store { | |
| 10 | t.Helper() | |
| 11 | s, err := Open(filepath.Join(t.TempDir(), "forge.db")) | |
| 12 | if err != nil { | |
| 13 | t.Fatal(err) | |
| 14 | } | |
| 15 | t.Cleanup(func() { s.Close() }) | |
| 16 | return s | |
| 17 | } | |
| 18 | ||
| 19 | func TestMigrateUpDown(t *testing.T) { | |
| 20 | s := open(t) | |
| 21 | ||
| 22 | if err := s.MigrateUp(); err != nil { | |
| 23 | t.Fatal(err) | |
| 24 | } | |
| 25 | v, err := s.Version() | |
| 26 | if err != nil { | |
| 27 | t.Fatal(err) | |
| 28 | } | |
| 29 | if v < 1 { | |
| 30 | t.Fatalf("version %d after MigrateUp", v) | |
| 31 | } | |
| 32 | ||
| 33 | // Seeded settings row exists. | |
| 34 | var epoch string | |
| 35 | if err := s.DB.QueryRow("SELECT value FROM settings WHERE key = 'key_epoch'").Scan(&epoch); err != nil { | |
| 36 | t.Fatal(err) | |
| 37 | } | |
| 38 | if epoch != "1" { | |
| 39 | t.Fatalf("key_epoch = %q, want 1", epoch) | |
| 40 | } | |
| 41 | ||
| 42 | // Down to empty, then back up. | |
| 43 | if err := s.MigrateTo(0); err != nil { | |
| 44 | t.Fatal(err) | |
| 45 | } | |
| 46 | var n int | |
| 47 | if err := s.DB.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&n); err != nil { | |
| 48 | t.Fatal(err) | |
| 49 | } | |
| 50 | if n != 0 { | |
| 51 | t.Fatalf("%d tables remain after down-migration to 0", n) | |
| 52 | } | |
| 53 | if err := s.MigrateUp(); err != nil { | |
| 54 | t.Fatal(err) | |
| 55 | } | |
| 56 | // Idempotent at latest. | |
| 57 | if err := s.MigrateUp(); err != nil { | |
| 58 | t.Fatal(err) | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | func TestKeyFingerprintGloballyUnique(t *testing.T) { | |
| 63 | s := open(t) | |
| 64 | if err := s.MigrateUp(); err != nil { | |
| 65 | t.Fatal(err) | |
| 66 | } | |
| 67 | mustExec := func(q string, args ...any) { | |
| 68 | t.Helper() | |
| 69 | if _, err := s.DB.Exec(q, args...); err != nil { | |
| 70 | t.Fatal(err) | |
| 71 | } | |
| 72 | } | |
| 73 | mustExec("INSERT INTO users (username) VALUES ('alice'), ('bob')") | |
| 74 | mustExec("INSERT INTO ssh_keys (user_id, fingerprint, algo, blob) VALUES (1, 'SHA256:aaa', 'ed25519', x'00')") | |
| 75 | ||
| 76 | // Same fingerprint on a different account must be rejected. | |
| 77 | _, err := s.DB.Exec("INSERT INTO ssh_keys (user_id, fingerprint, algo, blob) VALUES (2, 'SHA256:aaa', 'ed25519', x'00')") | |
| 78 | if err == nil || !strings.Contains(err.Error(), "UNIQUE") { | |
| 79 | t.Fatalf("duplicate ssh fingerprint across accounts: err = %v, want UNIQUE violation", err) | |
| 80 | } | |
| 81 | ||
| 82 | mustExec("INSERT INTO pgp_keys (user_id, fingerprint, armored) VALUES (1, 'FPR1', '-----')") | |
| 83 | _, err = s.DB.Exec("INSERT INTO pgp_keys (user_id, fingerprint, armored) VALUES (2, 'FPR1', '-----')") | |
| 84 | if err == nil || !strings.Contains(err.Error(), "UNIQUE") { | |
| 85 | t.Fatalf("duplicate pgp fingerprint across accounts: err = %v, want UNIQUE violation", err) | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | func TestForeignKeysEnforced(t *testing.T) { | |
| 90 | s := open(t) | |
| 91 | if err := s.MigrateUp(); err != nil { | |
| 92 | t.Fatal(err) | |
| 93 | } | |
| 94 | _, err := s.DB.Exec("INSERT INTO ssh_keys (user_id, fingerprint, algo, blob) VALUES (999, 'SHA256:zzz', 'ed25519', x'00')") | |
| 95 | if err == nil { | |
| 96 | t.Fatal("insert with dangling user_id succeeded; foreign keys are off") | |
| 97 | } | |
| 98 | } | |