diff --git a/docs/vctx.md b/docs/vctx.md index 8cdb806..5868b5e 100644 --- a/docs/vctx.md +++ b/docs/vctx.md @@ -57,7 +57,8 @@ When the effective format is `json` or `yaml` (from the table above, `--format`, a `-format` vault arg, or `VAULT_FORMAT`), `vctx` colourises vault's output like `jq`/`yq`. To do so it runs `vault` as a child instead of exec'ing it: stdin and stderr pass straight through, stdout is buffered and re-emitted -coloured, SIGINT/SIGTERM are forwarded, and vault's exit code is returned +coloured, SIGTERM/SIGHUP are forwarded (SIGINT already reaches vault from the +terminal), and vault's exit code is returned (`128+n` if killed by signal `n`). `--color` controls this: @@ -71,7 +72,8 @@ coloured, SIGINT/SIGTERM are forwarded, and vault's exit code is returned Without colour (piped output, `NO_COLOR`, `table`/`raw`/`pretty` formats) `vctx` execs `vault` exactly as before, so scripts see unchanged output. Streaming or interactive commands (`monitor`, `debug`, `events`, `ssh`, -`agent`, `proxy`, `server`, `login`, `operator`) are always exec'd and never coloured. `JQ_COLORS` +`agent`, `proxy`, `server`, `login`, `operator`, and `write` to an `auth/` +path, which may prompt for MFA) are always exec'd and never coloured. `JQ_COLORS` overrides the JSON palette as in `jq`. ## Flags diff --git a/vctx/main.go b/vctx/main.go index f68be85..2828f53 100644 --- a/vctx/main.go +++ b/vctx/main.go @@ -17,6 +17,7 @@ import ( "os" "os/exec" "os/signal" + "slices" "strings" "syscall" @@ -239,7 +240,8 @@ func autoFormat(args, baseEnv []string) string { // when it is one we colourise, else "". A -format arg beats VAULT_FORMAT, as in // vault itself. func colourFormat(args, env []string) string { - f, cmd := "", "" + f := "" + var pos []string for _, e := range env { if v, ok := strings.CutPrefix(e, "VAULT_FORMAT="); ok { f = v @@ -248,15 +250,11 @@ func colourFormat(args, env []string) string { for i := 0; i < len(args); i++ { a := args[i] if a == "--" { - if cmd == "" && i+1 < len(args) { - cmd = args[i+1] - } + pos = append(pos, args[i+1:]...) break } if !strings.HasPrefix(a, "-") { - if cmd == "" { - cmd = a - } + pos = append(pos, a) continue } name, val, hasVal := strings.Cut(strings.TrimLeft(a, "-"), "=") @@ -270,14 +268,17 @@ func colourFormat(args, env []string) string { f = val } f = strings.ToLower(f) - if uncaptured[cmd] || (f != "json" && f != "yaml") { + if len(pos) == 0 || uncaptured[pos[0]] || (pos[0] == "write" && slices.ContainsFunc(pos[1:], isAuthPath)) || (f != "json" && f != "yaml") { return "" } return f } -// runChild runs vault with stdin/stderr passed through, forwards SIGINT and -// SIGTERM, then writes its captured stdout colourised to w. It returns vault's +// isAuthPath matches auth/ paths; a write there may be a login prompting for MFA on stdout. +func isAuthPath(p string) bool { return strings.HasPrefix(strings.TrimPrefix(p, "/"), "auth/") } + +// runChild runs vault with stdin/stderr passed through, forwards SIGTERM and +// SIGHUP, then writes its captured stdout colourised to w. It returns vault's // exit code, or 128+n if it died from signal n. func runChild(bin string, argv, env []string, format string, w io.Writer) (int, error) { var out bytes.Buffer @@ -285,7 +286,8 @@ func runChild(bin string, argv, env []string, format string, w io.Writer) (int, cmd.Env, cmd.Stdin, cmd.Stdout, cmd.Stderr = env, os.Stdin, &out, os.Stderr sigs := make(chan os.Signal, 1) - signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + // SIGINT is caught but not forwarded: vault is in our process group and already gets ^C. + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) defer signal.Stop(sigs) if err := cmd.Start(); err != nil { return 0, fmt.Errorf("run vault: %w", err) @@ -296,7 +298,9 @@ func runChild(bin string, argv, env []string, format string, w io.Writer) (int, for { select { case s := <-sigs: - _ = cmd.Process.Signal(s) + if s != syscall.SIGINT { + _ = cmd.Process.Signal(s) + } case <-done: return } diff --git a/vctx/main_test.go b/vctx/main_test.go index e64d3da..c999599 100644 --- a/vctx/main_test.go +++ b/vctx/main_test.go @@ -284,6 +284,11 @@ func TestColourFormat(t *testing.T) { {[]string{"operator", "unseal"}, []string{"VAULT_FORMAT=json"}, ""}, {[]string{"-format=yaml", "operator", "generate-root"}, nil, ""}, {[]string{"operator", "rekey", "-format=json"}, nil, ""}, + {[]string{"write", "auth/userpass/login/bob"}, []string{"VAULT_FORMAT=json"}, ""}, + {[]string{"write", "-format=json", "auth/x/login/y"}, nil, ""}, + {[]string{"write", "-format", "yaml", "auth/x/login/y", "password=p"}, nil, ""}, + {[]string{"write", "secret/foo", "a=b"}, []string{"VAULT_FORMAT=json"}, "json"}, + {[]string{"write", "-format=json", "secret/foo", "a=b"}, nil, "json"}, } for _, c := range cases { if got := colourFormat(c.args, c.env); got != c.want { @@ -331,7 +336,8 @@ func TestRunChild(t *testing.T) { func TestRunChildForwardsSignal(t *testing.T) { dir := t.TempDir() - bin := fakeVault(t, `trap 'echo TERM > "$D/got"; trap - TERM; kill -TERM $$' TERM + bin := fakeVault(t, `trap 'echo INT >> "$D/got"' INT +trap 'echo TERM >> "$D/got"; trap - TERM; kill -TERM $$' TERM touch "$D/ready" i=0; while [ $i -lt 100 ]; do sleep 0.05; i=$((i+1)); done`) type res struct { @@ -349,6 +355,10 @@ i=0; while [ $i -lt 100 ]; do sleep 0.05; i=$((i+1)); done`) } time.Sleep(10 * time.Millisecond) } + if err := syscall.Kill(os.Getpid(), syscall.SIGINT); err != nil { + t.Fatal(err) + } + time.Sleep(200 * time.Millisecond) if err := syscall.Kill(os.Getpid(), syscall.SIGTERM); err != nil { t.Fatal(err) } @@ -357,7 +367,7 @@ i=0; while [ $i -lt 100 ]; do sleep 0.05; i=$((i+1)); done`) t.Fatal(r.err) } if got, _ := os.ReadFile(filepath.Join(dir, "got")); string(got) != "TERM\n" { - t.Errorf("child did not receive SIGTERM (got %q)", got) + t.Errorf("child signals = %q, want only SIGTERM", got) } if r.code != 128+15 { t.Errorf("code = %d, want %d", r.code, 128+15)