From 01889e2a486fe96509ddbcccc59cf886263874bf Mon Sep 17 00:00:00 2001 From: Privatae Date: Thu, 29 Oct 2026 15:54:51 +0000 Subject: [PATCH] bastion 0.58.0 --- CHANGELOG.md | 112 +++ client-go/claims_static_test.go | 10 +- client-go/console_hostops.go | 8 + client-go/device.go | 5 + client-go/enforcing.go | 8 + client-go/fuzz_grants_test.go | 48 + client-go/fuzz_observe_test.go | 123 +++ client-go/machinestore.go | 26 +- client-go/machinestore_test.go | 38 +- client-go/main.go | 1 + client-go/mgmt.go | 26 +- client-go/observe_ops.go | 9 +- client-go/observe_ops_test.go | 10 +- client-go/observe_pull.go | 4 +- client-go/observe_reads.go | 1162 ++++++++++++++++++++++ client-go/observe_reads_linux.go | 508 ++++++++++ client-go/observe_reads_linux_test.go | 711 +++++++++++++ client-go/observe_reads_other.go | 15 + client-go/observe_reads_test.go | 625 ++++++++++++ client-go/ops.go | 210 +++- client-go/ops_action.go | 50 + client-go/ops_action_linux.go | 7 +- client-go/ops_action_local_linux_test.go | 237 +++++ client-go/ops_action_local_test.go | 122 +++ client-go/ops_bounds.go | 516 ++++++++++ client-go/ops_exec_other.go | 9 + client-go/ops_plan_linux.go | 6 +- client-go/ops_skill_bounds_test.go | 506 ++++++++++ client-go/ops_verbs_linux.go | 296 +++++- client-go/ops_verbs_only_linux_test.go | 177 ++++ client-go/quarantine.go | 2 +- client-go/writes.go | 2 +- deploy/VERSION | 2 +- docs/claims.md | 27 + docs/observe.md | 45 +- docs/ops.md | 17 +- docs/security-model.md | 39 +- 37 files changed, 5629 insertions(+), 90 deletions(-) create mode 100644 client-go/fuzz_observe_test.go create mode 100644 client-go/observe_reads.go create mode 100644 client-go/observe_reads_linux.go create mode 100644 client-go/observe_reads_linux_test.go create mode 100644 client-go/observe_reads_other.go create mode 100644 client-go/observe_reads_test.go create mode 100644 client-go/ops_action_local_linux_test.go create mode 100644 client-go/ops_action_local_test.go create mode 100644 client-go/ops_bounds.go create mode 100644 client-go/ops_skill_bounds_test.go create mode 100644 client-go/ops_verbs_only_linux_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 53ee4b0..4cdbd62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,118 @@ From 0.39.1 on, a heading marks a version *not published* or *held* once that is settled. The newest entries may not be published yet; the manifest says which version is. Older entries are not marked. +## 0.58.0 + +- **A Host answers four common questions about a machine itself, instead of + handing the person a command to run.** Each is a read-only observe op, + granted under `observe` like the rest of the family and listed in the ops + the machine advertises, so a grant can name it. Each value is checked + against a written rule before anything is read or run, and a refusal says + which rule it broke without repeating the value. Off Linux each answers + `unsupported`. + - `observe:folders [depth]`: the 25 largest folders under an + absolute path, 1 to 3 levels down (2 when not given), and the total, on + that path's own filesystem only, counted the way `du -x` counts (disk + blocks, a file with several links once). Only folders are named, never a + file. What could not be read, and filesystems mounted under the path, are + listed against the folder that holds them, with a reason and a count. + `/proc`, `/sys`, `/dev`, a relative path, a path containing `..` and a + link that leads into those are refused. A walk that runs out of the + 20-second budget answers with what it measured and says every size is at + least that. The walk follows no link: a folder swapped for one while it + reads, or reached through one put above it, is listed as changed and not + counted. A read that never returns (a network mount that stopped + answering) is answered without at the budget, and while four such reads + are still waiting no new walk starts. + - `observe:journal [minutes]`: one systemd unit's journal for the + last 1 to 60 minutes (15 when not given), up to 5000 lines and 200 KB, + newest kept. A name with no type is a service, as with `systemctl`. The + name must be in systemd's unit-name alphabet and one this machine has, + loaded now or installed; otherwise it is refused, with the units whose + names come close. Until now a Host could read logs only from a Loki, + which most machines do not run, so it asked the person to run + `journalctl` instead. + - `observe:logins [hours]`: sshd's failed and accepted logins over the last + 1 to 168 hours (24 when not given). Failed attempts are counted and ranked + by source address and by user name, 20 of each; the last 20 accepted + logins come with user, source, method and time. A user name the other + side sent is cut to 32 characters, and anything outside the characters + account names use reads `?`. No raw line, key fingerprint or password is + returned. Only lines written by sshd running as root are read, so another + account on the machine cannot add login lines or bury the real ones under + its own. + - `observe:cert [server name]`: the certificate this machine's own + listener on a TCP port serves: subject, names, issuer, dates, days left, + and whether the chain verifies against this machine's roots for that + name. The port must be one `ss` shows listening here. The handshake goes + to loopback first and otherwise only to an address of this machine the + listener is bound to, never to one another machine could hold. +- **A journal the bastion cannot read is said, not shown as empty.** + `journalctl` run by an account outside the journal's groups shows only that + account's own lines, which would look like a quiet machine. `observe:journal` + and `observe:logins` first check that the bastion can read the system + journal (as root, which is how the networked install runs it, or as a member + of `systemd-journal`) and otherwise answer with the reason; + `observe:logins` then reports its counts as -1, never 0. +- **A grant from now on can be held to a window and a rate, by the + machine.** An op grant's bounds may now carry `_window` (days, from, to and + a zone, read on the machine's own clock; a window may wrap midnight) and + `_rate` (at most so many applies in any 24 hours, and so many seconds + between two). An apply outside them does not run, spends no use and is no + strike toward the quarantine: the answer is `not_now`, exit 75, with the + bound that held it and when it next could run. A dry run is never held, and + an apply with arguments outside the op is still refused as before. The + zone database is in the binary, so a zone reads the same on every machine. + An older bastion refuses both keys as naming no param, so a grant carrying + them fails closed there; the enforcing report says `grant_run_bounds` on a + bastion that holds them. +- **The machine keeps the runs, and who approved.** Each grant's record in + `ops.json` now holds the times of the applies that spent a use, the key + that approved it (the approval device's key, or the factor enrolled at this + console) and the machine's own seal over the record. The rate is counted + from that log, so it holds across a restart. A record edited outside the + bastion holds no grant until it is dropped and approved again, and so does + a grant installed by an older bastion (none was held when this shipped). A + grant whose approving key is no longer pinned is refused; `unpin-device` + drops every grant that key approved, so pinning it again brings none back. +- **`op_grant_drop ` takes a grant back from the Host.** It is + a tighten: no quarantine holds it and no approval is asked. A grant not + held here is already gone and is answered as dropped. At the machine, + `ops drop --all` takes back every op grant a Host holds, beside `ops drop + `. +- **An action op may have no program.** With `interpreter: none` and no + `run` file, an action op's verbs are its whole work. Nothing the Host wrote + executes, so it needs no sandbox and runs on a machine with no overlay. Its + dry run is the verbs' own read of what each would move, and its apply the + verbs and the log checks. Such an op that ships a `run` file is refused, + as is one that names destinations, a builder, reads, or a credential no + verb uses. An op with a program is still refused where the sandbox is not. + A verb whose tool the machine lacks refuses the run as the machine's own + state, never a strike, and the enforcing report now says, for each verb, + whether this machine can run it (`verbs`). +- **On your own machine, an action op reaches the internet, never your + network.** On a machine no fleet has anchored, an action op's destination + is never a private, ULA, CGNAT or reserved address (also inside NAT64 or + 6to4): one written as an address is refused when the op arrives, and a + name is refused at apply if any address it resolves to is one. Another + computer of yours is reached through a bastion on that computer. An + anchored machine keeps private destinations, since a fleet's ship steps + reach its own private control planes. A verb's fetch is held the same way + (`tree.checkout` fetching its tree's repository, `k8s.set_image` fetching + its source to check ancestry): the repository's host is checked once and + git is held to that address, so an op with no program reaches no more than + one with a program. On an anchored machine too a verb's fetch is held to + its checked address and is never this machine, its loopback, a link-local + address or a cloud's metadata service. An image `k8s.set_image` would put + live, which the machine's container runtime pulls by name, is refused on + your own machine when its registry is on your network or this machine, + both when a program reports it and when the verb would set it. + `k8s.set_image` now says it needs git as well as kubectl, so a machine + without git is told before a run. What stops working: a ship step on your + own unanchored bastion that deploys to another computer on your LAN, + fetches from a forge on it or rolls an image from a registry on it; and a + verb's fetch needs git 2.37 or newer, which holds a fetch to one address. + ## 0.57.0 - **Pairing with a Cloud Host is one pasted line.** A Host's "Set up a diff --git a/client-go/claims_static_test.go b/client-go/claims_static_test.go index 6257b42..5ccfa47 100644 --- a/client-go/claims_static_test.go +++ b/client-go/claims_static_test.go @@ -1179,8 +1179,8 @@ func TestObserveOpsWriteNothing(t *testing.T) { roots = append(roots, fn) } } - if len(roots) != 14 { - t.Fatalf("expected the 11 observe and 3 computer handlers, found %d", len(roots)) + if len(roots) != 18 { + t.Fatalf("expected the 15 observe and 3 computer handlers, found %d", len(roots)) } stop := map[string]bool{"sendMgmt": true, "sendMgmtExit": true, "refuseMgmt": true, "storeRecordObserve": true} @@ -1256,6 +1256,10 @@ func TestObserveOpsWriteNothing(t *testing.T) { "dmesg -T", "iptables -S", "journalctl -n1 -o short-unix --no-pager", + // observe:logins: sshd's lines over a window of whole hours. + "journalctl -o short-iso --no-pager -q --lines=50000 _COMM=sshd _COMM=sshd-session _COMM=sshd-auth _UID=0 + _SYSTEMD_UNIT=ssh.service _SYSTEMD_UNIT=sshd.service _UID=0", + // observe:journal: one unit this machine's own lists name, over minutes. + "journalctl -o short-iso --no-pager -q --lines=5000", "kubectl --kubeconfig get nodes -o json", "kubectl --kubeconfig get pods -A -o json", "nft list chain inet pubwall input", @@ -1270,6 +1274,8 @@ func TestObserveOpsWriteNothing(t *testing.T) { "system_profiler SPApplicationsDataType -json", "systemctl is-active fail2ban", "systemctl list-timers --all --no-legend --plain", + "systemctl list-unit-files --no-legend --plain", // observe:journal's unit check + "systemctl list-units --all --no-legend --plain", "systemctl list-units --failed --no-legend --plain", "ufw status", "ufw status verbose", diff --git a/client-go/console_hostops.go b/client-go/console_hostops.go index b113c1a..88f4258 100644 --- a/client-go/console_hostops.go +++ b/client-go/console_hostops.go @@ -142,6 +142,14 @@ func cmdOpsDrop(host, prefix string) error { } dir := cfg.dcnDir() prefix = strings.TrimSpace(prefix) + if prefix == "--all" { + dropped, err := opsDropWhere(dir, "dropped at this machine's console", func(string, *opGrantRec) bool { return true }) + if err != nil { + return err + } + fmt.Printf("%d op grant(s) dropped — the host cannot run under any of them again\n", len(dropped)) + return nil + } var hits []string for nonce, rec := range opsLoad(dir).Grants { if rec.Dropped == "" && strings.HasPrefix(nonce, prefix) { diff --git a/client-go/device.go b/client-go/device.go index 2d2594e..11409a4 100644 --- a/client-go/device.go +++ b/client-go/device.go @@ -995,6 +995,11 @@ func cmdPinDevice(key string, unpin bool) error { return err } fmt.Println("device key unpinned; approvals signed by it are refused from now on") + // Every op grant it approved dies with it (ops_bounds.go), dropped + // now so pinning the key again does not bring one back. + if n := opsDropApprovedBy("device:" + strings.TrimSpace(key)); n > 0 { + fmt.Printf("%d op grant(s) it approved dropped\n", n) + } return nil } if err := pinDeviceKey(key); err != nil { diff --git a/client-go/enforcing.go b/client-go/enforcing.go index f5dddc6..c93dc76 100644 --- a/client-go/enforcing.go +++ b/client-go/enforcing.go @@ -193,6 +193,14 @@ func buildEnforcingReport(dir string, allowed []string) map[string]any { if !aok { rep["action_ops_reason"] = awhy } + // Each verb, and whether this machine has the tool it runs: an op + // with no program (interpreter: none) runs here when its verbs do, + // with or without the sandbox above. + rep["verbs"] = opVerbsReport() + // An op grant's bounds may hold its runs to a window and a rate + // (ops_bounds.go). Said by the box, so the host sends neither to one + // that would refuse them as naming no param. + rep["grant_run_bounds"] = true } // This machine's encryption key, signed by its own key: what the host // seals an action op's credentials to (ops_seal.go). diff --git a/client-go/fuzz_grants_test.go b/client-go/fuzz_grants_test.go index d85988b..68b0f7d 100644 --- a/client-go/fuzz_grants_test.go +++ b/client-go/fuzz_grants_test.go @@ -345,3 +345,51 @@ func FuzzADeviceApprovalIsReadOneWayOnly(f *testing.F) { } }) } + +// FuzzAGrantsRunBoundsHoldWhateverTheHostSends: whatever bounds text a host +// sends, a run bound the machine accepts is one it can hold — a window has a +// length, a zone and a day, a rate names a limit inside the ceilings — and +// judging a run against it at any moment answers, never panics, and never +// lets a run inside an interval or past a day's count. +func FuzzAGrantsRunBoundsHoldWhateverTheHostSends(f *testing.F) { + m := &opManifest{Params: map[string]opParam{"count": {Type: "integer", Min: ptr64(1), Max: ptr64(5)}}} + for _, s := range []string{ + `{"_window":{"days":["mon"],"from":"22:00","to":"06:00","tz":"Europe/Berlin"}}`, + `{"_rate":{"per_day":2,"min_interval_s":600},"count":{"max":2}}`, + `{"_window":null,"_rate":null}`, `{"_x":1}`, `{"_window":{"from":"02:00","to":"02:00"}}`, + } { + f.Add(s, int64(1761739200), int64(300)) + } + f.Fuzz(func(t *testing.T, raw string, at, gap int64) { + b, err := parseGrantBounds(m, raw) + if err != nil { + return + } + if w := b.Window; w != nil { + if w.from == w.to || w.loc == nil || w.day == [7]bool{} { + t.Fatalf("accepted a window that is no window: %s", raw) + } + } + if r := b.Rate; r != nil { + if (r.PerDay == nil && r.MinInterval == nil) || (r.PerDay != nil && (*r.PerDay < 1 || *r.PerDay > opGrantUsesMax)) || + (r.MinInterval != nil && (*r.MinInterval < 1 || *r.MinInterval > opRateMinInterval)) { + t.Fatalf("accepted a rate outside the ceilings: %s", raw) + } + } + now := time.Unix(at%(1<<40), 0) + last := now.Add(-time.Duration(gap%(1<<30)) * time.Second) + if last.After(now) { + last = now + } + why, _, _ := opNotNowWhy(b, []time.Time{last}, now) + if b.Window == nil && b.Rate != nil && b.Rate.MinInterval != nil && + now.Sub(last) < time.Duration(*b.Rate.MinInterval)*time.Second && why == "" { + t.Fatalf("a run inside the interval was let through: %s", raw) + } + if b.Window == nil && b.Rate != nil && b.Rate.PerDay != nil && *b.Rate.PerDay == 1 && now.Sub(last) < 24*time.Hour && why == "" { + t.Fatalf("a second run in a day was let through: %s", raw) + } + }) +} + +func ptr64(v int64) *int64 { return &v } diff --git a/client-go/fuzz_observe_test.go b/client-go/fuzz_observe_test.go new file mode 100644 index 0000000..b89cc8e --- /dev/null +++ b/client-go/fuzz_observe_test.go @@ -0,0 +1,123 @@ +package main + +// Fuzz the walls a host's value meets in observe:folders, observe:journal, +// observe:cert and observe:logins. Whatever the input, a path that passes is +// absolute, clean and outside the kernel's trees; a unit that passes is in +// systemd's alphabet and shaped like no flag; a server name that passes is a +// DNS name; and the sshd parser names only addresses as sources and bounded +// names as users, whatever a remote side put in a user name. + +import ( + "net/netip" + "path" + "strings" + "testing" +) + +func FuzzFolderPathIsAbsoluteCleanAndOffTheKernelTrees(f *testing.F) { + for _, s := range []string{"/", "/var/lib", "/var//lib/", "/proc", "//proc/1", "/a/../proc", "/a..b", + "rel", "/dev/shm", "/x\x00y", "/\xff", "/srv/./data", "/sys/", "/devices"} { + f.Add(s) + } + f.Fuzz(func(t *testing.T, in string) { + p, err := checkFolderPath(in) + if err != nil { + return + } + if !strings.HasPrefix(p, "/") || path.Clean(p) != p || strings.Contains(p, "..") { + t.Fatalf("%q passed as %q", in, p) + } + for _, r := range p { + if r < 0x20 || r == 0x7f { + t.Fatalf("%q passed with a control character", in) + } + } + for _, root := range folderRefusedRoots { + if p == root || strings.HasPrefix(p, root+"/") { + t.Fatalf("%q passed into %s", in, root) + } + } + }) +} + +func FuzzUnitNameIsSystemdsAlphabet(f *testing.F) { + for _, s := range []string{"example", "getty@tty1", `dev-disk-by\x2dlabel-data.device`, "-.mount", "--unit=x", + "worker@.service", "a@b@c", "x;id", "data.mount", ".service", "@x"} { + f.Add(s) + } + f.Fuzz(func(t *testing.T, in string) { + u, err := normalizeUnitName(in) + if err != nil { + return + } + if !unitNameRe.MatchString(u) || len(u) > unitNameMax || !hasUnitSuffix(u) { + t.Fatalf("%q passed as %q", in, u) + } + if strings.HasPrefix(u, "-") || strings.HasPrefix(u, ".") || strings.HasPrefix(u, "@") { + t.Fatalf("%q passed as a flag-shaped %q", in, u) + } + if u != in && u != in+".service" { + t.Fatalf("%q became %q: the name is kept or given .service, nothing else", in, u) + } + }) +} + +func FuzzCertArgsAreAPortAndADNSName(f *testing.F) { + for _, s := range [][2]string{{"443", "box-a.example"}, {"0", ""}, {"65536", "x"}, {"443", "-x"}, + {"443", "a..b"}, {"8443", "192.0.2.10"}, {" 443 ", "A.EXAMPLE"}, {"443", "a_b"}} { + f.Add(s[0], s[1]) + } + f.Fuzz(func(t *testing.T, port, name string) { + p, n, err := parseCertArgs([]string{port, name}) + if err != nil { + return + } + if p < 1 || p > 65535 || !serverNameRe.MatchString(n) || n != strings.ToLower(n) || + strings.HasPrefix(n, "-") || strings.HasPrefix(n, ".") || strings.Contains(n, "..") { + t.Fatalf("(%q, %q) passed as (%d, %q)", port, name, p, n) + } + }) +} + +func FuzzSSHLoginsNameOnlyAddressesAndBoundedNames(f *testing.F) { + f.Add(fxSSHD) + f.Add("2026-10-29T10:00:01+00:00 h sshd[1]: Invalid user a from 1.2.3.4 port 1 from 203.0.113.5 port 2\n") + f.Add("2026-10-29T10:00:01+00:00 h sshd[1]: Failed password for x from evil/host port 1 ssh2\n") + f.Add("x y z: Accepted publickey for \x00 from ::1 port 9 ssh2: K\n") + f.Fuzz(func(t *testing.T, text string) { + tally := parseSSHLogins(text) + var rep loginsReport + tally.fill(&rep) + sum := 0 + for src, n := range tally.bySource { + sum += n + if _, err := netip.ParseAddr(src); err != nil && !loginHostRe.MatchString(src) { + t.Fatalf("source %q is not an address or a host name", src) + } + } + if sum != rep.FailedTotal { + t.Fatalf("sources count %d, total %d", sum, rep.FailedTotal) + } + users := 0 + for name, u := range tally.byUser { + users += u.Count + if len(name) > loginNameMax || loginName(name) != name { + t.Fatalf("user %q is not a bounded name", name) + } + } + if users != rep.FailedTotal { + t.Fatalf("users count %d, total %d", users, rep.FailedTotal) + } + if len(rep.Accepted) > loginsTopN || rep.AcceptedTotal < len(rep.Accepted) { + t.Fatalf("accepted %d of %d", len(rep.Accepted), rep.AcceptedTotal) + } + for _, a := range rep.Accepted { + if _, err := netip.ParseAddr(a.Source); err != nil && !loginHostRe.MatchString(a.Source) { + t.Fatalf("accepted source %q", a.Source) + } + if loginName(a.User) != a.User || loginMethod(a.Method) != a.Method { + t.Fatalf("accepted %+v", a) + } + } + }) +} diff --git a/client-go/machinestore.go b/client-go/machinestore.go index 8e649f7..de86986 100644 --- a/client-go/machinestore.go +++ b/client-go/machinestore.go @@ -1018,19 +1018,27 @@ var observeArgsRecorded = map[string]int{ "observe:net": 1, // host[:port], charset-checked before use "observe:metrics": 1, // a name from a fixed query table "observe:logs": 2, // a name, and an optional minutes window (a whole number) + "observe:folders": 2, // an absolute path, and an optional depth + "observe:journal": 2, // a unit name, and an optional minutes window + "observe:logins": 1, // an optional window in hours + "observe:cert": 2, // a local port, and an optional server name } // storeObserveLine is one line of observe//.jsonl. // -// WHAT COMES TO REST HERE. Eight of the eleven ops return fixed schemas the -// beat already summarises (posture ints, unit names, sockets, a probe's TLS -// subject). observe:alerts and observe:metrics carry label maps that can name -// namespaces and tenants. observe:logs is the outlier: its result is raw -// production log text, up to the line bound, persisted for 30 days — the one -// payload whose content this repo does not control, and so the one place a +// WHAT COMES TO REST HERE. Most of the ops return fixed schemas the beat +// already summarises (posture ints, unit names, sockets, a probe's TLS +// subject, a served certificate's names). observe:alerts and observe:metrics +// carry label maps that can name namespaces and tenants. observe:folders +// carries folder paths, which can carry a person's account or project name. +// observe:logins carries account names and the addresses they came from, the +// failed ones as the other side sent them (bounded by loginName). +// observe:logs and observe:journal are the outliers: their result is raw +// production log text, up to the line bound, persisted for 30 days — the +// payloads whose content this repo does not control, and so the places a // token or a user identifier can come to rest. Files are 0600 under a 0700 // tree; the reader is the machine's own OS account and the anchor's approved -// store. Named here so the next person choosing what to log through this op +// store. Named here so the next person choosing what to log through these ops // knows it stays. type storeObserveLine struct { At string `json:"at"` @@ -1097,8 +1105,8 @@ func storeObserveDroppedReset() { // (`observe:k8s`, `observe-k8s` and `observe_k8s` all reduce to // `observe-k8s`) and, since the lowercasing below, case folding // (`observe:K8s` and `observe:k8s`). Neither can bite today — the op reaches -// this writer only from the eleven names registered in handlerOps, all -// lowercase, which reduce to eleven distinct directories +// this writer only from the observe names registered in handlerOps, all +// lowercase, which reduce to as many distinct directories // (TestEveryRegisteredOpSanitisesToTheHostsShelfRule walks them) — but a // future pair like `observe:k8s-nodes` / `observe:k8s_nodes` would interleave // into one day-file. Whoever adds such a pair renames one of them. diff --git a/client-go/machinestore_test.go b/client-go/machinestore_test.go index 89af3a3..e41ecc4 100644 --- a/client-go/machinestore_test.go +++ b/client-go/machinestore_test.go @@ -15,6 +15,7 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" "sync" "sync/atomic" @@ -1696,9 +1697,12 @@ func TestObserveArgsRecordedMatchesTheHandlers(t *testing.T) { } // Arities, from the handlers' own checks (observe_ops.go handleObserveNet // len(args) != 1; observe_pull.go handleObserveMetrics len(args) != 1; - // handleObserveLogs len(args) > 2). + // handleObserveLogs len(args) > 2; observe_reads.go folders, journal and + // cert len(args) > 2, logins len(args) > 1). if observeArgsRecorded["observe:net"] != 1 || observeArgsRecorded["observe:metrics"] != 1 || - observeArgsRecorded["observe:logs"] != 2 { + observeArgsRecorded["observe:logs"] != 2 || observeArgsRecorded["observe:folders"] != 2 || + observeArgsRecorded["observe:journal"] != 2 || observeArgsRecorded["observe:logins"] != 1 || + observeArgsRecorded["observe:cert"] != 2 { t.Fatalf("arities drifted from the handlers: %v", observeArgsRecorded) } } @@ -1785,10 +1789,38 @@ func TestRecordedArgCountsMatchWhatTheHandlersAccept(t *testing.T) { // And every ACCEPTED position must hold a value the handler accepts — // observe:logs's second argument is a minutes window, and "x" there is // refused for its shape, which is not an arity refusal either. + // + // The four in observe_reads.go answer through their drivers on Linux, so + // what they read is put in front of them here and nothing on the box that + // runs the suite is: one unit this machine "has", one TLS listener ss + // "reports", an empty journal, and a folder of the test's own. + cert := observeTestCert(t, "box-a.example", []string{"box-a.example"}, nil, time.Now().Add(24*time.Hour)) + port := strconv.Itoa(observeTLSListener(t, cert)) + origExec, origHave, origAccess := observeExec, observeHave, observeJournalAccess + observeExec = func(_ context.Context, name string, args ...string) ([]byte, error) { + switch name + " " + args[0] { + case "systemctl list-units": + return []byte("example.service loaded active running Example\n"), nil + case "ss -tlnpH": + return []byte("LISTEN 0 4096 127.0.0.1:" + port + " 0.0.0.0:*\n"), nil + } + return nil, nil + } + observeHave = func(string) bool { return true } + observeJournalAccess = func() string { return "" } + defer func() { observeExec, observeHave, observeJournalAccess = origExec, origHave, origAccess }() + folder := "/" + if observeHostSupported { + folder = t.TempDir() + } accepted := map[string][]string{ "observe:net": {"203.0.113.9:443"}, "observe:metrics": {"up"}, "observe:logs": {"kernel", "5"}, + "observe:folders": {folder, "1"}, + "observe:journal": {"example", "5"}, + "observe:logins": {"24"}, + "observe:cert": {port, "box-a.example"}, } for op, n := range observeArgsRecorded { if len(accepted[op]) != n { @@ -1828,7 +1860,7 @@ func TestEveryRegisteredOpSanitisesToTheHostsShelfRule(t *testing.T) { t.Fatalf("%s sanitises to %q, which the host rejects as unreadable", op, got) } } - if n < 11 { + if n < 15 { t.Fatalf("only %d observe ops walked — the registry moved and this test is not covering it", n) } } diff --git a/client-go/main.go b/client-go/main.go index 9c48b99..7a2e196 100644 --- a/client-go/main.go +++ b/client-go/main.go @@ -607,6 +607,7 @@ func main() { case "ops": // ops [--host ] what ops and op grants this machine holds // ops drop [--host ] take back a held op grant (the host is told) + // ops drop --all [--host ] take back every op grant the host holds here host, args := takeHostFlag(os.Args[2:]) if len(args) >= 2 && args[0] == "drop" { err = cmdOpsDrop(host, args[1]) diff --git a/client-go/mgmt.go b/client-go/mgmt.go index 75491e7..a151262 100644 --- a/client-go/mgmt.go +++ b/client-go/mgmt.go @@ -105,6 +105,15 @@ type opHandler struct { // is exec'd, so the discipline here is a fixed QUERY TABLE instead of a // fixed argv: the host sends a NAME, the box owns the PromQL and the LogQL, // and an unknown name is refused with the known ones listed. +// - observe:folders / observe:journal / observe:logins / observe:cert +// (observe_reads.go): the questions a host otherwise handed the person as +// a command — the largest folders under a path, one unit's recent journal, +// sshd's failed and accepted logins, the certificate a local port serves. +// Their caller values are a path, a unit name, a number and a port with a +// server name, each checked against a written grammar before use. The +// one caller string that reaches a program is the unit name, and only +// once this machine's own unit list names it; a window reaches journalctl +// only as a number this box parsed and wrote back out. var handlerOps = map[string]opHandler{ "computer:inventory": {rootNone, "", handleComputerInventory}, "computer:windows": {rootNone, "", handleComputerWindows}, @@ -120,6 +129,10 @@ var handlerOps = map[string]opHandler{ "observe:alerts": {rootNone, "", handleObserveAlerts}, "observe:metrics": {rootNone, "", handleObserveMetrics}, "observe:logs": {rootNone, "", handleObserveLogs}, + "observe:folders": {rootNone, "", handleObserveFolders}, + "observe:journal": {rootNone, "", handleObserveJournal}, + "observe:logins": {rootNone, "", handleObserveLogins}, + "observe:cert": {rootNone, "", handleObserveCert}, "ctx_put": {rootCtx, "context store not configured", handleCtxPut}, "ctx_get": {rootCtx, "context store not configured", handleCtxGet}, "ctx_del": {rootCtx, "context store not configured", handleCtxDel}, @@ -158,10 +171,13 @@ var handlerOps = map[string]opHandler{ // op_* (ops.go / ops_exec_linux.go): an op the host wrote, held under // signed terms and run in a sandbox. rootDcn: the op store and ops.json // sit beside the pairing's grants, and the terms verify under its key. + // op_grant_drop (ops_bounds.go) takes a held grant back: a + // tighten (writes.go opTightens), so no quarantine or approval holds it. "op_sync": {rootDcn, "dcn store not configured", handleOpSync}, "op_dry_run": {rootDcn, "dcn store not configured", handleOpDryRun}, "op_apply": {rootDcn, "dcn store not configured", handleOpApply}, "op_grant_run": {rootDcn, "dcn store not configured", handleOpGrantRun}, + "op_grant_drop": {rootDcn, "dcn store not configured", handleOpGrantDrop}, "op_list": {rootDcn, "dcn store not configured", handleOpList}, "op_run_status": {rootDcn, "dcn store not configured", handleOpRunStatus}, // op_plan_* (ops_plan.go): a line of a ship plan the person's device @@ -231,10 +247,12 @@ type mgmtAccount struct { self string op string // "" when the frame did not parse: the box does not know // args is what the host passed, so the observe shelf can record WHAT was - // asked beside the result. Three ops read a caller value — observe:net + // asked beside the result. Seven ops read a caller value — observe:net // (host[:port]), observe:metrics (a name), observe:logs (a name and an - // optional selector); the rest ignore theirs. The shelf records args only - // for those three, bounded (machinestore.go observeArgsRecorded): the first + // optional window), observe:folders (a path and a depth), observe:journal + // (a unit and a window), observe:logins (hours) and observe:cert (a port + // and a server name); the rest ignore theirs. The shelf records args only + // for those seven, bounded (machinestore.go observeArgsRecorded): the first // version recorded whatever the host sent for every op, unbounded, and // this comment — which said only one op took a value — made that look safe. args []string @@ -258,7 +276,7 @@ func beginMgmtAccount(ws *websocket.Conn, self, op string, args []string) { // mgmtAccountOf is the record for this connection, or nil. Read by the observe // shelf to learn which op produced a result and which host asked, without the -// eleven handlers having to be told either. +// observe handlers having to be told either. func mgmtAccountOf(ws *websocket.Conn) *mgmtAccount { if v, ok := mgmtAccounts.Load(ws); ok { return v.(*mgmtAccount) diff --git a/client-go/observe_ops.go b/client-go/observe_ops.go index 7f0a8e5..3e0b380 100644 --- a/client-go/observe_ops.go +++ b/client-go/observe_ops.go @@ -6,9 +6,12 @@ package main // execs runs as root. // The discipline is the one a read-only sudoers policy lives by: every op is // a FIXED argv, read-only forms only, and no byte of a job's args ever reaches a shell or an -// argv position. The single exception is observe:net, whose one argument is +// argv position. The single exception in this file is observe:net, whose one argument is // a host[:port] that passes a strict charset check and is then handed to -// Go's net package — never to a program. +// Go's net package — never to a program. observe_reads.go has the one caller +// string in the family that does reach an argv: observe:journal's unit name, +// after systemd's unit-name alphabet and this machine's own unit list. Its +// windows reach journalctl only as numbers this box parsed and wrote back out. // // Every op answers typed JSON on stdout and exit 0. A box that cannot answer // (a tool missing, a platform without the probes) says so in the JSON — @@ -849,7 +852,7 @@ func writeObserve(ws *websocket.Conn, v any) { // The machine's own record of what it answered (machinestore.go, the // observe shelf): the same bytes that go down the lane, filed under the op // and the host that asked. Read off this connection's account rather than - // threaded through eleven handlers. BEFORE the reply, so a slow disk + // threaded through every handler. BEFORE the reply, so a slow disk // cannot leave a result sent-but-unrecorded; a failed write is counted, // never allowed to fail the reply. if a := mgmtAccountOf(ws); a != nil { diff --git a/client-go/observe_ops_test.go b/client-go/observe_ops_test.go index 4b15097..e523020 100644 --- a/client-go/observe_ops_test.go +++ b/client-go/observe_ops_test.go @@ -393,7 +393,8 @@ func TestNetTargetPublicOnly(t *testing.T) { var observeOps = []string{"observe:host", "observe:sockets", "observe:firewall", "observe:units", "observe:disk", "observe:kernel", "observe:k8s", "observe:net", - "observe:alerts", "observe:metrics", "observe:logs"} + "observe:alerts", "observe:metrics", "observe:logs", + "observe:folders", "observe:journal", "observe:logins", "observe:cert"} // observeArgOps are the ops in the family that read a caller value. Every one // of them must REFUSE the poison set rather than answer it; the rest ignore @@ -408,6 +409,10 @@ var observeArgRefusal = map[string]string{ "observe:net": "one argument", "observe:metrics": "exactly one argument", "observe:logs": "takes a name", + "observe:folders": "takes a folder path", + "observe:journal": "takes a unit name", + "observe:logins": "at most one argument", + "observe:cert": "takes a local tcp port", } var observeArgOps = func() map[string]bool { @@ -560,7 +565,8 @@ func TestObserveArgsNeverReachArgv(t *testing.T) { t.Fatalf("%s: the arity refusal must say %q, got %q", op, want, errOut) } // exactly one poisoned value: still refused — by the charset check - // for observe:net, by the fixed query table for the other two. + // for observe:net, by the fixed query table for metrics and logs, + // and by the written grammar of each of the four in observe_reads.go. _, errOut, code = driveObserve(t, op, poison[:1]) if code == 0 { t.Fatalf("%s with a shell value must refuse: code=%d err=%q", op, code, errOut) diff --git a/client-go/observe_pull.go b/client-go/observe_pull.go index 1d22d53..bc582a4 100644 --- a/client-go/observe_pull.go +++ b/client-go/observe_pull.go @@ -325,8 +325,8 @@ func parseAlerts(raw []byte) (alertsReport, error) { // handleObserveAlerts takes no argument: the op is the whole active set, the // way observe:host is the whole posture. Args are ignored exactly as they are -// there, so the only arg-taking ops in the family stay observe:net, -// observe:metrics and observe:logs. +// there, so it stays out of the arg-taking ops (observeArgsRecorded, +// machinestore.go, lists them). func handleObserveAlerts(ws *websocket.Conn, _ string, _ []string) { ctx, cancel := opBudget() defer cancel() diff --git a/client-go/observe_reads.go b/client-go/observe_reads.go new file mode 100644 index 0000000..59c7072 --- /dev/null +++ b/client-go/observe_reads.go @@ -0,0 +1,1162 @@ +package main + +// observe:folders / observe:journal / observe:logins / observe:cert — four +// questions people ask about a machine, answered by the machine. +// +// Each was a command a host could only hand to the person: du to find what +// fills a disk, journalctl -u for one service's log, the sshd lines for who +// has been trying to log in, a TLS client for the certificate a port serves. +// observe:logs reads a Loki, which most machines do not run, so a host with +// only this lane could not read one unit's log at all and asked the person to +// run journalctl instead. These four answer it here, read-only, the way the +// rest of the family does. +// +// They are the first ops in the family whose caller value is more than a name +// from a table, so the whole grammar of each is written down here and checked +// before anything is read or run: +// +// - observe:folders [depth 1-3]. The path never reaches a +// program: the sizing is a Go walk (observe_reads_linux.go). +// - observe:journal [minutes 1-60]. The unit reaches journalctl's +// argv, bound to its flag as --unit=, only after systemd's own unit-name +// alphabet is checked AND only when this machine's unit list names it. A +// value that fails either is refused before journalctl runs. +// - observe:logins [hours 1-168]. A number in a fixed argv. +// - observe:cert [server name]. The port must be a TCP listener ss +// reports on this machine, and the handshake goes only to loopback or to +// one of this machine's own addresses that listener is bound to. The name +// goes into the TLS hello, never to a program. +// +// A refusal never repeats a value that failed its check; it says which rule +// the value broke. Off Linux each op checks its arguments the same way and +// then answers {"unsupported": goos}, like the rest of the family. + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "net" + "net/netip" + "path" + "regexp" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/gorilla/websocket" +) + +// refuseObserveArgs is the answer to arguments an op will not take: the reason +// on stderr and exit 2, the code observe:net and observe:logs have always used +// for a bad argument. 126 stays "not permitted": the op was allowed, the value +// was not. +func refuseObserveArgs(ws *websocket.Conn, msg string) { + sendMgmt(ws, 'R', []byte(msg+"\n")) + sendMgmtExit(ws, 2) +} + +// parseBoundedInt reads one whole number in [lo, hi]. Out of range is REFUSED +// rather than clamped, for the reason parseLogMinutes gives: a host that asked +// for a week and silently got a day would draw a conclusion about the week. +func parseBoundedInt(s, what string, lo, hi int) (int, error) { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil { + return 0, errors.New(what + " must be a whole number") + } + if n < lo || n > hi { + return 0, errors.New(what + " must be between " + strconv.Itoa(lo) + " and " + strconv.Itoa(hi)) + } + return n, nil +} + +// ── observe:folders ────────────────────────────────────────────────────────── + +const ( + folderDepthDefault = 2 + folderDepthMax = 3 + // folderTopN is how many folders the answer names. A person looking for + // what fills a disk reads the first handful; 25 is room for the nested + // chain (/var, /var/lib, /var/lib/docker) and the siblings beside it. + folderTopN = 25 + // folderSkipCap bounds the skipped list. Skips are folded to the reported + // depth first (folderAt), so a k8s node's hundreds of volume mounts read + // as one row with a count, and 25 rows is past what that folding leaves. + folderSkipCap = 25 + // folderSkipKeys bounds the map the skips are folded in, so a tree of + // unreadable folders cannot grow it without bound; past it they are + // still counted in skipped_total. + folderSkipKeys = 1000 + // folderPathMax is PATH_MAX. + folderPathMax = 4096 +) + +// folderRefusedRoots hold no files on any disk: they are the kernel's views +// of processes, devices and itself. Sizing them answers nothing about what +// fills a disk, and walking /proc on a busy box is millions of entries. +var folderRefusedRoots = []string{"/proc", "/sys", "/dev"} + +const errFolderKernelTree = "/proc, /sys and /dev are not folders on a disk; ask about a folder elsewhere" + +// reasonOtherFS is the skip reason for a filesystem mounted under the path, +// which the walk does not enter (du -x). +const reasonOtherFS = "another filesystem, not counted" + +// checkFolderPath is the whole grammar of observe:folders' path. An absolute +// path only: a relative one would be relative to wherever the bastion was +// started, which the host cannot see. No ".." anywhere, not even inside a +// name: there is no folder a person asks about that needs one, and refusing +// the substring is a rule nobody has to reason about. No control characters, +// which no folder a person names contains and which would break a reader's +// line of output. +func checkFolderPath(p string) (string, error) { + switch { + case p == "": + return "", errors.New("a folder path is required") + case len(p) > folderPathMax: + return "", errors.New("a folder path is at most " + strconv.Itoa(folderPathMax) + " bytes") + case !utf8.ValidString(p): + return "", errors.New("a folder path must be valid UTF-8") + case !strings.HasPrefix(p, "/"): + return "", errors.New("a folder path must be absolute (start with /)") + case strings.Contains(p, ".."): + return "", errors.New("a folder path cannot contain '..'") + } + for _, r := range p { + if r < 0x20 || r == 0x7f { + return "", errors.New("a folder path cannot contain control characters") + } + } + // path, not filepath: these are Linux paths on every build, and the + // Windows filepath.Clean would turn the slashes round. + clean := path.Clean(p) + if err := folderPathAllowed(clean); err != nil { + return "", err + } + return clean, nil +} + +// folderPathAllowed is asked twice: of the path as asked, and of where its +// symlinks lead (readFolders), so a link into /proc is refused like /proc. +func folderPathAllowed(clean string) error { + for _, r := range folderRefusedRoots { + if clean == r || strings.HasPrefix(clean, r+"/") { + return errors.New(errFolderKernelTree) + } + } + return nil +} + +func parseFolderArgs(args []string) (string, int, error) { + p, err := checkFolderPath(args[0]) + if err != nil { + return "", 0, err + } + depth := folderDepthDefault + if len(args) > 1 { + if depth, err = parseBoundedInt(args[1], "depth", 1, folderDepthMax); err != nil { + return "", 0, err + } + } + return p, depth, nil +} + +// folderSize is one folder's disk use: allocated blocks, as du reports it, +// which is what fills a disk (a sparse file's apparent size does not). +type folderSize struct { + Path string `json:"path"` + Bytes int64 `json:"bytes"` + Depth int `json:"depth"` +} + +// folderSkip is a folder that holds something the walk could not size, at +// most the asked depth below the path, with how many such things it holds. +type folderSkip struct { + Path string `json:"path"` + Reason string `json:"reason"` + Count int `json:"count"` +} + +type foldersReport struct { + Supported bool `json:"supported"` + Reason string `json:"reason,omitempty"` + Path string `json:"path"` + // Resolved is where the path's symlinks led, when that is elsewhere. Every + // folder below is named under it. + Resolved string `json:"resolved,omitempty"` + Depth int `json:"depth"` + TotalBytes int64 `json:"total_bytes"` + Folders []folderSize `json:"folders"` + // Sized is how many folders down to the depth were sized; Folders is the + // largest folderTopN of them. + Sized int `json:"folders_sized"` + Skipped []folderSkip `json:"skipped"` + SkippedTotal int `json:"skipped_total"` + // Complete is false when the op budget ran out mid-walk. Every size is + // then a lower bound, and a folder the walk never reached is missing. + Complete bool `json:"complete"` + Partial string `json:"partial,omitempty"` + Note string `json:"note,omitempty"` +} + +// folderTop keeps the largest folderTopN folders offered to it. The walk +// offers every folder down to the depth, and a deep tree has tens of thousands +// of them; holding 25 is the bound. +type folderTop struct{ rows []folderSize } + +// folderLess orders by size, then by path backwards, so equal sizes come out +// in path order and two walks of an unchanged tree give the same list. +func folderLess(a, b folderSize) bool { + if a.Bytes != b.Bytes { + return a.Bytes < b.Bytes + } + return a.Path > b.Path +} + +func (t *folderTop) offer(f folderSize) { + if len(t.rows) < folderTopN { + t.rows = append(t.rows, f) + return + } + least := 0 + for i := range t.rows { + if folderLess(t.rows[i], t.rows[least]) { + least = i + } + } + if folderLess(t.rows[least], f) { + t.rows[least] = f + } +} + +func (t *folderTop) sorted() []folderSize { + out := append([]folderSize{}, t.rows...) + sort.Slice(out, func(i, j int) bool { return folderLess(out[j], out[i]) }) + return out +} + +// folderAt is the folder that names p in the answer: p itself when it is at +// most depth below root, else its ancestor at depth. The op promises folders +// down to the asked depth and nothing deeper, and a skip deep in a tree is +// reported against the folder the person can see. +func folderAt(root, p string, depth int) string { + var rel string + switch { + case p == root: + return p + case root == "/": + rel = strings.TrimPrefix(p, "/") + default: + rel = strings.TrimPrefix(p, root+"/") + } + parts := strings.Split(rel, "/") + if len(parts) <= depth { + return p + } + if root == "/" { + return "/" + strings.Join(parts[:depth], "/") + } + return root + "/" + strings.Join(parts[:depth], "/") +} + +// folderSkips folds what the walk could not size by (folder, reason). +type folderSkips struct { + by map[[2]string]int + total int +} + +func (s *folderSkips) add(at, reason string) { + s.total++ + if s.by == nil { + s.by = map[[2]string]int{} + } + k := [2]string{at, reason} + if _, ok := s.by[k]; ok || len(s.by) < folderSkipKeys { + s.by[k]++ + } +} + +func (s *folderSkips) list() []folderSkip { + out := make([]folderSkip, 0, len(s.by)) + for k, n := range s.by { + out = append(out, folderSkip{Path: k[0], Reason: k[1], Count: n}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Count != out[j].Count { + return out[i].Count > out[j].Count + } + if out[i].Path != out[j].Path { + return out[i].Path < out[j].Path + } + return out[i].Reason < out[j].Reason + }) + if len(out) > folderSkipCap { + out = out[:folderSkipCap] + } + return out +} + +// parseMountPoints reads /proc/self/mountinfo into the set of mount points. +// The walk does not cross them (du -x), and it knows them BEFORE it stats a +// name, so a dead network or FUSE mount under the path is never touched: a +// stat of its root is the call that hangs, and nothing can cancel a hung +// syscall. Field 5 is the mount point, with space, tab, newline and backslash +// written as octal escapes. +func parseMountPoints(text string) map[string]bool { + m := map[string]bool{} + for _, l := range strings.Split(text, "\n") { + f := strings.Fields(l) + if len(f) < 5 { + continue + } + m[unescapeMountField(f[4])] = true + } + return m +} + +func unescapeMountField(s string) string { + if !strings.Contains(s, `\`) { + return s + } + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] == '\\' && i+3 < len(s) { + if v, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil { + b.WriteByte(byte(v)) + i += 3 + continue + } + } + b.WriteByte(s[i]) + } + return b.String() +} + +// childPath joins a name onto a folder without doubling the root's slash. +func childPath(dir, name string) string { + if dir == "/" { + return "/" + name + } + return dir + "/" + name +} + +func handleObserveFolders(ws *websocket.Conn, _ string, args []string) { + if len(args) < 1 || len(args) > 2 { + refuseObserveArgs(ws, "observe:folders takes a folder path and optionally a depth (1-"+ + strconv.Itoa(folderDepthMax)+")") + return + } + p, depth, err := parseFolderArgs(args) + if err != nil { + refuseObserveArgs(ws, "refused: "+err.Error()) + return + } + if !observeHostSupported { + writeObserve(ws, observeUnsupported()) + return + } + rep, err := readFolders(p, depth) + if err != nil { + refuseObserveArgs(ws, "refused: "+err.Error()) + return + } + writeObserve(ws, rep) +} + +// ── observe:journal ────────────────────────────────────────────────────────── + +// unitSuffixes are systemd's unit types. A name without one is a service, the +// way systemctl itself reads `systemctl status nginx`. +var unitSuffixes = []string{".service", ".socket", ".device", ".mount", ".automount", ".swap", + ".target", ".path", ".timer", ".slice", ".scope"} + +// unitNameRe is systemd's own unit-name alphabet: letters, digits, ":-_.\" +// (unit_name_is_valid's VALID_CHARS) and "@" before an instance. A backslash +// is in it because systemd escapes with it (dev-disk-by\x2duuid-….device); +// the value reaches journalctl as one argv element, never through a shell, and +// journalctl reads a name with no *, ? or [ as an exact unit, not a pattern. +var unitNameRe = regexp.MustCompile(`^[A-Za-z0-9:_.\\@-]+$`) + +// unitNameMax is systemd's UNIT_NAME_MAX less its terminating NUL. +const unitNameMax = 255 + +func hasUnitSuffix(s string) bool { + for _, x := range unitSuffixes { + if strings.HasSuffix(s, x) && len(s) > len(x) { + return true + } + } + return false +} + +// normalizeUnitName is the whole grammar of observe:journal's unit. A leading +// '-' is refused even though one real unit has it (-.mount, the root +// filesystem, which logs nothing a person asks after): a value shaped like a +// flag never reaches a program, whatever flag it is bound to. +func normalizeUnitName(s string) (string, error) { + if s == "" { + return "", errors.New("a unit name is required") + } + if !unitNameRe.MatchString(s) { + return "", errors.New(`a unit name uses only letters, digits and : - _ . \ @`) + } + if s[0] == '-' || s[0] == '.' || s[0] == '@' { + return "", errors.New("a unit name cannot start with '-', '.' or '@'") + } + if !hasUnitSuffix(s) { + s += ".service" + } + if len(s) > unitNameMax { + return "", errors.New("a unit name is at most " + strconv.Itoa(unitNameMax) + " characters") + } + stem := s[:strings.LastIndexByte(s, '.')] + if strings.Count(stem, "@") > 1 { + return "", errors.New("a unit name has at most one '@'") + } + if strings.HasSuffix(stem, "@") { + return "", errors.New("that names a template, which never runs itself; name one instance of it (name@instance.service)") + } + return s, nil +} + +func parseJournalArgs(args []string) (string, int, error) { + unit, err := normalizeUnitName(args[0]) + if err != nil { + return "", 0, err + } + // The same window grammar observe:logs takes, read by the same function. + minutes, err := parseLogMinutes(args) + if err != nil { + return "", 0, err + } + return unit, minutes, nil +} + +// parseUnitList reads `systemctl list-units --all --no-legend --plain` — +// UNIT LOAD ACTIVE SUB DESCRIPTION — into unit name → LOAD state. --plain +// drops the status bullet; one is skipped anyway, should a systemd print it. +func parseUnitList(out string) map[string]string { + m := map[string]string{} + for _, l := range strings.Split(out, "\n") { + f := strings.Fields(l) + if len(f) > 0 && (f[0] == "●" || f[0] == "*") { + f = f[1:] + } + if len(f) < 2 { + continue + } + m[f[0]] = f[1] + } + return m +} + +// parseUnitFiles reads `systemctl list-unit-files --no-legend --plain` — +// UNIT FILE, STATE, PRESET — into the set of installed unit files. +func parseUnitFiles(out string) map[string]bool { + m := map[string]bool{} + for _, l := range strings.Split(out, "\n") { + f := strings.Fields(l) + if len(f) < 2 { + continue + } + m[f[0]] = true + } + return m +} + +// unitKnown says whether this machine has the unit. Both lists are needed: +// list-units holds what systemd has loaded, including instances and units with +// no file of their own, and list-unit-files holds what is installed, including +// a oneshot that ran an hour ago and was unloaded since — the case a person +// asks about ("why did the backup fail?"), and one list-units no longer shows. +// A unit list-units knows only as "not-found" has no file and is not here. +func unitKnown(unit string, loaded map[string]string, files map[string]bool) bool { + if st, ok := loaded[unit]; ok && st != "not-found" { + return true + } + if files[unit] { + return true + } + if i := strings.IndexByte(unit, '@'); i > 0 { + if files[unit[:i+1]+unit[strings.LastIndexByte(unit, '.'):]] { + return true + } + } + return false +} + +// unitSuggestions names up to ten units whose name holds the asked one's +// stem, so a host that guessed "ssh" where the unit is sshd.service can ask +// again without a second round of guessing. +func unitSuggestions(unit string, loaded map[string]string, files map[string]bool) []string { + stem := unit[:strings.LastIndexByte(unit, '.')] + if i := strings.IndexByte(stem, '@'); i >= 0 { + stem = stem[:i] + } + stem = strings.ToLower(stem) + if len(stem) < 2 { + return nil + } + set := map[string]bool{} + for u, st := range loaded { + if st != "not-found" && strings.Contains(strings.ToLower(u), stem) { + set[u] = true + } + } + for u := range files { + if strings.Contains(strings.ToLower(u), stem) { + set[u] = true + } + } + out := make([]string, 0, len(set)) + for u := range set { + out = append(out, u) + } + sort.Strings(out) + if len(out) > 10 { + out = out[:10] + } + return out +} + +// errUnknownUnit names the unit: it passed the alphabet check, so it is a +// unit name and nothing else, and the host needs to see what it asked for. +func errUnknownUnit(unit string, loaded map[string]string, files map[string]bool) error { + msg := "this machine has no unit " + unit + if s := unitSuggestions(unit, loaded, files); len(s) > 0 { + msg += "; units with a similar name: " + strings.Join(s, ", ") + } + return errors.New(msg) +} + +type journalReport struct { + Supported bool `json:"supported"` + Reason string `json:"reason,omitempty"` + Source string `json:"source"` + Unit string `json:"unit"` + Minutes int `json:"minutes"` + Lines int `json:"lines"` + Truncated bool `json:"truncated"` + Partial string `json:"partial,omitempty"` + // Note is set when the answer is empty, for the reason observe:logs sets + // one: an empty log reads like "nothing happened" and has to say which + // unit, over which window, came back with nothing. + Note string `json:"note,omitempty"` + Log string `json:"log"` +} + +func handleObserveJournal(ws *websocket.Conn, _ string, args []string) { + if len(args) < 1 || len(args) > 2 { + refuseObserveArgs(ws, "observe:journal takes a unit name and optionally minutes (1-"+ + strconv.Itoa(observeLogMinutesMax)+")") + return + } + unit, minutes, err := parseJournalArgs(args) + if err != nil { + refuseObserveArgs(ws, "refused: "+err.Error()) + return + } + if !observeHostSupported { + writeObserve(ws, observeUnsupported()) + return + } + rep, err := readJournal(unit, minutes) + if err != nil { + refuseObserveArgs(ws, "refused: "+err.Error()) + return + } + writeObserve(ws, rep) +} + +// observeJournalAccess says why this account cannot read the whole system +// journal, or "" when it can. A seam so a test can answer either way. It +// matters because journalctl run by an account without access does not fail: +// it shows that account's own lines, which hold no sshd and no system unit, so +// an empty answer would read as a quiet machine when it is a blind one. +var observeJournalAccess = journalAccess + +// ── observe:logins ─────────────────────────────────────────────────────────── + +const ( + loginsHoursDefault = 24 + loginsHoursMax = 168 + // loginsLineCap is what journalctl is asked for (--lines=50000 in the argv + // in observe_reads_linux.go; TestObserveLoginsRunsItsPinnedArgv holds the + // two together). A week on a machine the internet is guessing passwords + // at is tens of thousands of sshd lines; 50,000 of about 150 bytes is the + // most this op will hold in memory. Past it the oldest are the ones + // dropped, and the answer says from when it counted. + loginsLineCap = 50000 + // loginsTopN is the size of each ranked list and of the accepted list. + loginsTopN = 20 + // loginNameMax is the longest account name Linux tools create (useradd's + // 32). The names in failed attempts are whatever the other side sent. + loginNameMax = 32 +) + +func parseLoginsArgs(args []string) (int, error) { + if len(args) == 0 { + return loginsHoursDefault, nil + } + return parseBoundedInt(args[0], "hours", 1, loginsHoursMax) +} + +type loginSource struct { + Source string `json:"source"` + Count int `json:"count"` +} + +type loginUser struct { + User string `json:"user"` + Count int `json:"count"` + // Invalid is sshd's own word: no account by that name on this machine. + Invalid bool `json:"invalid,omitempty"` +} + +type acceptedLogin struct { + User string `json:"user"` + Source string `json:"source"` + Method string `json:"method"` + At string `json:"at"` +} + +type loginsReport struct { + Supported bool `json:"supported"` + Reason string `json:"reason,omitempty"` + Source string `json:"source"` + Hours int `json:"hours"` + // The totals read -1 when the journal could not be read: a 0 here would + // clear a finding, and a machine that was not looked at has not said + // nobody tried. + FailedTotal int `json:"failed_total"` + FailedSources int `json:"failed_sources"` + FailedUsers int `json:"failed_users"` + FailedBySource []loginSource `json:"failed_by_source"` + FailedByUser []loginUser `json:"failed_by_user"` + AcceptedTotal int `json:"accepted_total"` + Accepted []acceptedLogin `json:"accepted"` + EntriesRead int `json:"entries_read"` + // OldestEntry is the time of the first sshd entry read. When Truncated + // is set the counts cover from then, not the whole window. + OldestEntry string `json:"oldest_entry,omitempty"` + Truncated bool `json:"truncated"` + Partial string `json:"partial,omitempty"` + Note string `json:"note,omitempty"` +} + +// The four OpenSSH messages this op reads. Every one is anchored at the END: +// the user name is whatever the other side sent, and a name like +// "x from 198.51.100.1 port 1" must not move the source. The greedy user +// group takes the longest name that still leaves a " from port " +// before the end, so the source is always the last one on the line, the one +// sshd wrote. The source must then parse as an address (or, under UseDNS, a +// host name) or the line is not counted. +var ( + sshFailedRe = regexp.MustCompile(`^Failed (\S+) for (invalid user )?(.*) from (\S+) port (\d+)(?: ssh\d?)?(?:: .*)?$`) + sshInvalidRe = regexp.MustCompile(`^Invalid user (.*) from (\S+) port (\d+)$`) + sshPreauthRe = regexp.MustCompile(`^(?:Connection closed by|Connection reset by|Disconnected from) (authenticating|invalid) user (.*) (\S+) port (\d+)(?: \[preauth\])?$`) + sshAcceptedRe = regexp.MustCompile(`^Accepted (\S+) for (.*) from (\S+) port (\d+)(?: ssh\d?)?(?:: .*)?$`) + loginHostRe = regexp.MustCompile(`^[A-Za-z0-9.-]{1,253}$`) +) + +// loginSourceOf accepts an address, or a host name when sshd runs with +// UseDNS, and nothing else. +func loginSourceOf(s string) (string, bool) { + if a, err := netip.ParseAddr(s); err == nil { + return a.Unmap().String(), true + } + if loginHostRe.MatchString(s) { + return strings.ToLower(s), true + } + return "", false +} + +// loginName bounds a user name the other side chose. At most loginNameMax +// bytes from the alphabet account names are made of; anything else is '?'. +// It goes to a host that reads text, and a "user name" with spaces in it is a +// sentence somebody wrote to it, not an account. +func loginName(s string) string { + var b strings.Builder + for i := 0; i < len(s) && b.Len() < loginNameMax; i++ { + c := s[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', + c == '.', c == '_', c == '-', c == '@', c == '$': + b.WriteByte(c) + default: + b.WriteByte('?') + } + } + return b.String() +} + +// sshMethodRe is the shape of sshd's own method names: publickey, password, +// keyboard-interactive/pam, gssapi-with-mic, hostbased. +var sshMethodRe = regexp.MustCompile(`^[a-z0-9@./-]{1,40}$`) + +func loginMethod(s string) string { + if sshMethodRe.MatchString(s) { + return s + } + return "other" +} + +// journalStamp turns short-iso's time into RFC3339 UTC. Older systemd writes +// the offset without a colon. +func journalStamp(ts string) string { + for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05-0700"} { + if t, err := time.Parse(layout, ts); err == nil { + return t.UTC().Format(time.RFC3339) + } + } + return ts +} + +// splitJournalLine splits one `journalctl -o short-iso` line into its time, +// the process id and the message: "