// Package meshcli is the node-engine's side of `mesh-cli` (novox/hq ADR 0272): a socket on this machine that any // account may connect to, the account read from the kernel, and the line asked of the controller. // // **The node-engine vouches; it does not decide.** It adds what only it can know — which account on this machine // is asking, from the kernel's peer credentials, never from anything the program says — and sends the request out // over its own link, on its own node's subject. The controller judges whether the line runs as its terminal, as an // ordinary call, or not at all (ADR 0272 §4). Nothing received here is run or applied on the machine. // // **No listening control surface** (novox/hq ADR 0004, ADR 0272 §3): the socket has no network address, so // nothing on another machine reaches it, and nothing sent to it acts on this one. package meshcli import ( "context" "errors" "fmt" "net" "os" "os/user" "path/filepath" "strconv" "sync" "time" "git.novox.be/novox/mesh-sdk/go/cli" ) // Asked is what the node-engine sends the controller on its own node's subject: the line, and the account the // kernel says is asking. The controller's side holds the same field names (a test on each side). type Asked struct { Line []string `json:"line"` // Account is the login of the user id the kernel gave, from this machine's user database. Account string `json:"account"` // UID is the user id itself, so a renamed or duplicated login is still the same id. UID uint32 `json:"uid"` } // Forward asks the controller, and is answered. An error is said to the program as a refusal: nothing ran. type Forward func(ctx context.Context, asked Asked) (cli.Answer, error) // readWithin bounds how long a connection may take to say its line: a program that connects and says nothing // holds nothing for long. const readWithin = 30 * time.Second // atOnce bounds how many lines are asked at the same time. A person types one at a time; more than this is a // program on the machine, and it waits. const atOnce = 8 // peerOf reads the connecting process's credentials from the kernel. A variable so a test can say what the kernel // would; it is the only source of the account. var peerOf = peerCredentials // lookupAccount names a user id from this machine's user database. var lookupAccount = func(uid uint32) (string, error) { u, err := user.LookupId(strconv.FormatUint(uint64(uid), 10)) if err != nil { return "", err } return u.Username, nil } // Serve listens on socket until ctx ends, and answers each connection: one request, one answer. // // The directory is made 0755 and the socket 0666: every account on the machine may ask, because the account is // what is vouched for and the controller decides what it may do. A file at the socket's path that is not a socket // is never removed: the engine says so and serves nothing. func Serve(ctx context.Context, socket string, forward Forward, say func(string)) error { dir := filepath.Dir(socket) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("mesh-cli's socket directory: %w", err) } // The directory is the engine's: a link, or one another account owns, could put the socket where that // account replaces it with its own. Said, and nothing served. if problem := notOwnDirectory(dir); problem != "" { return fmt.Errorf("mesh-cli is not served on this machine: %s", problem) } if err := os.Chmod(dir, 0o755); err != nil { return fmt.Errorf("mesh-cli's socket directory: %w", err) } if info, err := os.Lstat(socket); err == nil { if info.Mode()&os.ModeSocket == 0 { return fmt.Errorf("%s is there and is not a socket, and the node-engine did not put it there; "+ "mesh-cli is not served on this machine until it is moved", socket) } // A socket left by the engine before this one: nothing listens on it any more. if err := os.Remove(socket); err != nil { return fmt.Errorf("the socket a previous engine left at %s: %w", socket, err) } } l, err := net.ListenUnix("unix", &net.UnixAddr{Name: socket, Net: "unix"}) if err != nil { return fmt.Errorf("listening for mesh-cli at %s: %w", socket, err) } if err := os.Chmod(socket, 0o666); err != nil { l.Close() return fmt.Errorf("opening mesh-cli's socket to every account: %w", err) } go func() { <-ctx.Done() l.Close() }() slots := make(chan struct{}, atOnce) var wg sync.WaitGroup defer wg.Wait() for { conn, err := l.AcceptUnix() if err != nil { if ctx.Err() != nil { return nil } var ne net.Error if errors.As(err, &ne) && ne.Timeout() { continue } return fmt.Errorf("mesh-cli's socket: %w", err) } select { case slots <- struct{}{}: case <-ctx.Done(): conn.Close() return nil } wg.Add(1) go func() { defer wg.Done() defer func() { <-slots }() answer(ctx, conn, forward, say) }() } } // answer serves one connection. func answer(ctx context.Context, conn *net.UnixConn, forward Forward, say func(string)) { defer conn.Close() reply := func(a cli.Answer) { _ = conn.SetWriteDeadline(time.Now().Add(readWithin)) if err := cli.WriteAnswer(conn, a); err != nil && say != nil { say(fmt.Sprintf("mesh-cli: the answer could not be written back: %v", err)) } } uid, err := peerOf(conn) if err != nil { // Never sent as anyone: an account the kernel did not give is no account at all. reply(cli.Refusal("the node-engine could not read which account is asking, so nothing was asked: " + err.Error())) return } account, err := lookupAccount(uid) if err != nil || account == "" { reply(cli.Refusal(fmt.Sprintf("user id %d is not in this machine's user database, so nothing was asked", uid))) return } _ = conn.SetReadDeadline(time.Now().Add(readWithin)) req, err := cli.ReadRequest(conn) if err != nil { reply(cli.Refusal("the request could not be read, so nothing was asked: " + err.Error())) return } asking, cancel := context.WithTimeout(ctx, cli.AskWithin-time.Minute/2) defer cancel() a, err := forward(asking, Asked{Line: req.Line, Account: account, UID: uid}) if err != nil { reply(cli.Refusal("the controller was not reached, so nothing ran: " + err.Error())) return } reply(a) }