Commit 2ec5c18

Eric Bower  ·  2026-09-10 23:32:50 -0400 EDT
parent 208cf1e
chore(script): cert migration to valkey
2 files changed,  +540, -0
+445, -0
......@@ -0,0 +1,445 @@
1+package main
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "errors"
7+ "flag"
8+ "fmt"
9+ "io"
10+ "io/fs"
11+ "log/slog"
12+ "net"
13+ "os"
14+ "path"
15+ "path/filepath"
16+ "strconv"
17+ "strings"
18+ "time"
19+
20+ "github.com/picosh/pico/pkg/shared"
21+)
22+
23+// StorageData matches caddy-storage-redis serialization schema.
24+type StorageData struct {
25+ Value []byte `json:"value"`
26+ Modified time.Time `json:"modified"`
27+ Size int64 `json:"size"`
28+ Compression int `json:"compression"`
29+ Encryption int `json:"encryption"`
30+}
31+
32+// ValkeyClient is a lightweight RESP client using only standard library.
33+type ValkeyClient struct {
34+ conn net.Conn
35+ br *bufio.Reader
36+ bw *bufio.Writer
37+}
38+
39+func NewValkeyClient(addr, password string, db int, timeout time.Duration) (*ValkeyClient, error) {
40+ conn, err := net.DialTimeout("tcp", addr, timeout)
41+ if err != nil {
42+ return nil, fmt.Errorf("connect to valkey (%s): %w", addr, err)
43+ }
44+
45+ client := &ValkeyClient{
46+ conn: conn,
47+ br: bufio.NewReader(conn),
48+ bw: bufio.NewWriter(conn),
49+ }
50+
51+ if password != "" {
52+ if _, err := client.Do("AUTH", password); err != nil {
53+ _ = conn.Close()
54+ return nil, fmt.Errorf("authentication failed: %w", err)
55+ }
56+ }
57+
58+ if db > 0 {
59+ if _, err := client.Do("SELECT", strconv.Itoa(db)); err != nil {
60+ _ = conn.Close()
61+ return nil, fmt.Errorf("select db %d failed: %w", db, err)
62+ }
63+ }
64+
65+ return client, nil
66+}
67+
68+func (c *ValkeyClient) Close() error {
69+ return c.conn.Close()
70+}
71+
72+func (c *ValkeyClient) Do(args ...string) (any, error) {
73+ if err := c.conn.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
74+ return nil, err
75+ }
76+
77+ if _, err := fmt.Fprintf(c.bw, "*%d\r\n", len(args)); err != nil {
78+ return nil, err
79+ }
80+ for _, arg := range args {
81+ if _, err := fmt.Fprintf(c.bw, "$%d\r\n%s\r\n", len(arg), arg); err != nil {
82+ return nil, err
83+ }
84+ }
85+ if err := c.bw.Flush(); err != nil {
86+ return nil, err
87+ }
88+
89+ return c.readReply()
90+}
91+
92+func (c *ValkeyClient) readReply() (any, error) {
93+ line, err := c.br.ReadString('\n')
94+ if err != nil {
95+ return nil, err
96+ }
97+ if len(line) < 3 {
98+ return nil, fmt.Errorf("malformed RESP reply: %q", line)
99+ }
100+
101+ prefix := line[0]
102+ payload := strings.TrimRight(line[1:], "\r\n")
103+
104+ switch prefix {
105+ case '+': // Simple string
106+ return payload, nil
107+ case '-': // Error
108+ return nil, errors.New(payload)
109+ case ':': // Integer
110+ return strconv.ParseInt(payload, 10, 64)
111+ case '$': // Bulk string
112+ length, err := strconv.Atoi(payload)
113+ if err != nil {
114+ return nil, err
115+ }
116+ if length == -1 {
117+ return nil, nil // Nil
118+ }
119+ buf := make([]byte, length+2)
120+ if _, err := io.ReadFull(c.br, buf); err != nil {
121+ return nil, err
122+ }
123+ return string(buf[:length]), nil
124+ case '*': // Array
125+ count, err := strconv.Atoi(payload)
126+ if err != nil {
127+ return nil, err
128+ }
129+ if count == -1 {
130+ return nil, nil
131+ }
132+ items := make([]any, count)
133+ for i := 0; i < count; i++ {
134+ items[i], err = c.readReply()
135+ if err != nil {
136+ return nil, err
137+ }
138+ }
139+ return items, nil
140+ default:
141+ return nil, fmt.Errorf("unknown RESP prefix: %c", prefix)
142+ }
143+}
144+
145+func (c *ValkeyClient) Exists(key string) (bool, error) {
146+ res, err := c.Do("EXISTS", key)
147+ if err != nil {
148+ return false, err
149+ }
150+ if n, ok := res.(int64); ok {
151+ return n > 0, nil
152+ }
153+ return false, nil
154+}
155+
156+func (c *ValkeyClient) Set(key, value string) error {
157+ res, err := c.Do("SET", key, value)
158+ if err != nil {
159+ return err
160+ }
161+ if s, ok := res.(string); ok && s == "OK" {
162+ return nil
163+ }
164+ return fmt.Errorf("unexpected set response: %v", res)
165+}
166+
167+func (c *ValkeyClient) ZAdd(key string, score float64, member string) error {
168+ _, err := c.Do("ZADD", key, strconv.FormatFloat(score, 'f', -1, 64), member)
169+ return err
170+}
171+
172+// splitDirectoryKey reproduces caddy-storage-redis's directory hierarchy split.
173+func splitDirectoryKey(key string, baseIsDir bool) (string, string) {
174+ dir := path.Dir(key)
175+ base := path.Base(key)
176+ if baseIsDir {
177+ base += "/"
178+ }
179+ return dir, base
180+}
181+
182+// storeDirectoryRecord recursively adds records into Redis Sorted Sets matching caddy-storage-redis.
183+func storeDirectoryRecord(client *ValkeyClient, key string, score float64, baseIsDir bool) error {
184+ dir, base := splitDirectoryKey(key, baseIsDir)
185+ if dir == "." {
186+ return nil
187+ }
188+
189+ if err := client.ZAdd(dir, score, base); err != nil {
190+ return fmt.Errorf("unable to add %s to set %s: %w", base, dir, err)
191+ }
192+
193+ return storeDirectoryRecord(client, dir, score, true)
194+}
195+
196+type syncStats struct {
197+ found int
198+ synced int
199+ skipped int
200+ failed int
201+}
202+
203+// findCaddyRoots detects potential Caddy storage roots in given path.
204+func findCaddyRoots(targetDir string) []string {
205+ var roots []string
206+
207+ // Check if targetDir is itself a Caddy storage root
208+ if isCaddyStorageRoot(targetDir) {
209+ return []string{targetDir}
210+ }
211+
212+ // Check immediate subdirectories (e.g. `data/caddy` or service dirs)
213+ entries, err := os.ReadDir(targetDir)
214+ if err != nil {
215+ return nil
216+ }
217+
218+ for _, e := range entries {
219+ if !e.IsDir() || e.Name() == "archive" {
220+ continue
221+ }
222+ sub := filepath.Join(targetDir, e.Name())
223+ if isCaddyStorageRoot(sub) {
224+ roots = append(roots, sub)
225+ continue
226+ }
227+
228+ // Also check nested `.../data/caddy` commonly used in compose mounts
229+ nested := filepath.Join(sub, "data", "caddy")
230+ if isCaddyStorageRoot(nested) {
231+ roots = append(roots, nested)
232+ continue
233+ }
234+ nestedCaddy := filepath.Join(sub, "caddy")
235+ if isCaddyStorageRoot(nestedCaddy) {
236+ roots = append(roots, nestedCaddy)
237+ }
238+ }
239+
240+ return roots
241+}
242+
243+func isCaddyStorageRoot(dir string) bool {
244+ certs := filepath.Join(dir, "certificates")
245+ acme := filepath.Join(dir, "acme")
246+ if fi, err := os.Stat(certs); err == nil && fi.IsDir() {
247+ return true
248+ }
249+ if fi, err := os.Stat(acme); err == nil && fi.IsDir() {
250+ return true
251+ }
252+ return false
253+}
254+
255+func syncFile(
256+ client *ValkeyClient,
257+ root string,
258+ filePath string,
259+ keyPrefix string,
260+ dryRun bool,
261+ overwrite bool,
262+ logger *slog.Logger,
263+) (synced bool, skipped bool, err error) {
264+ rel, err := filepath.Rel(root, filePath)
265+ if err != nil {
266+ return false, false, err
267+ }
268+
269+ // Normalize to slash path
270+ caddyKey := filepath.ToSlash(rel)
271+
272+ // Only sync active Caddy storage assets: certificates, acme accounts, and ocsp staples
273+ if !strings.HasPrefix(caddyKey, "certificates/") &&
274+ !strings.HasPrefix(caddyKey, "acme/") &&
275+ !strings.HasPrefix(caddyKey, "ocsp/") {
276+ return false, true, nil
277+ }
278+
279+ // Skip temporary locks, write tests, or hidden files
280+ baseName := filepath.Base(caddyKey)
281+ if strings.Contains(caddyKey, "/locks/") || strings.HasPrefix(baseName, ".") || strings.HasPrefix(baseName, "rw_test_") {
282+ return false, true, nil
283+ }
284+
285+ prefixedKey := path.Join(keyPrefix, caddyKey)
286+
287+ fi, err := os.Stat(filePath)
288+ if err != nil {
289+ return false, false, err
290+ }
291+
292+ if !overwrite && client != nil {
293+ exists, err := client.Exists(prefixedKey)
294+ if err != nil {
295+ return false, false, fmt.Errorf("check exists %s: %w", prefixedKey, err)
296+ }
297+ if exists {
298+ logger.Debug("key already exists in valkey, skipping", "key", prefixedKey)
299+ return false, true, nil
300+ }
301+ }
302+
303+ content, err := os.ReadFile(filePath)
304+ if err != nil {
305+ return false, false, err
306+ }
307+
308+ sd := StorageData{
309+ Value: content,
310+ Modified: fi.ModTime().UTC(),
311+ Size: int64(len(content)),
312+ Compression: 0,
313+ Encryption: 0,
314+ }
315+
316+ jsonBytes, err := json.Marshal(sd)
317+ if err != nil {
318+ return false, false, fmt.Errorf("marshal storage data for %s: %w", caddyKey, err)
319+ }
320+
321+ if dryRun {
322+ logger.Info("[dry-run] would sync", "key", prefixedKey, "size", len(content), "modTime", sd.Modified)
323+ return true, false, nil
324+ }
325+
326+ score := float64(sd.Modified.Unix())
327+ if err := storeDirectoryRecord(client, prefixedKey, score, false); err != nil {
328+ return false, false, fmt.Errorf("index directory for %s: %w", prefixedKey, err)
329+ }
330+
331+ if err := client.Set(prefixedKey, string(jsonBytes)); err != nil {
332+ return false, false, fmt.Errorf("set key %s: %w", prefixedKey, err)
333+ }
334+
335+ logger.Info("synced", "key", prefixedKey, "size", len(content))
336+ return true, false, nil
337+}
338+
339+func main() {
340+ defaultAddr := shared.GetEnv("VALKEY_ADDR", shared.GetEnv("REDIS_ADDR", ""))
341+ defaultPass := shared.GetEnv("VALKEY_PASSWORD", shared.GetEnv("REDIS_PASSWORD", ""))
342+ defaultPrefix := shared.GetEnv("KEY_PREFIX", "caddy")
343+
344+ addrFlag := flag.String("addr", defaultAddr, "Valkey/Redis server address (host:port)")
345+ passFlag := flag.String("password", defaultPass, "Valkey/Redis password")
346+ dbFlag := flag.Int("db", 0, "Valkey database index")
347+ prefixFlag := flag.String("prefix", defaultPrefix, "Caddy storage key prefix")
348+ dirFlag := flag.String("dir", "", "Directory containing Caddy storage (required)")
349+ dryRunFlag := flag.Bool("dry-run", false, "Preview keys without uploading to Valkey")
350+ overwriteFlag := flag.Bool("overwrite", false, "Overwrite keys even if they already exist in Valkey")
351+ verboseFlag := flag.Bool("verbose", false, "Enable verbose logging")
352+ flag.Parse()
353+
354+ logLevel := slog.LevelInfo
355+ if *verboseFlag {
356+ logLevel = slog.LevelDebug
357+ }
358+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel}))
359+
360+ dir := *dirFlag
361+ if dir == "" && flag.NArg() > 0 {
362+ dir = flag.Arg(0)
363+ }
364+ if dir == "" {
365+ logger.Error("directory is required (provide -dir <path> or path as argument)")
366+ os.Exit(1)
367+ }
368+
369+ if *addrFlag == "" {
370+ logger.Error("address is required (provide -addr or set VALKEY_ADDR)")
371+ os.Exit(1)
372+ }
373+
374+ if *passFlag == "" {
375+ logger.Error("password is required (provide -password or set VALKEY_PASSWORD)")
376+ os.Exit(1)
377+ }
378+
379+ roots := findCaddyRoots(dir)
380+ if len(roots) == 0 {
381+ logger.Error("no Caddy storage directories found in path", "dir", dir)
382+ os.Exit(1)
383+ }
384+
385+ logger.Info("discovered Caddy storage roots", "count", len(roots), "roots", roots)
386+
387+ var client *ValkeyClient
388+ if !*dryRunFlag {
389+ var err error
390+ client, err = NewValkeyClient(*addrFlag, *passFlag, *dbFlag, 5*time.Second)
391+ if err != nil {
392+ logger.Error("failed to connect to Valkey", "err", err, "addr", *addrFlag)
393+ os.Exit(1)
394+ }
395+ defer func() { _ = client.Close() }()
396+ logger.Info("connected to Valkey successfully", "addr", *addrFlag, "db", *dbFlag)
397+ } else {
398+ logger.Info("running in DRY-RUN mode; no changes will be made to Valkey")
399+ }
400+
401+ stats := syncStats{}
402+
403+ for _, root := range roots {
404+ logger.Info("syncing Caddy root", "path", root)
405+ err := filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error {
406+ if walkErr != nil {
407+ return walkErr
408+ }
409+ if d.IsDir() {
410+ return nil
411+ }
412+
413+ stats.found++
414+ synced, skipped, err := syncFile(client, root, p, *prefixFlag, *dryRunFlag, *overwriteFlag, logger)
415+ if err != nil {
416+ logger.Error("failed to sync file", "path", p, "err", err)
417+ stats.failed++
418+ return nil
419+ }
420+ if synced {
421+ stats.synced++
422+ } else if skipped {
423+ stats.skipped++
424+ }
425+ return nil
426+ })
427+ if err != nil {
428+ logger.Error("error walking directory", "root", root, "err", err)
429+ }
430+ }
431+
432+ fmt.Printf("\n--- Valkey TLS Migration Summary ---\n")
433+ fmt.Printf("Total files found: %d\n", stats.found)
434+ fmt.Printf("Successfully synced: %d\n", stats.synced)
435+ fmt.Printf("Skipped (existing): %d\n", stats.skipped)
436+ fmt.Printf("Failed: %d\n", stats.failed)
437+ if *dryRunFlag {
438+ fmt.Printf("Mode: DRY RUN (no keys written)\n")
439+ }
440+ fmt.Printf("------------------------------------\n")
441+
442+ if stats.failed > 0 {
443+ os.Exit(1)
444+ }
445+}
+95, -0
......@@ -0,0 +1,95 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+)
10+
11+func TestSplitDirectoryKey(t *testing.T) {
12+ tests := []struct {
13+ key string
14+ baseIsDir bool
15+ wantDir string
16+ wantBase string
17+ }{
18+ {
19+ key: "caddy/certificates/acme.org/site.com/site.com.crt",
20+ baseIsDir: false,
21+ wantDir: "caddy/certificates/acme.org/site.com",
22+ wantBase: "site.com.crt",
23+ },
24+ {
25+ key: "caddy/certificates/acme.org/site.com",
26+ baseIsDir: true,
27+ wantDir: "caddy/certificates/acme.org",
28+ wantBase: "site.com/",
29+ },
30+ {
31+ key: "caddy",
32+ baseIsDir: true,
33+ wantDir: ".",
34+ wantBase: "caddy/",
35+ },
36+ }
37+
38+ for _, tt := range tests {
39+ gotDir, gotBase := splitDirectoryKey(tt.key, tt.baseIsDir)
40+ if gotDir != tt.wantDir || gotBase != tt.wantBase {
41+ t.Errorf("splitDirectoryKey(%q, %v) = (%q, %q), want (%q, %q)",
42+ tt.key, tt.baseIsDir, gotDir, gotBase, tt.wantDir, tt.wantBase)
43+ }
44+ }
45+}
46+
47+func TestStorageDataJSON(t *testing.T) {
48+ now := time.Now().UTC().Truncate(time.Second)
49+ sd := StorageData{
50+ Value: []byte("test-cert-data"),
51+ Modified: now,
52+ Size: 14,
53+ Compression: 0,
54+ Encryption: 0,
55+ }
56+
57+ data, err := json.Marshal(sd)
58+ if err != nil {
59+ t.Fatalf("marshal error: %v", err)
60+ }
61+
62+ var parsed map[string]any
63+ if err := json.Unmarshal(data, &parsed); err != nil {
64+ t.Fatalf("unmarshal error: %v", err)
65+ }
66+
67+ // In Go json.Marshal, []byte encodes as base64 string
68+ if val, ok := parsed["value"].(string); !ok || val == "" {
69+ t.Errorf("expected base64 string for value, got: %v", parsed["value"])
70+ }
71+ if size, ok := parsed["size"].(float64); !ok || int64(size) != 14 {
72+ t.Errorf("expected size 14, got: %v", parsed["size"])
73+ }
74+}
75+
76+func TestFindCaddyRoots(t *testing.T) {
77+ tmp := t.TempDir()
78+
79+ // 1. Root with certificates/
80+ caddy1 := filepath.Join(tmp, "caddy1")
81+ if err := os.MkdirAll(filepath.Join(caddy1, "certificates"), 0755); err != nil {
82+ t.Fatal(err)
83+ }
84+
85+ // 2. Nested with data/caddy/acme/
86+ service2 := filepath.Join(tmp, "service2", "data", "caddy")
87+ if err := os.MkdirAll(filepath.Join(service2, "acme"), 0755); err != nil {
88+ t.Fatal(err)
89+ }
90+
91+ roots := findCaddyRoots(tmp)
92+ if len(roots) != 2 {
93+ t.Errorf("expected 2 roots, got %d: %v", len(roots), roots)
94+ }
95+}