Commit 10c1e6efa5
Verified · cmc
internal/config/config.go +76
| @@ -2,6 +2,9 @@ | ||
| 2 | 2 | package config |
| 3 | 3 | |
| 4 | 4 | import ( |
| 5 | "crypto/ecdsa" | |
| 6 | "crypto/x509" | |
| 7 | "encoding/pem" | |
| 5 | 8 | "errors" |
| 6 | 9 | "fmt" |
| 7 | 10 | "net" |
| @@ -35,6 +38,7 @@ type Config struct { | ||
| 35 | 38 | Mirrors Mirrors `toml:"mirrors"` |
| 36 | 39 | Deps Deps `toml:"deps"` |
| 37 | 40 | Retention Retention `toml:"retention"` |
| 41 | Push Push `toml:"push"` | |
| 38 | 42 | // GoImport maps vanity Go module paths to repositories, e.g. |
| 39 | 43 | // "gitbay.org/gitbay" = "krz/gitbay". Requests carrying ?go-get=1 |
| 40 | 44 | // under a mapped path get a go-import meta tag. |
| @@ -211,6 +215,58 @@ type Mail struct { | ||
| 211 | 215 | SMTPPass string `toml:"smtp_pass,omitempty"` |
| 212 | 216 | } |
| 213 | 217 | |
| 218 | // Push is APNs delivery to registered Apple devices. A key belongs to a | |
| 219 | // bundle ID, so an instance pushes to the app built under the topic named | |
| 220 | // here and no other; a self-hoster points this at their own key and their | |
| 221 | // own build. | |
| 222 | type Push struct { | |
| 223 | Enabled bool `toml:"enabled"` | |
| 224 | KeyFile string `toml:"key_file"` | |
| 225 | KeyID string `toml:"key_id"` | |
| 226 | TeamID string `toml:"team_id"` | |
| 227 | Topic string `toml:"topic"` // the app's bundle identifier | |
| 228 | // Environment is a name rather than a URL so a typo cannot aim the | |
| 229 | // key at a host that is not Apple's. | |
| 230 | Environment string `toml:"environment"` // production | sandbox | |
| 231 | } | |
| 232 | ||
| 233 | // Host is the APNs endpoint for the configured environment. | |
| 234 | // GITBAY_APNS_HOST overrides it for tests, as GITBAY_SWEEP_TICK does for | |
| 235 | // the retention sweep. | |
| 236 | func (p Push) Host() string { | |
| 237 | if h := os.Getenv("GITBAY_APNS_HOST"); h != "" { | |
| 238 | return h | |
| 239 | } | |
| 240 | if p.Environment == "sandbox" { | |
| 241 | return "api.sandbox.push.apple.com" | |
| 242 | } | |
| 243 | return "api.push.apple.com" | |
| 244 | } | |
| 245 | ||
| 246 | // LoadAPNSKey reads Apple's .p8 provider key: a PEM-wrapped PKCS#8 | |
| 247 | // P-256 private key. Read at startup and validated there, so a | |
| 248 | // misconfigured [push] refuses to start rather than filling a queue | |
| 249 | // nobody is watching. | |
| 250 | func LoadAPNSKey(path string) (*ecdsa.PrivateKey, error) { | |
| 251 | data, err := os.ReadFile(path) | |
| 252 | if err != nil { | |
| 253 | return nil, err | |
| 254 | } | |
| 255 | block, _ := pem.Decode(data) | |
| 256 | if block == nil { | |
| 257 | return nil, errors.New("not PEM") | |
| 258 | } | |
| 259 | any, err := x509.ParsePKCS8PrivateKey(block.Bytes) | |
| 260 | if err != nil { | |
| 261 | return nil, err | |
| 262 | } | |
| 263 | key, ok := any.(*ecdsa.PrivateKey) | |
| 264 | if !ok { | |
| 265 | return nil, errors.New("not an EC private key") | |
| 266 | } | |
| 267 | return key, nil | |
| 268 | } | |
| 269 | ||
| 214 | 270 | // Default returns the configuration used when a key is absent from the file. |
| 215 | 271 | func Default() Config { |
| 216 | 272 | return Config{ |
| @@ -288,6 +344,26 @@ func (c Config) Validate() error { | ||
| 288 | 344 | if c.Limits.MaxReposPerUser < 0 || c.Limits.MaxBytesPerUser < 0 || c.Limits.MaxSnippetsPerUser < 0 { |
| 289 | 345 | errs = append(errs, errors.New("limits.max_repos_per_user, max_bytes_per_user and max_snippets_per_user must not be negative")) |
| 290 | 346 | } |
| 347 | if c.Push.Enabled { | |
| 348 | for _, f := range []struct{ name, val string }{ | |
| 349 | {"push.key_file", c.Push.KeyFile}, | |
| 350 | {"push.key_id", c.Push.KeyID}, | |
| 351 | {"push.team_id", c.Push.TeamID}, | |
| 352 | {"push.topic", c.Push.Topic}, | |
| 353 | } { | |
| 354 | if f.val == "" { | |
| 355 | errs = append(errs, fmt.Errorf("%s is required when push.enabled", f.name)) | |
| 356 | } | |
| 357 | } | |
| 358 | if err := oneOf("push.environment", c.Push.Environment, "production", "sandbox"); err != nil { | |
| 359 | errs = append(errs, err) | |
| 360 | } | |
| 361 | if c.Push.KeyFile != "" { | |
| 362 | if _, err := LoadAPNSKey(c.Push.KeyFile); err != nil { | |
| 363 | errs = append(errs, fmt.Errorf("push.key_file: %w", err)) | |
| 364 | } | |
| 365 | } | |
| 366 | } | |
| 291 | 367 | if c.SSH.Port < 1 || c.SSH.Port > 65535 { |
| 292 | 368 | errs = append(errs, fmt.Errorf("ssh.port %d out of range", c.SSH.Port)) |
| 293 | 369 | } |
internal/config/config_test.go +106
| @@ -1,6 +1,11 @@ | ||
| 1 | 1 | package config |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "crypto/ecdsa" | |
| 5 | "crypto/elliptic" | |
| 6 | "crypto/rand" | |
| 7 | "crypto/x509" | |
| 8 | "encoding/pem" | |
| 4 | 9 | "os" |
| 5 | 10 | "path/filepath" |
| 6 | 11 | "strings" |
| @@ -143,3 +148,104 @@ func TestValidCombinations(t *testing.T) { | ||
| 143 | 148 | }) |
| 144 | 149 | } |
| 145 | 150 | } |
| 151 | ||
| 152 | // writeP8 writes a PEM-wrapped PKCS#8 P-256 key, the shape of Apple's | |
| 153 | // .p8 provider key, and returns its path. | |
| 154 | func writeP8(t *testing.T) string { | |
| 155 | t.Helper() | |
| 156 | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| 157 | if err != nil { | |
| 158 | t.Fatal(err) | |
| 159 | } | |
| 160 | der, err := x509.MarshalPKCS8PrivateKey(key) | |
| 161 | if err != nil { | |
| 162 | t.Fatal(err) | |
| 163 | } | |
| 164 | p := filepath.Join(t.TempDir(), "apns.p8") | |
| 165 | f, err := os.Create(p) | |
| 166 | if err != nil { | |
| 167 | t.Fatal(err) | |
| 168 | } | |
| 169 | defer f.Close() | |
| 170 | if err := pem.Encode(f, &pem.Block{Type: "PRIVATE KEY", Bytes: der}); err != nil { | |
| 171 | t.Fatal(err) | |
| 172 | } | |
| 173 | return p | |
| 174 | } | |
| 175 | ||
| 176 | func TestPushConfigValidation(t *testing.T) { | |
| 177 | keyPath := writeP8(t) | |
| 178 | full := ` | |
| 179 | [push] | |
| 180 | enabled = true | |
| 181 | key_file = "` + keyPath + `" | |
| 182 | key_id = "KEYID" | |
| 183 | team_id = "TEAMID" | |
| 184 | topic = "org.gitbay.gitbay" | |
| 185 | environment = "production" | |
| 186 | ` | |
| 187 | cases := []struct { | |
| 188 | name string | |
| 189 | body string | |
| 190 | want string // substring of the expected error; "" means valid | |
| 191 | }{ | |
| 192 | {"disabled needs nothing", "\n[push]\nenabled = false\n", ""}, | |
| 193 | {"complete is valid", full, ""}, | |
| 194 | {"key_id required", strings.Replace(full, `key_id = "KEYID"`, "", 1), "push.key_id"}, | |
| 195 | {"team_id required", strings.Replace(full, `team_id = "TEAMID"`, "", 1), "push.team_id"}, | |
| 196 | {"topic required", strings.Replace(full, `topic = "org.gitbay.gitbay"`, "", 1), "push.topic"}, | |
| 197 | {"environment must be a known name", | |
| 198 | strings.Replace(full, `environment = "production"`, `environment = "staging"`, 1), | |
| 199 | "push.environment"}, | |
| 200 | } | |
| 201 | for _, tc := range cases { | |
| 202 | t.Run(tc.name, func(t *testing.T) { | |
| 203 | _, err := Load(writeConfig(t, minimal+tc.body)) | |
| 204 | if tc.want == "" { | |
| 205 | if err != nil { | |
| 206 | t.Fatalf("want valid, got %v", err) | |
| 207 | } | |
| 208 | return | |
| 209 | } | |
| 210 | if err == nil || !strings.Contains(err.Error(), tc.want) { | |
| 211 | t.Fatalf("want an error mentioning %q, got %v", tc.want, err) | |
| 212 | } | |
| 213 | }) | |
| 214 | } | |
| 215 | } | |
| 216 | ||
| 217 | // A key_file that exists but is not a PKCS#8 EC key is refused at load, | |
| 218 | // not at the first notice: the failure mode otherwise is a queue that | |
| 219 | // fills and dead-letters with nobody watching. | |
| 220 | func TestPushConfigRejectsAnUnparseableKey(t *testing.T) { | |
| 221 | p := filepath.Join(t.TempDir(), "junk.p8") | |
| 222 | if err := os.WriteFile(p, []byte("not a key\n"), 0o600); err != nil { | |
| 223 | t.Fatal(err) | |
| 224 | } | |
| 225 | body := ` | |
| 226 | [push] | |
| 227 | enabled = true | |
| 228 | key_file = "` + p + `" | |
| 229 | key_id = "K" | |
| 230 | team_id = "T" | |
| 231 | topic = "org.gitbay.gitbay" | |
| 232 | environment = "production" | |
| 233 | ` | |
| 234 | _, err := Load(writeConfig(t, minimal+body)) | |
| 235 | if err == nil || !strings.Contains(err.Error(), "push.key_file") { | |
| 236 | t.Fatalf("want a push.key_file error, got %v", err) | |
| 237 | } | |
| 238 | } | |
| 239 | ||
| 240 | func TestPushHost(t *testing.T) { | |
| 241 | if got := (Push{Environment: "production"}).Host(); got != "api.push.apple.com" { | |
| 242 | t.Fatalf("production host = %q", got) | |
| 243 | } | |
| 244 | if got := (Push{Environment: "sandbox"}).Host(); got != "api.sandbox.push.apple.com" { | |
| 245 | t.Fatalf("sandbox host = %q", got) | |
| 246 | } | |
| 247 | t.Setenv("GITBAY_APNS_HOST", "127.0.0.1:1234") | |
| 248 | if got := (Push{Environment: "production"}).Host(); got != "127.0.0.1:1234" { | |
| 249 | t.Fatalf("GITBAY_APNS_HOST ignored: %q", got) | |
| 250 | } | |
| 251 | } | |