1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "bytes"
7 "context"
8 "errors"
9 "fmt"
10 "io"
11 "io/fs"
12 "os"
13 "os/exec"
14 "os/signal"
15 "strings"
16 "syscall"
17 "time"
18
19 "github.com/bnema/kvconf"
20 "github.com/spf13/cobra"
21 "golang.org/x/term"
22
23 "bnema.dev/ermite/internal/adapters/client"
24)
25
26const (
27 // envConfig selects the CLI config file instead of ~/.config/ermite/config.
28 envConfig = "ERMITE_CONFIG"
29 // Keys of the CLI config file.
30 keyURL = "url"
31 keyTokenCmd = "token.cmd"
32
33 // tokenCmdTimeout bounds one run of token.cmd, prompts included.
34 tokenCmdTimeout = 60 * time.Second
35 // tokenCmdMaxOutput bounds what is read from its standard output.
36 tokenCmdMaxOutput = 4 << 10
37)
38
39// cliFile is the CLI config file. It holds no secret: the token is fetched
40// from a secret manager by the token.cmd command line.
41type cliFile struct {
42 URL string `kv:"url"`
43 TokenCmd string `kv:"token.cmd"`
44}
45
46// path is the config file: $ERMITE_CONFIG, else <config dir>/ermite/config.
47func (e *cliEnv) path() (string, error) {
48 if e.configPath != nil {
49 return e.configPath()
50 }
51 if p := strings.TrimSpace(e.getenv(envConfig)); p != "" {
52 return p, nil
53 }
54 p, err := kvconf.DefaultPath("ermite", "config")
55 if err != nil {
56 return "", fmt.Errorf("no home directory to find the config file: set %s", envConfig)
57 }
58 return p, nil
59}
60
61// readFile reads the config file, once per process. A missing file, or no
62// place to look for it, gives an empty one. Problems in the file are warnings
63// on stderr, printed once, and only when warn is set. The path is returned
64// even when reading fails.
65//
66// The file is opened once: what is parsed and what checkPrivate vets (see
67// fetchToken) are the same file, so token.cmd cannot come from a file that was
68// swapped after the check.
69func (e *cliEnv) readFile(cmd *cobra.Command, warn bool) (*cliFile, string, error) {
70 e.fileOnce.Do(func() {
71 p, err := e.path()
72 if err != nil {
73 // Reading is best effort when the location is unknown; the
74 // `ermite config` commands report this error.
75 e.file = &cliFile{}
76 return
77 }
78 e.filePath = p
79 f, warnings, trust, err := loadConfigFile(p)
80 if err != nil {
81 e.fileErr = fmt.Errorf("config: %w", err)
82 return
83 }
84 e.fileTrust = trust
85 if warn {
86 for _, w := range warnings {
87 warnf(cmd, "config: %s", w)
88 }
89 }
90 e.file = f
91 })
92 return e.file, e.filePath, e.fileErr
93}
94
95// loadConfigFile parses the file at path (missing: empty). trust is nil when
96// token.cmd may be run from this file, else the reason why not.
97func loadConfigFile(path string) (f *cliFile, warnings []kvconf.Warning, trust, err error) {
98 h, err := openConfig(path)
99 if errors.Is(err, fs.ErrNotExist) {
100 return &cliFile{}, nil, nil, nil
101 }
102 if err != nil {
103 return nil, nil, nil, err
104 }
105 defer h.Close() //nolint:errcheck // read only
106 info, err := h.Stat()
107 if err != nil {
108 return nil, nil, nil, err
109 }
110 if !info.Mode().IsRegular() {
111 return nil, nil, nil, fmt.Errorf("%s: %w", path, kvconf.ErrNotRegular)
112 }
113 trust = checkPrivate(path, info, os.Geteuid())
114 data, err := io.ReadAll(io.LimitReader(h, kvconf.DefaultMaxBytes+1))
115 if err != nil {
116 return nil, nil, nil, err
117 }
118 if len(data) > kvconf.DefaultMaxBytes {
119 return nil, nil, nil, fmt.Errorf("%s: %w", path, kvconf.ErrTooLarge)
120 }
121 f = &cliFile{}
122 warnings, err = kvconf.Unmarshal(data, f, kvconf.Options{})
123 if err != nil {
124 return nil, nil, nil, fmt.Errorf("%s: %w", path, err)
125 }
126 return f, warnings, trust, nil
127}
128
129// urlSource says where the server address in use comes from, for error messages.
130func (e *cliEnv) urlSource() string {
131 if strings.TrimSpace(e.getenv(client.EnvURL)) != "" {
132 return client.EnvURL
133 }
134 return fmt.Sprintf("url in %s (fix it with `ermite config set url <url>`)", e.filePath)
135}
136
137// resolveURL is ERMITE_URL if set, else the url of the config file, else "".
138func (e *cliEnv) resolveURL(cmd *cobra.Command, warn bool) (string, error) {
139 if v := strings.TrimSpace(e.getenv(client.EnvURL)); v != "" {
140 return v, nil
141 }
142 f, _, err := e.readFile(cmd, warn)
143 if err != nil {
144 return "", err
145 }
146 return strings.TrimSpace(f.URL), nil
147}
148
149// tokenFromCmd is the output of token.cmd, or "" when the file sets none or
150// when ERMITE_URL points somewhere else than the url of the file: the token
151// the command fetches belongs to the server of the file, and must not be sent
152// to another one.
153func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) {
154 f, _, err := e.readFile(cmd, warn)
155 if err != nil {
156 return "", err
157 }
158 line := strings.TrimSpace(f.TokenCmd)
159 if line == "" {
160 return "", nil
161 }
162 e.tokenOnce.Do(func() {
163 if !e.urlIsFromFile(f) {
164 if warn {
165 noticef(cmd, "%s differs from the config file url: token.cmd not run (set %s)", client.EnvURL, client.EnvToken)
166 }
167 return
168 }
169 e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), line)
170 })
171 return e.token, e.tokenErr
172}
173
174// urlIsFromFile reports whether the server in use is the one of the config
175// file: ERMITE_URL is unset, or names the same address as the file's url.
176func (e *cliEnv) urlIsFromFile(f *cliFile) bool {
177 envURL := strings.TrimSpace(e.getenv(client.EnvURL))
178 if envURL == "" {
179 return true
180 }
181 a, err := client.ParseURL(envURL)
182 if err != nil {
183 return false
184 }
185 b, err := client.ParseURL(strings.TrimSpace(f.URL))
186 return err == nil && a.String() == b.String()
187}
188
189// fetchToken runs token.cmd. Neither its output nor its error text reach the
190// caller's messages: the output is the secret.
191func (e *cliEnv) fetchToken(ctx context.Context, line string) (string, error) {
192 if e.fileTrust != nil {
193 return "", e.fileTrust
194 }
195 out, err := e.runTokenCmd(ctx, line)
196 if err != nil {
197 return "", fmt.Errorf("token.cmd failed: %w", err)
198 }
199 out = strings.TrimSpace(out)
200 switch {
201 case out == "":
202 return "", errors.New("token.cmd printed nothing")
203 case strings.ContainsAny(out, "\r\n"):
204 return "", errors.New("token.cmd printed more than one line")
205 }
206 return out, nil
207}
208
209// runTokenShell is the default runTokenCmd: the system shell, for at most
210// tokenCmdTimeout.
211func runTokenShell(ctx context.Context, line string) (string, error) {
212 return runShell(ctx, line, tokenCmdTimeout)
213}
214
215// runShell runs line with the system shell. Standard error is the terminal's,
216// and so is standard input when it is a terminal, so a pinentry or a biometric
217// prompt works; a script or an agent gets no input. Standard output is
218// captured, up to tokenCmdMaxOutput bytes. The environment is the process's.
219// An interrupt or SIGTERM sent to ermite, or the end of ctx, stops the command
220// like a timeout does (see shellCommand for what is killed).
221func runShell(ctx context.Context, line string, timeout time.Duration) (string, error) {
222 sigCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
223 defer stop()
224 runCtx, cancel := context.WithTimeout(sigCtx, timeout)
225 defer cancel()
226
227 foreground := term.IsTerminal(int(os.Stdin.Fd()))
228 c := shellCommand(runCtx, line, foreground)
229 out := &cappedBuffer{max: tokenCmdMaxOutput}
230 c.Stdout, c.Stderr = out, os.Stderr
231 if foreground {
232 c.Stdin = os.Stdin
233 } // else nil: the command reads os.DevNull
234 // Do not wait forever for a background process that kept the pipe open.
235 c.WaitDelay = 2 * time.Second
236 err := c.Run()
237 if err != nil {
238 switch {
239 case sigCtx.Err() != nil:
240 return "", errors.New("interrupted")
241 case errors.Is(runCtx.Err(), context.DeadlineExceeded):
242 return "", fmt.Errorf("timed out after %s", timeout)
243 }
244 if exit := (*exec.ExitError)(nil); errors.As(err, &exit) {
245 return "", errors.New(exit.String())
246 }
247 return "", err
248 }
249 if out.over {
250 return "", fmt.Errorf("printed more than %d bytes", tokenCmdMaxOutput)
251 }
252 return out.buf.String(), nil
253}
254
255// cappedBuffer keeps the first max bytes written and notes an overflow.
256type cappedBuffer struct {
257 buf bytes.Buffer
258 max int
259 over bool
260}
261
262func (b *cappedBuffer) Write(p []byte) (int, error) {
263 n := len(p) // report everything as written: a short write would abort the copy
264 if room := b.max - b.buf.Len(); len(p) > room {
265 b.over = true
266 p = p[:room]
267 }
268 b.buf.Write(p)
269 return n, nil
270}
271
272var _ io.Writer = (*cappedBuffer)(nil)