1package secrets
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "strings"
9 "time"
10
11 "github.com/bnema/zerowrap"
12
13 "github.com/bnema/gordon/internal/boundaries/out"
14 "github.com/bnema/gordon/internal/domain"
15)
16
17const defaultCloudflareTokenPassName = "gordon/cloudflare/api-token"
18
19// Package-level disabled logger and pass provider reused across calls.
20var disabledLogger = zerowrap.New(zerowrap.Config{Level: "disabled"})
21var defaultPassProvider = NewPassProvider(disabledLogger)
22
23// PublicTLSResolverConfig holds optional overrides for PublicTLSResolver.
24type PublicTLSResolverConfig struct {
25 PassLookup func(ctx context.Context, name string) (string, error)
26 EnvLookup func(key string) string
27}
28
29// PublicTLSResolver resolves Cloudflare API tokens using pass, file, or env.
30type PublicTLSResolver struct {
31 passLookup func(ctx context.Context, name string) (string, error)
32 envLookup func(key string) string
33}
34
35var _ out.SecretResolver = (*PublicTLSResolver)(nil)
36
37// NewPublicTLSResolver creates a new PublicTLSResolver with the given config.
38// Defaults PassLookup to defaultPassLookup and EnvLookup to os.Getenv.
39func NewPublicTLSResolver(cfg PublicTLSResolverConfig) *PublicTLSResolver {
40 r := &PublicTLSResolver{
41 passLookup: defaultPassLookup,
42 envLookup: os.Getenv,
43 }
44 if cfg.PassLookup != nil {
45 r.passLookup = cfg.PassLookup
46 }
47 if cfg.EnvLookup != nil {
48 r.envLookup = cfg.EnvLookup
49 }
50 return r
51}
52
53// ResolveCloudflareToken resolves the Cloudflare API token.
54// Lookup order: pass, token file env, token env.
55func (r *PublicTLSResolver) ResolveCloudflareToken(ctx context.Context) (out.SecretValue, error) {
56 var passErr error
57 var fileErr error
58
59 // 1. Try pass
60 if token, err := r.passLookup(ctx, defaultCloudflareTokenPassName); err == nil {
61 if trimmed := strings.TrimSpace(token); trimmed != "" {
62 return out.SecretValue{Value: trimmed, Source: domain.ACMETokenSourcePass}, nil
63 }
64 } else if !errors.Is(err, os.ErrNotExist) {
65 passErr = err
66 }
67
68 // 2. Try token file env
69 if filePath := r.envLookup("GORDON_CLOUDFLARE_API_TOKEN_FILE"); filePath != "" {
70 if data, err := readFileWithTimeout(ctx, filePath, 5*time.Second); err == nil {
71 if trimmed := strings.TrimSpace(string(data)); trimmed != "" {
72 return out.SecretValue{Value: trimmed, Source: domain.ACMETokenSourceFile}, nil
73 }
74 } else {
75 fileErr = fmt.Errorf("read token file: %w", err)
76 }
77 }
78
79 // 3. Try token env
80 if token := r.envLookup("GORDON_CLOUDFLARE_API_TOKEN"); token != "" {
81 if trimmed := strings.TrimSpace(token); trimmed != "" {
82 return out.SecretValue{Value: trimmed, Source: domain.ACMETokenSourceEnv}, nil
83 }
84 }
85
86 // 4. No token found — return remembered errors if any
87 if passErr != nil {
88 return out.SecretValue{Source: domain.ACMETokenSourceNone}, fmt.Errorf("resolve pass token: %w", passErr)
89 }
90 if fileErr != nil {
91 return out.SecretValue{Source: domain.ACMETokenSourceNone}, fileErr
92 }
93 return out.SecretValue{Source: domain.ACMETokenSourceNone}, nil
94}
95
96// defaultPassLookup executes "pass show <name>" with a 2-second timeout.
97// Returns the raw output with trailing newline. Never logs token values.
98func defaultPassLookup(ctx context.Context, name string) (string, error) {
99 // Only allow the known Cloudflare token pass name.
100 if name != defaultCloudflareTokenPassName {
101 return "", fmt.Errorf("pass lookup: disallowed name %q", name)
102 }
103
104 return defaultPassProvider.GetSecret(ctx, name)
105}
106
107// readFileWithTimeout reads a file with a context-aware deadline.
108// It runs os.ReadFile in a goroutine and selects on context expiry.
109// If the context has a deadline, it is used; otherwise a timeout is applied.
110func readFileWithTimeout(ctx context.Context, path string, timeout time.Duration) ([]byte, error) {
111 readCtx := ctx
112 if _, hasDeadline := ctx.Deadline(); !hasDeadline {
113 var cancel context.CancelFunc
114 readCtx, cancel = context.WithTimeout(ctx, timeout)
115 defer cancel()
116 }
117
118 type result struct {
119 data []byte
120 err error
121 }
122
123 ch := make(chan result, 1)
124 go func() {
125 // os.ReadFile is not context-cancellable. The buffered channel ensures
126 // this goroutine can finish without blocking if readCtx expires first;
127 // for local config files this trade-off is acceptable, though reads on
128 // slow NFS/FUSE mounts may continue after cancellation.
129 data, err := os.ReadFile(path)
130 select {
131 case ch <- result{data: data, err: err}:
132 case <-readCtx.Done():
133 }
134 }()
135
136 select {
137 case <-readCtx.Done():
138 return nil, readCtx.Err()
139 case r := <-ch:
140 return r.data, r.err
141 }
142}