carlos / platform Public

relay: masters file with retire_at and hot reload (spec §4)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev f5ac3f5935d71e8353e48ec5938fed72dddcf84a parent 93375c3
2 files changed, +157 −0
  • internal/relay/masters.go +110 −0
  • internal/relay/masters_test.go +47 −0
diff --git a/internal/relay/masters.go b/internal/relay/masters.go
new file mode 100644
index 000000000..c0ec6ff52
--- /dev/null
+++ b/internal/relay/masters.go
@@ -0,0 +1,110 @@
+package relay
+
+import (
+ "bufio"
+ "context"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "os"
+ "sort"
+ "strings"
+ "time"
+)
+
+type Master struct {
+ Kid string
+ Key []byte
+ RetireAt time.Time // zero means never
+}
+
+type Masters struct{ byKid map[string]Master }
+
+// ParseMasters reads "<kid> <base64 key> <retire_at|->" lines. Blank lines
+// and "#" comments are skipped. Keys must decode to exactly 32 bytes.
+func ParseMasters(r io.Reader) (*Masters, error) {
+ m := &Masters{byKid: map[string]Master{}}
+ sc := bufio.NewScanner(r)
+ line := 0
+ for sc.Scan() {
+ line++
+ s := strings.TrimSpace(sc.Text())
+ if s == "" || strings.HasPrefix(s, "#") {
+ continue
+ }
+ f := strings.Fields(s)
+ if len(f) != 3 {
+ return nil, fmt.Errorf("relay masters line %d: want 3 fields, got %d", line, len(f))
+ }
+ if !kidRe.MatchString(f[0]) {
+ return nil, fmt.Errorf("relay masters line %d: bad kid %q", line, f[0])
+ }
+ key, err := base64.StdEncoding.DecodeString(f[1])
+ if err != nil || len(key) != 32 {
+ return nil, fmt.Errorf("relay masters line %d: key must be base64 of 32 bytes", line)
+ }
+ var retire time.Time
+ if f[2] != "-" {
+ retire, err = time.Parse(time.RFC3339, f[2])
+ if err != nil {
+ return nil, fmt.Errorf("relay masters line %d: bad retire_at %q", line, f[2])
+ }
+ }
+ m.byKid[f[0]] = Master{Kid: f[0], Key: key, RetireAt: retire}
+ }
+ return m, sc.Err()
+}
+
+func (m *Masters) Lookup(kid string, now time.Time) (Master, bool) {
+ mk, ok := m.byKid[kid]
+ if !ok || (!mk.RetireAt.IsZero() && !now.Before(mk.RetireAt)) {
+ return Master{}, false
+ }
+ return mk, true
+}
+
+func (m *Masters) Kids() []string {
+ out := make([]string, 0, len(m.byKid))
+ for k := range m.byKid {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// WatchMasters re-reads path whenever its mtime changes, polling every
+// five seconds (fsnotify would be a dependency for one file). onChange is
+// called with the parse result or error each time the content changes,
+// and once at start.
+func WatchMasters(ctx context.Context, path string, onChange func(*Masters, error)) {
+ var last time.Time
+ tick := time.NewTicker(5 * time.Second)
+ defer tick.Stop()
+ load := func() {
+ st, err := os.Stat(path)
+ if err != nil {
+ onChange(nil, err)
+ return
+ }
+ if st.ModTime().Equal(last) {
+ return
+ }
+ last = st.ModTime()
+ f, err := os.Open(path)
+ if err != nil {
+ onChange(nil, err)
+ return
+ }
+ defer f.Close()
+ onChange(ParseMasters(f))
+ }
+ load()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-tick.C:
+ load()
+ }
+ }
+}
diff --git a/internal/relay/masters_test.go b/internal/relay/masters_test.go
new file mode 100644
index 000000000..3c19a51f1
--- /dev/null
+++ b/internal/relay/masters_test.go
@@ -0,0 +1,47 @@
+package relay
+
+import (
+ "bytes"
+ "encoding/base64"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestParseMasters(t *testing.T) {
+ in := "k1 " + b64(bytes.Repeat([]byte{1}, 32)) + " -\n" +
+ "k0 " + b64(bytes.Repeat([]byte{2}, 32)) + " 2026-09-08T00:00:00Z\n" +
+ "# comment\n\n"
+ m, err := ParseMasters(strings.NewReader(in))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := m.Kids(); !reflect.DeepEqual(got, []string{"k0", "k1"}) {
+ t.Fatalf("kids = %v", got)
+ }
+ before := time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC)
+ after := time.Date(2026, 9, 9, 0, 0, 0, 0, time.UTC)
+ if _, ok := m.Lookup("k0", before); !ok {
+ t.Fatal("k0 should be live before retire_at")
+ }
+ if _, ok := m.Lookup("k0", after); ok {
+ t.Fatal("k0 should be retired after retire_at")
+ }
+ if _, ok := m.Lookup("k1", after); !ok {
+ t.Fatal("k1 never retires")
+ }
+ if _, ok := m.Lookup("kx", before); ok {
+ t.Fatal("unknown kid")
+ }
+}
+
+func TestParseMastersRejectsBadLines(t *testing.T) {
+ for _, bad := range []string{"k1 notbase64 -", "k1 " + b64([]byte{1}) + " -", "K1 " + b64(bytes.Repeat([]byte{1}, 32)) + " -", "k1 " + b64(bytes.Repeat([]byte{1}, 32)) + " yesterday"} {
+ if _, err := ParseMasters(strings.NewReader(bad)); err == nil {
+ t.Errorf("accepted %q", bad)
+ }
+ }
+}
+
+func b64(b []byte) string { return base64.StdEncoding.EncodeToString(b) }