1package acmestore
2
3import (
4 "context"
5 "crypto/tls"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "os"
10 "path/filepath"
11 "strings"
12 "syscall"
13 "time"
14
15 "github.com/bnema/gordon/internal/boundaries/out"
16 "github.com/bnema/gordon/internal/domain"
17)
18
19// certMetadata is the JSON structure persisted in each certificate directory.
20type certMetadata struct {
21 ID string `json:"id"`
22 Names []string `json:"names"`
23 Challenge string `json:"challenge"`
24 NotAfter time.Time `json:"not_after"`
25 LastError string `json:"last_error,omitempty"`
26}
27
28// storeState is the JSON structure persisted for ACME reconciliation state.
29type storeState struct {
30 ObtainCursor int `json:"obtain_cursor"`
31}
32
33const (
34 lockFile = ".lock"
35 accountFile = "account.json"
36 stateFile = "state.json"
37 certDir = "certs"
38 backupSuffix = ".old"
39 tempInfix = ".tmp-"
40 certFile = "cert.pem"
41 chainFile = "chain.pem"
42 fullchainFile = "fullchain.pem"
43 privkeyFile = "privkey.pem"
44 metaFile = "metadata.json"
45
46 dirMode os.FileMode = 0700
47 privKeyMode os.FileMode = 0600
48 metaMode os.FileMode = 0600
49 accountMode os.FileMode = 0600
50 lockMode os.FileMode = 0600
51 pemMode os.FileMode = 0644
52)
53
54var ErrLockHeld = errors.New("acmestore: lock already held")
55
56// Store implements out.CertificateStore using the local filesystem.
57type Store struct {
58 root string
59}
60
61// New creates a Store rooted at root. root must be non-empty and is created
62// along with the certs subdirectory if they do not exist.
63func New(root string) (*Store, error) {
64 if root == "" {
65 return nil, errors.New("acmestore: root is required")
66 }
67 if err := os.MkdirAll(root, dirMode); err != nil {
68 return nil, fmt.Errorf("acmestore: mkdir root: %w", err)
69 }
70 if _, err := ensureCertsDir(root); err != nil {
71 return nil, err
72 }
73 return &Store{root: root}, nil
74}
75
76// LoadAccount reads the account from <root>/account.json.
77// It returns nil, nil when the file does not exist.
78func (s *Store) LoadAccount(_ context.Context) (*out.ACMEAccount, error) {
79 path := filepath.Join(s.root, accountFile)
80 data, err := os.ReadFile(path)
81 if err != nil {
82 if os.IsNotExist(err) {
83 return nil, nil
84 }
85 return nil, fmt.Errorf("acmestore: read account: %w", err)
86 }
87 var acct out.ACMEAccount
88 if err := json.Unmarshal(data, &acct); err != nil {
89 return nil, fmt.Errorf("acmestore: unmarshal account: %w", err)
90 }
91 return &acct, nil
92}
93
94// SaveAccount writes the account to <root>/account.json atomically with mode 0600.
95func (s *Store) SaveAccount(_ context.Context, account out.ACMEAccount) error {
96 data, err := json.MarshalIndent(account, "", " ")
97 if err != nil {
98 return fmt.Errorf("acmestore: marshal account: %w", err)
99 }
100 if err := writeAtomic(filepath.Join(s.root, accountFile), data, accountMode); err != nil {
101 return fmt.Errorf("acmestore: save account: %w", err)
102 }
103 return nil
104}
105
106// LoadState reads ACME reconciliation state from <root>/state.json.
107// It returns zero state when the file does not exist.
108func (s *Store) LoadState(_ context.Context) (out.CertificateStoreState, error) {
109 path := filepath.Join(s.root, stateFile)
110 data, err := os.ReadFile(path)
111 if err != nil {
112 if os.IsNotExist(err) {
113 return out.CertificateStoreState{}, nil
114 }
115 return out.CertificateStoreState{}, fmt.Errorf("acmestore: read state: %w", err)
116 }
117 var state storeState
118 if err := json.Unmarshal(data, &state); err != nil {
119 return out.CertificateStoreState{}, fmt.Errorf("acmestore: unmarshal state: %w", err)
120 }
121 if state.ObtainCursor < 0 {
122 state.ObtainCursor = 0
123 }
124 return out.CertificateStoreState{ObtainCursor: state.ObtainCursor}, nil
125}
126
127// SaveState writes ACME reconciliation state to <root>/state.json atomically.
128func (s *Store) SaveState(_ context.Context, state out.CertificateStoreState) error {
129 if state.ObtainCursor < 0 {
130 state.ObtainCursor = 0
131 }
132 data, err := json.MarshalIndent(storeState{ObtainCursor: state.ObtainCursor}, "", " ")
133 if err != nil {
134 return fmt.Errorf("acmestore: marshal state: %w", err)
135 }
136 if err := writeAtomic(filepath.Join(s.root, stateFile), data, metaMode); err != nil {
137 return fmt.Errorf("acmestore: save state: %w", err)
138 }
139 return nil
140}
141
142// LoadAll reads all certificate directories under <root>/certs/ and returns
143// the stored certificates. Directories with unsafe IDs are skipped.
144func (s *Store) LoadAll(_ context.Context) ([]out.StoredCertificate, error) {
145 certsPath := filepath.Join(s.root, certDir)
146 entries, err := os.ReadDir(certsPath)
147 if err != nil {
148 if os.IsNotExist(err) {
149 return nil, nil
150 }
151 return nil, fmt.Errorf("acmestore: list certs: %w", err)
152 }
153
154 var result []out.StoredCertificate
155 for _, entry := range entries {
156 if !entry.IsDir() {
157 continue
158 }
159 id := entry.Name()
160 if !safeID(id) {
161 continue
162 }
163 // Skip temporary and backup directories created by Save.
164 if isStoreInternalCertDir(id) {
165 continue
166 }
167
168 cert, err := s.loadOne(id)
169 if err != nil {
170 return nil, fmt.Errorf("acmestore: load %s: %w", id, err)
171 }
172 result = append(result, *cert)
173 }
174 return result, nil
175}
176
177// loadOne reads a single certificate directory and returns the stored
178// certificate.
179func (s *Store) loadOne(id string) (*out.StoredCertificate, error) {
180 dir := filepath.Join(s.root, certDir, id)
181
182 meta, err := readMetadata(filepath.Join(dir, metaFile))
183 if err != nil {
184 return nil, fmt.Errorf("acmestore: metadata %s: %w", id, err)
185 }
186
187 certPEM, err := readPEM(filepath.Join(dir, certFile))
188 if err != nil {
189 return nil, fmt.Errorf("acmestore: read %s cert.pem: %w", id, err)
190 }
191 chainPEM, err := readPEM(filepath.Join(dir, chainFile))
192 if err != nil {
193 return nil, fmt.Errorf("acmestore: read %s chain.pem: %w", id, err)
194 }
195 fullchainPEM, err := readPEM(filepath.Join(dir, fullchainFile))
196 if err != nil {
197 return nil, fmt.Errorf("acmestore: read %s fullchain.pem: %w", id, err)
198 }
199 privKeyPEM, err := readPEM(filepath.Join(dir, privkeyFile))
200 if err != nil {
201 return nil, fmt.Errorf("acmestore: read %s privkey.pem: %w", id, err)
202 }
203
204 var tlsCert tls.Certificate
205 if len(fullchainPEM) > 0 && len(privKeyPEM) > 0 {
206 parsed, err := tls.X509KeyPair(fullchainPEM, privKeyPEM)
207 if err != nil {
208 return nil, fmt.Errorf("acmestore: parse key pair %s: %w", id, err)
209 }
210 tlsCert = parsed
211 }
212
213 challenge, err := domain.ParseACMEChallengeMode(meta.Challenge)
214 if err != nil {
215 return nil, fmt.Errorf("acmestore: challenge %s: %w", id, err)
216 }
217
218 return &out.StoredCertificate{
219 ID: id,
220 Names: meta.Names,
221 Challenge: challenge,
222 Certificate: tlsCert,
223 CertPEM: certPEM,
224 ChainPEM: chainPEM,
225 FullchainPEM: fullchainPEM,
226 PrivateKeyPEM: privKeyPEM,
227 NotAfter: meta.NotAfter,
228 LastError: meta.LastError,
229 }, nil
230}
231
232// Save persists a certificate to <root>/certs/<safe-id>/.
233// All PEM files and metadata are written atomically.
234func (s *Store) Save(_ context.Context, cert out.StoredCertificate) error {
235 if !safeID(cert.ID) {
236 return fmt.Errorf("acmestore: unsafe certificate id %q: %w", cert.ID, domain.ErrPathTraversal)
237 }
238
239 parent := filepath.Join(s.root, certDir)
240 if err := os.MkdirAll(parent, dirMode); err != nil {
241 return fmt.Errorf("acmestore: mkdir certs: %w", err)
242 }
243 if err := recoverCertificateDir(parent, cert.ID); err != nil {
244 return fmt.Errorf("acmestore: recover cert %s: %w", cert.ID, err)
245 }
246
247 dir := filepath.Join(parent, cert.ID)
248 tmpDir, err := os.MkdirTemp(parent, cert.ID+".tmp-*")
249 if err != nil {
250 return fmt.Errorf("acmestore: create temp dir: %w", err)
251 }
252 committed := false
253 defer func() {
254 if !committed {
255 os.RemoveAll(tmpDir)
256 }
257 }()
258
259 if err := writeCertificateFiles(tmpDir, cert); err != nil {
260 return err
261 }
262
263 backupDir := dir + backupSuffix
264 _ = os.RemoveAll(backupDir)
265 hadExisting := false
266 if err := os.Rename(dir, backupDir); err != nil {
267 if !os.IsNotExist(err) {
268 return fmt.Errorf("acmestore: backup existing cert dir: %w", err)
269 }
270 } else {
271 hadExisting = true
272 }
273
274 if err := os.Rename(tmpDir, dir); err != nil {
275 if hadExisting {
276 _ = os.Rename(backupDir, dir)
277 }
278 return fmt.Errorf("acmestore: commit cert dir: %w", err)
279 }
280 committed = true
281 if hadExisting {
282 _ = os.RemoveAll(backupDir)
283 }
284 return nil
285}
286
287func writeCertificateFiles(dir string, cert out.StoredCertificate) error {
288 meta := certMetadata{
289 ID: cert.ID,
290 Names: cert.Names,
291 Challenge: string(cert.Challenge),
292 NotAfter: cert.NotAfter,
293 LastError: cert.LastError,
294 }
295
296 if err := writeFileIfNonNil(filepath.Join(dir, certFile), cert.CertPEM, pemMode); err != nil {
297 return fmt.Errorf("acmestore: write cert.pem: %w", err)
298 }
299 if err := writeFileIfNonNil(filepath.Join(dir, chainFile), cert.ChainPEM, pemMode); err != nil {
300 return fmt.Errorf("acmestore: write chain.pem: %w", err)
301 }
302 if err := writeFileIfNonNil(filepath.Join(dir, fullchainFile), cert.FullchainPEM, pemMode); err != nil {
303 return fmt.Errorf("acmestore: write fullchain.pem: %w", err)
304 }
305 if err := writeFileIfNonNil(filepath.Join(dir, privkeyFile), cert.PrivateKeyPEM, privKeyMode); err != nil {
306 return fmt.Errorf("acmestore: write privkey.pem: %w", err)
307 }
308
309 metaData, err := json.MarshalIndent(meta, "", " ")
310 if err != nil {
311 return fmt.Errorf("acmestore: marshal metadata: %w", err)
312 }
313 if err := writeAtomic(filepath.Join(dir, metaFile), metaData, metaMode); err != nil {
314 return fmt.Errorf("acmestore: write metadata: %w", err)
315 }
316 return nil
317}
318
319// Lock acquires an exclusive advisory lock using a lock file at <root>/.lock.
320// It is fail-fast and non-blocking: if another live process holds the lock,
321// Lock returns an error immediately without waiting. Context cancellation is
322// respected before attempting acquisition. The returned unlock function releases
323// the OS advisory lock, closes the file descriptor, and removes the lock file.
324func (s *Store) Lock(ctx context.Context) (func() error, error) {
325 select {
326 case <-ctx.Done():
327 return nil, ctx.Err()
328 default:
329 }
330
331 path := filepath.Join(s.root, lockFile)
332 f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, lockMode)
333 if err != nil {
334 return nil, fmt.Errorf("acmestore: open lock file: %w", err)
335 }
336
337 fd, err := fileDescriptor(f)
338 if err != nil {
339 _ = f.Close()
340 return nil, err
341 }
342 if err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
343 _ = f.Close()
344 if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
345 return nil, ErrLockHeld
346 }
347 return nil, fmt.Errorf("acmestore: acquire lock: %w", err)
348 }
349
350 if err := writeLockMetadata(f); err != nil {
351 _ = syscall.Flock(fd, syscall.LOCK_UN)
352 _ = f.Close()
353 return nil, err
354 }
355
356 return func() error {
357 return releaseLockFile(f, fd)
358 }, nil
359}
360
361func writeLockMetadata(f *os.File) error {
362 if err := f.Truncate(0); err != nil {
363 return fmt.Errorf("acmestore: truncate lock file: %w", err)
364 }
365 if _, err := f.Seek(0, 0); err != nil {
366 return fmt.Errorf("acmestore: seek lock file: %w", err)
367 }
368 metadata := fmt.Sprintf("pid=%d\nhost=%s\ncreated_at=%s\n", os.Getpid(), hostname(), time.Now().UTC().Format(time.RFC3339Nano))
369 if _, err := f.WriteString(metadata); err != nil {
370 return fmt.Errorf("acmestore: write lock metadata: %w", err)
371 }
372 return nil
373}
374
375func releaseLockFile(f *os.File, fd int) error {
376 var errs []error
377 if err := syscall.Flock(fd, syscall.LOCK_UN); err != nil {
378 errs = append(errs, fmt.Errorf("unlock: %w", err))
379 }
380 if err := f.Close(); err != nil {
381 errs = append(errs, fmt.Errorf("close: %w", err))
382 }
383 if err := errors.Join(errs...); err != nil {
384 return fmt.Errorf("acmestore: release lock: %w", err)
385 }
386 return nil
387}
388
389func fileDescriptor(f *os.File) (int, error) {
390 fd := f.Fd()
391 if fd > uintptr(^uint(0)>>1) {
392 return 0, fmt.Errorf("acmestore: lock file descriptor overflows int")
393 }
394 return int(fd), nil
395}
396
397func hostname() string {
398 host, err := os.Hostname()
399 if err != nil {
400 return "unknown"
401 }
402 return host
403}
404
405func ensureCertsDir(root string) (string, error) {
406 certsPath := filepath.Join(root, certDir)
407 if err := os.MkdirAll(certsPath, dirMode); err != nil {
408 return "", fmt.Errorf("acmestore: mkdir certs: %w", err)
409 }
410 if err := recoverCertificateDirs(certsPath); err != nil {
411 return "", fmt.Errorf("acmestore: recover certs: %w", err)
412 }
413 return certsPath, nil
414}
415
416func isStoreInternalTempDir(id string) bool {
417 return strings.Contains(id, tempInfix)
418}
419
420func isStoreInternalCertDir(id string) bool {
421 return isStoreInternalTempDir(id) || strings.HasSuffix(id, backupSuffix)
422}
423
424func recoverCertificateDir(certsPath, id string) error {
425 if !safeID(id) {
426 return nil
427 }
428 backupDir := filepath.Join(certsPath, id+backupSuffix)
429 dir := filepath.Join(certsPath, id)
430 if _, err := os.Stat(dir); err == nil {
431 return nil
432 } else if !os.IsNotExist(err) {
433 return err
434 }
435 if _, err := os.Stat(backupDir); err != nil {
436 if os.IsNotExist(err) {
437 return nil
438 }
439 return err
440 }
441 if err := os.Rename(backupDir, dir); err != nil {
442 return fmt.Errorf("restore backup %s: %w", filepath.Base(backupDir), err)
443 }
444 return nil
445}
446
447func recoverCertificateDirs(certsPath string) error {
448 entries, err := os.ReadDir(certsPath)
449 if err != nil {
450 if os.IsNotExist(err) {
451 return nil
452 }
453 return err
454 }
455 for _, entry := range entries {
456 if !entry.IsDir() {
457 continue
458 }
459 name := entry.Name()
460 if isStoreInternalTempDir(name) {
461 if err := os.RemoveAll(filepath.Join(certsPath, name)); err != nil {
462 return fmt.Errorf("remove temp %s: %w", name, err)
463 }
464 continue
465 }
466 if base, ok := strings.CutSuffix(name, backupSuffix); ok {
467 if err := recoverCertificateDir(certsPath, base); err != nil {
468 return err
469 }
470 }
471 }
472 return nil
473}
474
475// safeID rejects IDs that could cause path traversal or collide with store-internal artifacts.
476func safeID(id string) bool {
477 return id != "" && !strings.HasPrefix(id, ".") && !isStoreInternalCertDir(id) && !strings.Contains(id, "/") && !strings.Contains(id, "\\") && !strings.Contains(id, "..") && !strings.Contains(id, "\x00")
478}
479
480// writeAtomic writes data to path atomically by writing to a temporary file
481// in the same directory and then renaming.
482func writeAtomic(path string, data []byte, mode os.FileMode) error {
483 dir := filepath.Dir(path)
484 tmp, err := os.CreateTemp(dir, ".tmp-*")
485 if err != nil {
486 return fmt.Errorf("create temp: %w", err)
487 }
488 tmpName := tmp.Name()
489
490 // Clean up temp file on any failure
491 removeOnErr := true
492 defer func() {
493 if removeOnErr {
494 os.Remove(tmpName)
495 }
496 }()
497
498 if err := tmp.Chmod(mode); err != nil {
499 tmp.Close()
500 return fmt.Errorf("chmod temp: %w", err)
501 }
502 if _, err := tmp.Write(data); err != nil {
503 tmp.Close()
504 return fmt.Errorf("write temp: %w", err)
505 }
506 // Sync before close to ensure data is on disk.
507 if err := tmp.Sync(); err != nil {
508 tmp.Close()
509 return fmt.Errorf("sync temp: %w", err)
510 }
511 if err := tmp.Close(); err != nil {
512 return fmt.Errorf("close temp: %w", err)
513 }
514 if err := os.Rename(tmpName, path); err != nil {
515 return fmt.Errorf("rename temp: %w", err)
516 }
517 removeOnErr = false
518 return nil
519}
520
521// writeFileIfNonNil writes data to path atomically only when data is non-nil.
522func writeFileIfNonNil(path string, data []byte, mode os.FileMode) error {
523 if data == nil {
524 return nil
525 }
526 return writeAtomic(path, data, mode)
527}
528
529// readPEM reads a PEM file from path. If the file does not exist it returns
530// nil, nil without error. Other read errors are returned.
531func readPEM(path string) ([]byte, error) {
532 data, err := os.ReadFile(path)
533 if err != nil {
534 if os.IsNotExist(err) {
535 return nil, nil
536 }
537 return nil, err
538 }
539 return data, nil
540}
541
542// readMetadata reads and unmarshals a metadata JSON file.
543func readMetadata(path string) (*certMetadata, error) {
544 data, err := os.ReadFile(path)
545 if err != nil {
546 return nil, fmt.Errorf("read: %w", err)
547 }
548 var meta certMetadata
549 if err := json.Unmarshal(data, &meta); err != nil {
550 return nil, fmt.Errorf("unmarshal: %w", err)
551 }
552 return &meta, nil
553}