- UID, CLASS and SCOPE are read at their first occurrence: logind writes
each once, and a later line saying otherwise is not the record's word.
- A comment says /run/systemd/sessions is systemd's private format, and
that a format change fails closed: no session, so nobody is the terminal.
- Tests that hold, each proved by the mutation it guards failing:
- a record naming UID=1001 for session-8.scope under user-1000.slice,
asked as uid 1000 (the record's UID check alone);
- session-a.b.scope with a passing record (the id's characters alone);
- session-9.scope/x.scope (the scope being the slice's direct child);
- a key said twice, either way round (first occurrence);
- the client sends its line only after the session was read, so reading
the session in the forward fails the test.
349 lines
13 KiB
Go
349 lines
13 KiB
Go
package meshcli
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/cli"
|
|
)
|
|
|
|
// serving starts Serve on a socket of the test's own, and stops it when the test ends.
|
|
func serving(t *testing.T, forward Forward) string {
|
|
t.Helper()
|
|
socket := filepath.Join(t.TempDir(), "run", "cli.sock")
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() { done <- Serve(ctx, socket, forward, func(string) {}) }()
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
if err := <-done; err != nil {
|
|
t.Errorf("Serve ended with %v", err)
|
|
}
|
|
})
|
|
for i := 0; i < 200; i++ {
|
|
if _, err := os.Stat(socket); err == nil {
|
|
return socket
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatal("the socket never appeared")
|
|
return ""
|
|
}
|
|
|
|
// The account sent is the one the kernel gives for the connecting process, and the answer comes back as it was.
|
|
func TestTheAccountAskingIsTheKernelsAndTheAnswerComesBack(t *testing.T) {
|
|
var got Asked
|
|
socket := serving(t, func(_ context.Context, asked Asked) cli.Answer {
|
|
got = asked
|
|
return cli.Answer{Stdout: []byte("done\n"), Exit: 0, Terminal: true, Why: "the terminal"}
|
|
})
|
|
a, err := cli.Ask(context.Background(), socket, []string{"settings", "show", "zsh"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(a.Stdout) != "done\n" || !a.Terminal || a.Refused != "" {
|
|
t.Fatalf("the answer came back as %+v", a)
|
|
}
|
|
me, err := user.Current()
|
|
if err != nil {
|
|
t.Skipf("this machine cannot name the test's account: %v", err)
|
|
}
|
|
if int(got.UID) != os.Getuid() || got.Account != me.Username {
|
|
t.Fatalf("asked as %d %q, and the kernel says %d %q", got.UID, got.Account, os.Getuid(), me.Username)
|
|
}
|
|
if strings.Join(got.Line, " ") != "settings show zsh" {
|
|
t.Fatalf("the line was sent as %q", got.Line)
|
|
}
|
|
if info, err := os.Stat(socket); err != nil || info.Mode().Perm() != 0o666 {
|
|
t.Fatalf("the socket is not open to every account: %v %v", info.Mode(), err)
|
|
}
|
|
}
|
|
|
|
// Credentials the kernel did not give are no account: refused, and nothing is asked.
|
|
func TestAConnectionWhoseAccountCannotBeReadIsNeverAskedAsAnyone(t *testing.T) {
|
|
before := peerOf
|
|
peerOf = func(*net.UnixConn) (uint32, int32, error) { return 0, 0, errors.New("no credentials") }
|
|
t.Cleanup(func() { peerOf = before })
|
|
asked := false
|
|
socket := serving(t, func(context.Context, Asked) cli.Answer {
|
|
asked = true
|
|
return cli.Answer{}
|
|
})
|
|
a, err := cli.Ask(context.Background(), socket, []string{"status"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if asked || a.Refused == "" || a.Exit == 0 {
|
|
t.Fatalf("a connection with no account was asked (%v) or not refused: %+v", asked, a)
|
|
}
|
|
}
|
|
|
|
// A request over the bound is refused and never asked.
|
|
func TestARequestOverTheBoundIsRefused(t *testing.T) {
|
|
asked := false
|
|
socket := serving(t, func(context.Context, Asked) cli.Answer {
|
|
asked = true
|
|
return cli.Answer{}
|
|
})
|
|
a, err := cli.Ask(context.Background(), socket, []string{strings.Repeat("x", cli.MaxRequest)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if asked || a.Refused == "" {
|
|
t.Fatalf("a request over the bound was asked (%v): %+v", asked, a)
|
|
}
|
|
}
|
|
|
|
// A file at the socket's path that is not a socket is never removed.
|
|
func TestAFileThatIsNotASocketIsKept(t *testing.T) {
|
|
dir := t.TempDir()
|
|
socket := filepath.Join(dir, "cli.sock")
|
|
if err := os.WriteFile(socket, []byte("mine"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err := Serve(context.Background(), socket, nil, nil)
|
|
if err == nil || !strings.Contains(err.Error(), "not a socket") {
|
|
t.Fatalf("a file at the socket's path was not refused: %v", err)
|
|
}
|
|
if body, _ := os.ReadFile(socket); string(body) != "mine" {
|
|
t.Fatal("the file at the socket's path was touched")
|
|
}
|
|
}
|
|
|
|
// The request the controller reads holds these field names; the controller's side holds the same list
|
|
// (mesh-controller, the mesh-cli request's test).
|
|
func TestTheRequestToTheControllerKeepsItsFieldNames(t *testing.T) {
|
|
body, err := json.Marshal(Asked{Line: []string{"status"}, Account: "a", UID: 1})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, _ = json.Marshal(Asked{Line: []string{"status"}, Account: "a", UID: 1, Session: "session-3.scope"})
|
|
if got := keysOf(t, body); got != "account line session uid" {
|
|
t.Fatalf("the request's fields are %q", got)
|
|
}
|
|
body, _ = json.Marshal(Asked{Line: []string{"status"}, Account: "a", UID: 1})
|
|
if got := keysOf(t, body); got != "account line uid" {
|
|
t.Fatalf("the request's fields are %q", got)
|
|
}
|
|
body, _ = json.Marshal(Asked{Follow: "call-1", Account: "a", UID: 1})
|
|
if got := keysOf(t, body); got != "account follow uid" {
|
|
t.Fatalf("a follow's fields are %q", got)
|
|
}
|
|
// What the controller answers as a line's result, and as a running line's: the same names its side holds.
|
|
body, err = json.Marshal(cli.Answer{Stdout: []byte("o"), Stderr: []byte("e"), Exit: 1, Terminal: true, Why: "w",
|
|
Refused: "r", Cut: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := keysOf(t, body); got != "cut exit refused stderr stdout terminal why" {
|
|
t.Fatalf("the answer's fields are %q", got)
|
|
}
|
|
body, _ = json.Marshal(runningResult{Running: true, Call: "call-1"})
|
|
if got := keysOf(t, body); got != "call running" {
|
|
t.Fatalf("a running answer's fields are %q", got)
|
|
}
|
|
}
|
|
|
|
func keysOf(t *testing.T, body []byte) string {
|
|
t.Helper()
|
|
var m map[string]any
|
|
if err := json.Unmarshal(body, &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var keys []string
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return strings.Join(keys, " ")
|
|
}
|
|
|
|
// **One account cannot hold the socket** (review of ADR 0272): its lines beyond its own bound are answered busy at
|
|
// once, nothing is asked, and the engine goes on accepting.
|
|
func TestOneAccountsLinesBeyondItsBoundAreAnsweredBusy(t *testing.T) {
|
|
was := perAccount
|
|
perAccount = 1
|
|
t.Cleanup(func() { perAccount = was })
|
|
release := make(chan struct{})
|
|
started := make(chan struct{}, 2)
|
|
socket := serving(t, func(context.Context, Asked) cli.Answer {
|
|
started <- struct{}{}
|
|
<-release
|
|
return cli.Answer{}
|
|
})
|
|
// Released before the engine is stopped: cleanups run last first, and the engine waits for its lines.
|
|
t.Cleanup(func() { close(release) })
|
|
go func() { _, _ = cli.Ask(context.Background(), socket, []string{"push"}) }()
|
|
<-started
|
|
done := make(chan cli.Answer, 1)
|
|
go func() {
|
|
a, _ := cli.Ask(context.Background(), socket, []string{"status"})
|
|
done <- a
|
|
}()
|
|
select {
|
|
case a := <-done:
|
|
if !strings.Contains(a.Refused, "busy") {
|
|
t.Fatalf("a second line from the same account reads %+v", a)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("a second line from the same account waited instead of being answered busy")
|
|
}
|
|
if len(started) != 0 {
|
|
t.Fatal("the busy line was asked")
|
|
}
|
|
}
|
|
|
|
// **A process in another namespace is not this machine's account** (review of ADR 0272): a container running with
|
|
// the operator's user id shares the id and not the machine; refused, and nothing is asked.
|
|
func TestAPeerInAnotherNamespaceIsRefused(t *testing.T) {
|
|
before := sameNamespaces
|
|
sameNamespaces = func(pid int32) error { return errors.New("its mount namespace is not the engine's") }
|
|
t.Cleanup(func() { sameNamespaces = before })
|
|
asked := false
|
|
socket := serving(t, func(context.Context, Asked) cli.Answer {
|
|
asked = true
|
|
return cli.Answer{}
|
|
})
|
|
a, err := cli.Ask(context.Background(), socket, []string{"status"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if asked || !strings.Contains(a.Refused, "namespace") {
|
|
t.Fatalf("a peer in another namespace was asked (%v): %+v", asked, a)
|
|
}
|
|
}
|
|
|
|
// **The session a line comes from is the kernel's word, and only a login session counts** (review of ADR 0272): a
|
|
// service running as the operator — the tool runner, a user unit — is not a person at a terminal. The engine reads
|
|
// the peer's cgroup and sends the login session's scope, or nothing; the controller makes no other terminal.
|
|
func TestTheLoginSessionAskingIsReadFromTheKernel(t *testing.T) {
|
|
for _, c := range []struct{ cgroup, session string }{
|
|
{"0::/user.slice/user-1000.slice/session-3.scope\n", "session-3.scope"},
|
|
{"0::/user.slice/user-1000.slice/session-c1.scope\n", "session-c1.scope"},
|
|
{"0::/user.slice/user-1000.slice/user@1000.service/app.slice/tmux.scope\n", ""},
|
|
{"0::/system.slice/node-tools.service\n", ""},
|
|
{"0::/user.slice/user-1001.slice/session-3.scope\n", ""}, // another account's session
|
|
{"12:pids:/user.slice\n0::/user.slice/user-1000.slice/session-9.scope/sub\n", ""},
|
|
// Below a session scope, in a scope of its own: only the check that the scope is the account slice's direct
|
|
// child refuses it.
|
|
{"0::/user.slice/user-1000.slice/session-9.scope/x.scope\n", ""},
|
|
{"", ""},
|
|
} {
|
|
if got := loginSessionIn(c.cgroup, 1000); got != c.session {
|
|
t.Errorf("%q read as %q, want %q", c.cgroup, got, c.session)
|
|
}
|
|
}
|
|
// And it is sent: the test process's own, whatever it is, as the kernel says it.
|
|
var got Asked
|
|
socket := serving(t, func(_ context.Context, asked Asked) cli.Answer {
|
|
got = asked
|
|
return cli.Answer{}
|
|
})
|
|
if _, err := cli.Ask(context.Background(), socket, []string{"status"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, _ := os.ReadFile("/proc/self/cgroup")
|
|
if want := userSessionIn(string(raw), uint32(os.Getuid())); got.Session != want {
|
|
t.Fatalf("sent session %q, the kernel says %q", got.Session, want)
|
|
}
|
|
}
|
|
|
|
// **A login session is a person's: systemd's own record says so** (review of ADR 0272): the scope the cgroup names
|
|
// must be a session logind keeps for this very account, of class `user` — not a greeter's, not the user manager's,
|
|
// not one whose record names another account — and read at accept, with the namespaces, before anything else.
|
|
func TestALoginSessionIsAUsersByItsOwnRecord(t *testing.T) {
|
|
dir := t.TempDir()
|
|
was := sessionsDir
|
|
sessionsDir = dir
|
|
t.Cleanup(func() { sessionsDir = was })
|
|
write := func(id, body string) {
|
|
if err := os.WriteFile(filepath.Join(dir, id), []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write("9", "UID=1000\nUSER=op\nCLASS=user\nSCOPE=session-9.scope\nACTIVE=1\n")
|
|
write("3", "UID=1000\nCLASS=greeter\nSCOPE=session-3.scope\n")
|
|
write("5", "UID=1000\nCLASS=user\nSCOPE=session-6.scope\n")
|
|
// The account the record names is not the slice's: only the record's UID check refuses it.
|
|
write("8", "UID=1001\nCLASS=user\nSCOPE=session-8.scope\n")
|
|
// A name with a dot, held as a record that would pass: only the check of the id's characters refuses it.
|
|
write("a.b", "UID=1000\nCLASS=user\nSCOPE=session-a.b.scope\n")
|
|
// A key said twice: the first is the record's word (systemd writes each once); a later one is not read.
|
|
write("11", "UID=1001\nCLASS=user\nSCOPE=session-11.scope\nUID=1000\n")
|
|
write("12", "UID=1000\nCLASS=user\nSCOPE=session-12.scope\nCLASS=greeter\nUID=1001\n")
|
|
for _, c := range []struct {
|
|
cgroup string
|
|
uid uint32
|
|
session string
|
|
}{
|
|
{"0::/user.slice/user-1000.slice/session-9.scope\n", 1000, "session-9.scope"},
|
|
{"0::/user.slice/user-1000.slice/x.scope\n", 1000, ""},
|
|
{"0::/user.slice/user-1000.slice/session-9.scope\n", 1001, ""}, // asked as another uid than the slice's
|
|
{"0::/user.slice/user-1000.slice/session-8.scope\n", 1000, ""}, // the record names uid 1001
|
|
{"0::/user.slice/user-1000.slice/session-3.scope\n", 1000, ""}, // a greeter's
|
|
{"0::/user.slice/user-1000.slice/session-5.scope\n", 1000, ""}, // a record naming another scope
|
|
{"0::/user.slice/user-1000.slice/session-7.scope\n", 1000, ""}, // no record at all
|
|
{"0::/user.slice/user-1000.slice/session-a.b.scope\n", 1000, ""},
|
|
{"0::/user.slice/user-1000.slice/session-...scope\n", 1000, ""},
|
|
{"0::/user.slice/user-1000.slice/session-11.scope\n", 1000, ""}, // first UID is 1001
|
|
{"0::/user.slice/user-1000.slice/session-12.scope\n", 1000, "session-12.scope"}, // first UID and CLASS stand
|
|
} {
|
|
if got := userSessionIn(c.cgroup, c.uid); got != c.session {
|
|
t.Errorf("%q as uid %d read as %q, want %q", c.cgroup, c.uid, got, c.session)
|
|
}
|
|
}
|
|
}
|
|
|
|
// **The session is read at accept, with the namespaces, before the line** (review of ADR 0272): the peer is judged as
|
|
// it was when it connected. The client sends its line only once the session has been read; were the session read
|
|
// after the line — in the forward, say — the line would never be sent and the test fails.
|
|
func TestTheSessionIsReadBeforeTheLine(t *testing.T) {
|
|
var mu sync.Mutex
|
|
var order []string
|
|
note := func(s string) { mu.Lock(); order = append(order, s); mu.Unlock() }
|
|
read := make(chan struct{}, 1)
|
|
beforeNS, beforeSession := sameNamespaces, sessionOf
|
|
sameNamespaces = func(int32) error { note("namespaces"); return nil }
|
|
sessionOf = func(int32, uint32) string { note("session"); read <- struct{}{}; return "session-1.scope" }
|
|
t.Cleanup(func() { sameNamespaces, sessionOf = beforeNS, beforeSession })
|
|
var got Asked
|
|
socket := serving(t, func(_ context.Context, asked Asked) cli.Answer {
|
|
note("forward")
|
|
got = asked
|
|
return cli.Answer{}
|
|
})
|
|
conn, err := net.Dial("unix", socket)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer conn.Close()
|
|
select {
|
|
case <-read:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("the session was not read before the line was sent")
|
|
}
|
|
note("line sent")
|
|
if err := cli.WriteRequest(conn, cli.Request{Line: []string{"status"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
|
if _, err := cli.ReadAnswer(conn); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if strings.Join(order, ",") != "namespaces,session,line sent,forward" || got.Session != "session-1.scope" {
|
|
t.Fatalf("read in the order %v, sent %q", order, got.Session)
|
|
}
|
|
}
|