From f047d0a4a9702f5d7f3d7eadd3e62496311acc00 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 16:33:44 +0200 Subject: [PATCH] go/cli: carry mesh-cli's standard input with its line, bounded at 64 KiB, for the controller's terminal alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A secret given at the terminal (secret accept … --from -) has no way through mesh-cli today: the controller runs every line with no standard input. The request now carries it, refused over 64 KiB by mesh-cli and by the reader alike, and MaxRequest grows by what base64 makes of that bound. --- go/cli/cli.go | 35 +++++++++++++++++++++++++++++------ go/cli/cli_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/go/cli/cli.go b/go/cli/cli.go index 30180fc..16d2a2a 100644 --- a/go/cli/cli.go +++ b/go/cli/cli.go @@ -24,9 +24,13 @@ import ( // address (ADR 0272 §3). const Socket = "/run/nox-mesh-host/cli.sock" -// MaxRequest bounds what the node-engine reads of a request. A command line is a few hundred bytes; settings given -// inline are a few kilobytes. -const MaxRequest = 64 << 10 +// MaxStdin bounds the standard input mesh-cli carries for a line: a secret, a key, a settings file — kilobytes. More +// is refused by mesh-cli before anything is asked, and by the node-engine and the controller again. +const MaxStdin = 64 << 10 + +// MaxRequest bounds what the node-engine reads of a request: a command line of a few hundred bytes, settings given +// inline of a few kilobytes, and standard input of at most MaxStdin, which JSON writes as base64 (4 bytes for every 3). +const MaxRequest = 64<<10 + MaxStdin*4/3 + 4 // MaxAnswer bounds what mesh-cli reads of an answer. The controller cuts what it runs to fit one bus message, and // says so; this is the local bound above that. @@ -36,9 +40,16 @@ const MaxAnswer = 16 << 20 // and a minute for the link on either side. const AskWithin = 6 * time.Minute -// Request is what mesh-cli asks: the controller's command line, as words. +// Request is what mesh-cli asks: the controller's command line, as words, and what mesh-cli's own standard input +// held when it was not a terminal. +// +// **Stdin is for the controller's terminal alone** (novox/hq ADR 0259 §10, ADR 0272): a secret given with +// `secret accept … --from -`, so it never stands on a command line. The controller gives it to a line that runs as +// its terminal and refuses any other line that carries it; nobody keeps it — not the node-engine, not the +// controller's record of calls, not its journal. type Request struct { - Line []string `json:"line"` + Line []string `json:"line"` + Stdin []byte `json:"stdin,omitempty"` } // Answer is what comes back: what the command printed, how it exited, and whether it ran as the controller's @@ -87,6 +98,9 @@ func ReadRequest(r io.Reader) (Request, error) { if len(req.Line) == 0 { return Request{}, errors.New("the request names no command") } + if len(req.Stdin) > MaxStdin { + return Request{}, fmt.Errorf("%w: standard input of more than %d bytes", ErrTooLong, MaxStdin) + } return req, nil } @@ -102,9 +116,18 @@ func ReadAnswer(r io.Reader) (Answer, error) { // Ask is mesh-cli's side: connect to the socket, ask, and wait for the answer within AskWithin or ctx. func Ask(ctx context.Context, socket string, line []string) (Answer, error) { + return AskWith(ctx, socket, Request{Line: line}) +} + +// AskWith asks a whole request: a line, and the standard input it carries, at most MaxStdin. +func AskWith(ctx context.Context, socket string, req Request) (Answer, error) { + line := req.Line if len(line) == 0 { return Answer{}, errors.New("no command given") } + if len(req.Stdin) > MaxStdin { + return Answer{}, fmt.Errorf("standard input is more than %d bytes, which mesh-cli does not carry; nothing was asked", MaxStdin) + } ctx, cancel := context.WithTimeout(ctx, AskWithin) defer cancel() var d net.Dialer @@ -116,7 +139,7 @@ func Ask(ctx context.Context, socket string, line []string) (Answer, error) { if deadline, ok := ctx.Deadline(); ok { _ = conn.SetDeadline(deadline) } - if err := WriteRequest(conn, Request{Line: line}); err != nil { + if err := WriteRequest(conn, req); err != nil { return Answer{}, fmt.Errorf("asking the node-engine: %w", err) } a, err := ReadAnswer(conn) diff --git a/go/cli/cli_test.go b/go/cli/cli_test.go index 893dba4..92c5050 100644 --- a/go/cli/cli_test.go +++ b/go/cli/cli_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "context" + "encoding/json" "errors" "net" "path/filepath" @@ -44,6 +45,17 @@ func TestARequestAndAnAnswerSurviveTheirRoundTrip(t *testing.T) { // A request over the bound is refused, not read whole; one naming no command is refused. func TestARequestOverTheBoundOrNamingNothingIsRefused(t *testing.T) { long := Request{Line: []string{strings.Repeat("x", MaxRequest)}} + big := Request{Line: []string{"secret"}, Stdin: bytes.Repeat([]byte("s"), MaxStdin+1)} + var bb bytes.Buffer + if err := WriteRequest(&bb, big); err != nil { + t.Fatal(err) + } + if _, err := ReadRequest(&bb); !errors.Is(err, ErrTooLong) { + t.Fatalf("standard input over its bound was read: %v", err) + } + if _, err := AskWith(context.Background(), "/nonexistent.sock", big); err == nil || !strings.Contains(err.Error(), "nothing was asked") { + t.Fatalf("mesh-cli would ask with standard input over its bound: %v", err) + } var b bytes.Buffer if err := WriteRequest(&b, long); err != nil { t.Fatal(err) @@ -103,3 +115,27 @@ func TestAnUnknownOutcomeIsNotARefusal(t *testing.T) { t.Fatalf("an unknown outcome with no call names one: %+v", b) } } + +// Standard input up to its bound travels whole, beside the line: a secret given with --from -. +func TestStandardInputTravelsWithItsLine(t *testing.T) { + given := append(bytes.Repeat([]byte{0, 0xff, '\n', 'x'}, MaxStdin/4-1), "end"...) + var b bytes.Buffer + if err := WriteRequest(&b, Request{Line: []string{"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "-"}, + Stdin: given}); err != nil { + t.Fatal(err) + } + if b.Len() > MaxRequest { + t.Fatalf("a request with standard input at its bound is %d bytes, over MaxRequest %d", b.Len(), MaxRequest) + } + got, err := ReadRequest(&b) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got.Stdin, given) || got.Line[1] != "accept" { + t.Fatalf("standard input did not travel whole: %d bytes, want %d", len(got.Stdin), len(given)) + } + none, _ := json.Marshal(Request{Line: []string{"status"}}) + if strings.Contains(string(none), "stdin") { + t.Fatalf("a request without standard input says it has one: %s", none) + } +}