Commit 10c1e6efa5

10c1e6efa565ef0053d287f0e2fffb8380ee9eba

parent: 29548d6557

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 09:27 UTC

config: the [push] section

Validated at load: with push.enabled, the four fields are required,
environment is one of two names, and key_file must parse as a PKCS#8
EC key. GITBAY_APNS_HOST redirects the endpoint for tests.

Ref #89
internal/config/config.go +76
@@ -2,6 +2,9 @@
22package config
33
44import (
5 "crypto/ecdsa"
6 "crypto/x509"
7 "encoding/pem"
58 "errors"
69 "fmt"
710 "net"
@@ -35,6 +38,7 @@ type Config struct {
3538 Mirrors Mirrors `toml:"mirrors"`
3639 Deps Deps `toml:"deps"`
3740 Retention Retention `toml:"retention"`
41 Push Push `toml:"push"`
3842 // GoImport maps vanity Go module paths to repositories, e.g.
3943 // "gitbay.org/gitbay" = "krz/gitbay". Requests carrying ?go-get=1
4044 // under a mapped path get a go-import meta tag.
@@ -211,6 +215,58 @@ type Mail struct {
211215 SMTPPass string `toml:"smtp_pass,omitempty"`
212216}
213217
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.
222type 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.
236func (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.
250func 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
214270// Default returns the configuration used when a key is absent from the file.
215271func Default() Config {
216272 return Config{
@@ -288,6 +344,26 @@ func (c Config) Validate() error {
288344 if c.Limits.MaxReposPerUser < 0 || c.Limits.MaxBytesPerUser < 0 || c.Limits.MaxSnippetsPerUser < 0 {
289345 errs = append(errs, errors.New("limits.max_repos_per_user, max_bytes_per_user and max_snippets_per_user must not be negative"))
290346 }
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 }
291367 if c.SSH.Port < 1 || c.SSH.Port > 65535 {
292368 errs = append(errs, fmt.Errorf("ssh.port %d out of range", c.SSH.Port))
293369 }
internal/config/config_test.go +106
@@ -1,6 +1,11 @@
11package config
22
33import (
4 "crypto/ecdsa"
5 "crypto/elliptic"
6 "crypto/rand"
7 "crypto/x509"
8 "encoding/pem"
49 "os"
510 "path/filepath"
611 "strings"
@@ -143,3 +148,104 @@ func TestValidCombinations(t *testing.T) {
143148 })
144149 }
145150}
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.
154func 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
176func TestPushConfigValidation(t *testing.T) {
177 keyPath := writeP8(t)
178 full := `
179[push]
180enabled = true
181key_file = "` + keyPath + `"
182key_id = "KEYID"
183team_id = "TEAMID"
184topic = "org.gitbay.gitbay"
185environment = "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.
220func 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]
227enabled = true
228key_file = "` + p + `"
229key_id = "K"
230team_id = "T"
231topic = "org.gitbay.gitbay"
232environment = "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
240func 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}