v3.4.0 593c1c3

store.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close