mesh/merge-gate pass: builds mesh-sdk, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript), go/merge-check.sh (go)
mesh/delivery covered after its stop: a later walk that contains it was delivered: plan-1791659640519725407 (novox/mesh-sdk at 19cfe0ef into main, done, …
mesh/delivery-group group feat/a-terminal-line-takes-standard-input delivered: every member is delivered
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.
188 lines
7.1 KiB
Go
188 lines
7.1 KiB
Go
// Package cli is the local protocol between `mesh-cli` and the node-engine on its machine (novox/hq ADR 0272).
|
|
//
|
|
// `mesh-cli <command line>` asks the controller to run one of its command lines. It holds no credential and
|
|
// reads no file of the mesh: it connects to the node-engine's socket, writes one Request, and reads one Answer.
|
|
// The node-engine reads the connecting process's account from the kernel and asks the controller over its own
|
|
// link; that request is the node-engine's and the controller's, and lives in neither this package nor any SDK
|
|
// (ADR 0039 as ADR 0188 reads it: the transport lives in the runtime).
|
|
//
|
|
// One request and one answer per connection, each one line of JSON. Standard library only.
|
|
package cli
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"time"
|
|
)
|
|
|
|
// Socket is where the node-engine listens for mesh-cli: a file under its runtime directory, with no network
|
|
// address (ADR 0272 §3).
|
|
const Socket = "/run/nox-mesh-host/cli.sock"
|
|
|
|
// 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.
|
|
const MaxAnswer = 16 << 20
|
|
|
|
// AskWithin is how long mesh-cli waits for its answer: the controller's own bound on a call (five minutes),
|
|
// 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, 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"`
|
|
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
|
|
// terminal and why. Refused is set when nothing ran at all, and says why; Exit is then 1.
|
|
type Answer struct {
|
|
Stdout []byte `json:"stdout,omitempty"`
|
|
Stderr []byte `json:"stderr,omitempty"`
|
|
Exit int `json:"exit"`
|
|
Terminal bool `json:"terminal"`
|
|
// Why says whether the line ran as the terminal, and why or why not, in the controller's words.
|
|
Why string `json:"why,omitempty"`
|
|
// Refused says why nothing ran: the account, the node, the controller unreachable.
|
|
Refused string `json:"refused,omitempty"`
|
|
// Cut says the output was longer than one bus message and was cut there.
|
|
Cut bool `json:"cut,omitempty"`
|
|
// Unknown says the line was asked and its outcome is not known: no answer came in time, and it may have
|
|
// run. Never said as a refusal, which means nothing ran.
|
|
Unknown string `json:"unknown,omitempty"`
|
|
}
|
|
|
|
// Refusal is an answer saying that nothing ran, and why.
|
|
func Refusal(why string) Answer { return Answer{Exit: 1, Refused: why} }
|
|
|
|
// NotKnown is an answer saying the line was asked and what came of it is not known: it may have run. call is
|
|
// the controller's call the line runs as, when it is known, which `calls` answers.
|
|
func NotKnown(call, why string) Answer {
|
|
where := "the controller's `calls` lists what came of each line"
|
|
if call != "" {
|
|
where = "see the call " + call + " (`calls " + call + "`)"
|
|
}
|
|
return Answer{Exit: 1, Unknown: why + "; the line may have run — " + where}
|
|
}
|
|
|
|
// ErrTooLong is a request or an answer over its bound.
|
|
var ErrTooLong = errors.New("longer than the protocol allows")
|
|
|
|
// WriteRequest writes one request, as one line.
|
|
func WriteRequest(w io.Writer, r Request) error { return writeLine(w, r) }
|
|
|
|
// ReadRequest reads one request, refusing one over MaxRequest or one that names no command.
|
|
func ReadRequest(r io.Reader) (Request, error) {
|
|
var req Request
|
|
if err := readLine(r, MaxRequest, &req); err != nil {
|
|
return Request{}, err
|
|
}
|
|
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
|
|
}
|
|
|
|
// WriteAnswer writes one answer, as one line.
|
|
func WriteAnswer(w io.Writer, a Answer) error { return writeLine(w, a) }
|
|
|
|
// ReadAnswer reads one answer, refusing one over MaxAnswer.
|
|
func ReadAnswer(r io.Reader) (Answer, error) {
|
|
var a Answer
|
|
err := readLine(r, MaxAnswer, &a)
|
|
return a, err
|
|
}
|
|
|
|
// 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
|
|
conn, err := d.DialContext(ctx, "unix", socket)
|
|
if err != nil {
|
|
return Answer{}, fmt.Errorf("the node-engine on this machine does not answer at %s: %w", socket, err)
|
|
}
|
|
defer conn.Close()
|
|
if deadline, ok := ctx.Deadline(); ok {
|
|
_ = conn.SetDeadline(deadline)
|
|
}
|
|
if err := WriteRequest(conn, req); err != nil {
|
|
return Answer{}, fmt.Errorf("asking the node-engine: %w", err)
|
|
}
|
|
a, err := ReadAnswer(conn)
|
|
if err != nil {
|
|
return Answer{}, fmt.Errorf("reading the node-engine's answer: %w", err)
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
func writeLine(w io.Writer, v any) error {
|
|
body, err := json.Marshal(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = w.Write(append(body, '\n'))
|
|
return err
|
|
}
|
|
|
|
// readLine reads one line of JSON of at most limit bytes. A line over the limit is refused rather than read
|
|
// whole: the reader is the side that pays for it.
|
|
func readLine(r io.Reader, limit int, into any) error {
|
|
br := bufio.NewReaderSize(io.LimitReader(r, int64(limit)+1), 64<<10)
|
|
var body []byte
|
|
for {
|
|
chunk, err := br.ReadSlice('\n')
|
|
body = append(body, chunk...)
|
|
if len(body) > limit {
|
|
return fmt.Errorf("%w: more than %d bytes", ErrTooLong, limit)
|
|
}
|
|
if err == nil {
|
|
break
|
|
}
|
|
if errors.Is(err, bufio.ErrBufferFull) {
|
|
continue
|
|
}
|
|
if errors.Is(err, io.EOF) && len(body) > 0 {
|
|
break
|
|
}
|
|
return err
|
|
}
|
|
if err := json.Unmarshal(body, into); err != nil {
|
|
return fmt.Errorf("not one: %w", err)
|
|
}
|
|
return nil
|
|
}
|