// Package cli is the local protocol between `mesh-cli` and the node-engine on its machine (novox/hq ADR 0272). // // `mesh-cli ` 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 }