vctx: exec auth/ writes uncoloured; stop forwarding SIGINT to vault
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

This commit is contained in:
2026-10-23 23:17:42 +11:00
parent 92223ce33d
commit 1cedcbde19
3 changed files with 32 additions and 16 deletions
+4 -2
View File
@@ -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
+16 -12
View File
@@ -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
}
+12 -2
View File
@@ -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)