A service running as the operator — the tool runner, a unit of the account's user manager — shares the operator's user id, and was as much the terminal on the control-node as a person typing. The engine now reads the asking process's cgroup and sends its login session's scope (session-<id>.scope under the account's own user-<uid>.slice), or nothing; the controller makes nothing else the terminal. The SDK is pinned at its tag now that go/v0.1.9 exists.
244 lines
9.4 KiB
Go
244 lines
9.4 KiB
Go
// 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"
|
|
"strings"
|
|
"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,omitempty"`
|
|
// 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"`
|
|
// Follow is a call a line runs as, asked about again by the account that asked it (AskTheController).
|
|
Follow string `json:"follow,omitempty"`
|
|
// Session is the login session the asking process runs in — its systemd scope, `session-<id>.scope` under the
|
|
// account's own slice — as the kernel's cgroup says it; empty for anything else: a system service, a user unit,
|
|
// a process another account's session started. Only a login session is a person at a terminal (novox/hq ADR
|
|
// 0272): the controller makes nothing else the terminal.
|
|
Session string `json:"session,omitempty"`
|
|
}
|
|
|
|
// Forward asks the controller and follows the line to what came of it (AskTheController): what it printed, that
|
|
// nothing ran and why, or that it is not known.
|
|
type Forward func(ctx context.Context, asked Asked) cli.Answer
|
|
|
|
// readWithin bounds how long a connection may take to say its line: a program that connects and says nothing
|
|
// holds nothing for long. A variable so a test need not wait.
|
|
var readWithin = 30 * time.Second
|
|
|
|
// atOnce bounds how many lines are asked at the same time, and perAccount how many of them one account's: a person
|
|
// types one at a time, so more is a program on the machine. One over either bound is answered busy at once — the
|
|
// engine never stops accepting for it, so one account cannot hold the socket from every other (review of ADR 0272).
|
|
var (
|
|
atOnce = 8
|
|
perAccount = 2
|
|
)
|
|
|
|
// slots counts the lines being asked, in all and per user id.
|
|
type slots struct {
|
|
mu sync.Mutex
|
|
all int
|
|
byUID map[uint32]int
|
|
}
|
|
|
|
func (s *slots) take(uid uint32) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.all >= atOnce || s.byUID[uid] >= perAccount {
|
|
return false
|
|
}
|
|
s.all++
|
|
s.byUID[uid]++
|
|
return true
|
|
}
|
|
|
|
func (s *slots) give(uid uint32) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.all--
|
|
if s.byUID[uid]--; s.byUID[uid] <= 0 {
|
|
delete(s.byUID, uid)
|
|
}
|
|
}
|
|
|
|
// peerOf reads the connecting process's credentials from the kernel: its user id and process id. A variable so a
|
|
// test can say what the kernel would; it is the only source of the account.
|
|
var peerOf = peerCredentials
|
|
|
|
// sessionOf reads the login session a process runs in, from the kernel. A variable so a test can say.
|
|
var sessionOf = func(pid int32, uid uint32) string {
|
|
raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/cgroup", pid))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return loginSessionIn(string(raw), uid)
|
|
}
|
|
|
|
// loginSessionIn is the login session scope a process's cgroup names, for its own account: the unified hierarchy's
|
|
// line, `0::/user.slice/user-<uid>.slice/session-<id>.scope`, and nothing below or beside it. Anything else is no
|
|
// login session — a service under system.slice, a unit under the account's user manager (user@<uid>.service), a
|
|
// scope of another account's.
|
|
func loginSessionIn(cgroup string, uid uint32) string {
|
|
for _, line := range strings.Split(cgroup, "\n") {
|
|
path, ok := strings.CutPrefix(line, "0::")
|
|
if !ok {
|
|
continue
|
|
}
|
|
prefix := fmt.Sprintf("/user.slice/user-%d.slice/", uid)
|
|
scope, ok := strings.CutPrefix(path, prefix)
|
|
if !ok || strings.Contains(scope, "/") || !strings.HasPrefix(scope, "session-") || !strings.HasSuffix(scope, ".scope") {
|
|
return ""
|
|
}
|
|
return scope
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// sameNamespaces refuses a peer whose mount or user namespace is not the engine's. A variable so a test can say.
|
|
var sameNamespaces = namespacesOf
|
|
|
|
// 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()
|
|
}()
|
|
held := &slots{byUID: map[uint32]int{}}
|
|
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)
|
|
}
|
|
// Bounded from the first byte: nothing below may wait on a connection for longer than this before the
|
|
// line is read, whatever the peer does (review of ADR 0272).
|
|
_ = conn.SetDeadline(time.Now().Add(readWithin))
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
answer(ctx, conn, held, forward, say)
|
|
}()
|
|
}
|
|
}
|
|
|
|
// answer serves one connection.
|
|
func answer(ctx context.Context, conn *net.UnixConn, held *slots, 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, pid, 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
|
|
}
|
|
// The user id is this machine's account only in this machine's namespaces: a container running with the
|
|
// operator's id shares the number and nothing else (review of ADR 0272).
|
|
if err := sameNamespaces(pid); err != nil {
|
|
reply(cli.Refusal("the asking process does not run in this machine's own namespaces, so it is not this " +
|
|
"machine's account and nothing was asked: " + err.Error()))
|
|
return
|
|
}
|
|
if !held.take(uid) {
|
|
reply(cli.Refusal(fmt.Sprintf("busy: user id %d has %d lines being asked already, or the machine %d; ask "+
|
|
"again when one has ended. Nothing was asked", uid, perAccount, atOnce)))
|
|
return
|
|
}
|
|
defer held.give(uid)
|
|
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
|
|
}
|
|
req, err := cli.ReadRequest(conn)
|
|
if err != nil {
|
|
reply(cli.Refusal("the request could not be read, so nothing was asked: " + err.Error()))
|
|
return
|
|
}
|
|
// From here the wait is the controller's: as long as mesh-cli waits, and no longer.
|
|
_ = conn.SetDeadline(time.Now().Add(cli.AskWithin))
|
|
asking, cancel := context.WithTimeout(ctx, cli.AskWithin-30*time.Second)
|
|
defer cancel()
|
|
reply(forward(asking, Asked{Line: req.Line, Account: account, UID: uid, Session: sessionOf(pid, uid)}))
|
|
}
|