bastion 0.58.0
This commit is contained in:
@@ -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 <sinceArg> -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 <unitArg> <sinceArg> -o short-iso --no-pager -q --lines=5000",
|
||||
"kubectl --kubeconfig <observeKubeconfig> get nodes -o json",
|
||||
"kubectl --kubeconfig <observeKubeconfig> 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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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/<op>/<date>.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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,6 +607,7 @@ func main() {
|
||||
case "ops":
|
||||
// ops [--host <host>] what ops and op grants this machine holds
|
||||
// ops drop <grant> [--host <host>] take back a held op grant (the host is told)
|
||||
// ops drop --all [--host <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])
|
||||
|
||||
+22
-4
@@ -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 <grant|all> (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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,508 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
// Linux drivers for observe:folders, observe:journal, observe:logins and
|
||||
// observe:cert (observe_reads.go holds their grammar and parsers). The
|
||||
// programs are the ones observe_ops_linux.go keeps to — journalctl, systemctl
|
||||
// and ss, read-only forms only — and every exec shares one op budget.
|
||||
//
|
||||
// No elevation of its own. The networked install runs the bastion as root
|
||||
// (deploy/install.sh writes no User=), which is how observe:host already reads
|
||||
// nft and iptables, and so how these read the journal and every folder. A
|
||||
// bastion run as a user reads what that user can: a folder it cannot open is
|
||||
// named in skipped, and a journal it cannot read wholly is said so outright
|
||||
// (journalAccess), because journalctl does not fail for such a user — it shows
|
||||
// only the user's own lines, an empty answer that would read as a quiet machine.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// journalAccess is the default observeJournalAccess. root reads everything;
|
||||
// any other account can read the system journal only when it may open the
|
||||
// system journal file itself (the systemd-journal or adm group), which is
|
||||
// exactly what journalctl will be able to do.
|
||||
func journalAccess() string {
|
||||
if os.Geteuid() == 0 {
|
||||
return ""
|
||||
}
|
||||
raw, err := os.ReadFile("/etc/machine-id")
|
||||
mid := strings.TrimSpace(string(raw))
|
||||
if err == nil && machineIDRe.MatchString(mid) {
|
||||
for _, dir := range []string{"/var/log/journal/", "/run/log/journal/"} {
|
||||
if f, err := os.Open(dir + mid + "/system.journal"); err == nil {
|
||||
_ = f.Close()
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
return "the bastion runs as an account that cannot read the system journal; " +
|
||||
"run it as root (the networked install does) or add that account to the systemd-journal group"
|
||||
}
|
||||
|
||||
var machineIDRe = regexp.MustCompile(`^[0-9a-f]{32}$`)
|
||||
|
||||
// ── observe:folders ──────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
// folderReadBatch is how many names one directory read takes: a folder of
|
||||
// a million files is read in pieces, not into memory at once.
|
||||
folderReadBatch = 512
|
||||
// folderLinkCap bounds the set of hard-linked files already counted.
|
||||
// Past it each further link is counted (sizes may read high) and the
|
||||
// answer says so.
|
||||
folderLinkCap = 1 << 20
|
||||
// folderStuckMax bounds the walks left inside a read that has not
|
||||
// returned. Each holds an OS thread for as long as that read hangs (a hard
|
||||
// network mount: until it comes back), and a Host asking again and again
|
||||
// about a path on one would otherwise pile them up until the bastion
|
||||
// could start no more. Past it the op answers at once, sizing nothing.
|
||||
folderStuckMax = 4
|
||||
)
|
||||
|
||||
// folderStuckGrace is how long past the op budget the answer waits for a walk
|
||||
// that is inside one read, before answering without it. folderSizer is the
|
||||
// walk. Both are variables so a test can stand in a walk that never returns.
|
||||
var (
|
||||
folderStuckGrace = 2 * time.Second
|
||||
folderSizer = sizeFolders
|
||||
// folderWalksStuck counts the walks readFolders answered without that have not
|
||||
// returned since.
|
||||
folderWalksStuck atomic.Int32
|
||||
)
|
||||
|
||||
// folderBeforeOpen is called with each folder's path between its stat and its
|
||||
// open: a seam so a test can swap the folder in that gap. nil outside tests.
|
||||
var folderBeforeOpen func(string)
|
||||
|
||||
// reasonChanged is the skip reason for a folder that is no longer the one the
|
||||
// walk stat'd when it comes to open it: swapped for a link, or reached
|
||||
// through a folder above it that was.
|
||||
const reasonChanged = "changed while it was read, not counted"
|
||||
|
||||
type folderWalk struct {
|
||||
ctx context.Context
|
||||
root string
|
||||
depth int
|
||||
dev uint64
|
||||
mounts map[string]bool
|
||||
linked map[uint64]struct{}
|
||||
linkFull bool
|
||||
top folderTop
|
||||
sized int
|
||||
skips folderSkips
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (w *folderWalk) expired() bool {
|
||||
if !w.cut && w.ctx.Err() != nil {
|
||||
w.cut = true
|
||||
}
|
||||
return w.cut
|
||||
}
|
||||
|
||||
// skip records what could not be sized against the folder that holds it,
|
||||
// folded to the asked depth. A file is never named: its folder is.
|
||||
func (w *folderWalk) skip(dir, reason string) {
|
||||
w.skips.add(folderAt(w.root, dir, w.depth), reason)
|
||||
}
|
||||
|
||||
func folderSkipReason(err error) string {
|
||||
var errno syscall.Errno
|
||||
if errors.As(err, &errno) {
|
||||
if errno == syscall.EACCES || errno == syscall.EPERM {
|
||||
return "permission denied"
|
||||
}
|
||||
return errno.Error()
|
||||
}
|
||||
return "could not be read"
|
||||
}
|
||||
|
||||
// firstLink says whether a file with more than one link is being seen for
|
||||
// the first time, so its blocks are counted once, as du counts them.
|
||||
func (w *folderWalk) firstLink(ino uint64) bool {
|
||||
if _, seen := w.linked[ino]; seen {
|
||||
return false
|
||||
}
|
||||
if len(w.linked) >= folderLinkCap {
|
||||
w.linkFull = true
|
||||
return true
|
||||
}
|
||||
w.linked[ino] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
// openFolder opens p only when it is still the folder st describes. Between
|
||||
// the stat and the open, p, or a folder above it, can be swapped for a link
|
||||
// by whoever owns the folder above; O_NOFOLLOW refuses a link at p itself,
|
||||
// and the device and inode of what opened refuse one above it. Without this
|
||||
// a link put there in that gap would lead the walk elsewhere, onto another
|
||||
// filesystem or into /proc, under p's name.
|
||||
func openFolder(p string, st *syscall.Stat_t) (*os.File, error) {
|
||||
f, err := os.OpenFile(p, os.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0)
|
||||
if err != nil {
|
||||
if errors.Is(err, syscall.ELOOP) || errors.Is(err, syscall.ENOTDIR) {
|
||||
return nil, errFolderChanged
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, err
|
||||
}
|
||||
got, ok := fi.Sys().(*syscall.Stat_t)
|
||||
if !ok || got.Dev != st.Dev || got.Ino != st.Ino {
|
||||
_ = f.Close()
|
||||
return nil, errFolderChanged
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
var errFolderChanged = errors.New(reasonChanged)
|
||||
|
||||
// dir sizes one folder and everything under it on the same filesystem, and
|
||||
// returns the bytes. st is the folder's own stat, taken when its parent was
|
||||
// read: its blocks are counted, and the folder opened must still be the one
|
||||
// it describes (openFolder). Names are read in batches and stat'd as read;
|
||||
// subfolders are walked after the folder is closed, so the walk holds one
|
||||
// open folder at a time however deep it goes.
|
||||
func (w *folderWalk) dir(p string, level int, st *syscall.Stat_t) int64 {
|
||||
total := int64(st.Blocks) * 512
|
||||
if w.expired() {
|
||||
return total
|
||||
}
|
||||
if folderBeforeOpen != nil {
|
||||
folderBeforeOpen(p)
|
||||
}
|
||||
f, err := openFolder(p, st)
|
||||
if err != nil {
|
||||
if errors.Is(err, errFolderChanged) {
|
||||
w.skip(p, reasonChanged)
|
||||
} else {
|
||||
w.skip(p, folderSkipReason(err))
|
||||
}
|
||||
return total
|
||||
}
|
||||
var subdirs []string
|
||||
var subSt []syscall.Stat_t
|
||||
for !w.expired() {
|
||||
ents, rerr := f.ReadDir(folderReadBatch)
|
||||
for _, e := range ents {
|
||||
child := childPath(p, e.Name())
|
||||
if w.mounts[child] {
|
||||
w.skip(child, reasonOtherFS)
|
||||
continue
|
||||
}
|
||||
var st syscall.Stat_t
|
||||
if err := syscall.Lstat(child, &st); err != nil {
|
||||
// Gone between the read and the stat is churn, not a gap.
|
||||
if !errors.Is(err, syscall.ENOENT) {
|
||||
w.skip(p, folderSkipReason(err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
size := int64(st.Blocks) * 512
|
||||
if st.Mode&syscall.S_IFMT == syscall.S_IFDIR {
|
||||
// The mount table is read once at the start; a mount made
|
||||
// since is caught here by its device instead.
|
||||
if uint64(st.Dev) != w.dev {
|
||||
w.skip(child, reasonOtherFS)
|
||||
continue
|
||||
}
|
||||
subdirs = append(subdirs, child)
|
||||
subSt = append(subSt, st)
|
||||
continue
|
||||
}
|
||||
if uint64(st.Nlink) > 1 && !w.firstLink(uint64(st.Ino)) {
|
||||
continue
|
||||
}
|
||||
total += size
|
||||
}
|
||||
if rerr != nil {
|
||||
if rerr != io.EOF {
|
||||
w.skip(p, folderSkipReason(rerr))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = f.Close()
|
||||
for i, c := range subdirs {
|
||||
if w.expired() {
|
||||
break
|
||||
}
|
||||
total += w.dir(c, level+1, &subSt[i])
|
||||
}
|
||||
if level >= 1 && level <= w.depth {
|
||||
w.sized++
|
||||
w.top.offer(folderSize{Path: p, Bytes: total, Depth: level})
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// readFolders sizes the folders under asked. The error is a refusal: a path
|
||||
// that is not a folder here, or whose links lead into a kernel tree.
|
||||
//
|
||||
// All of it, the path's resolution included, runs on its own goroutine, so
|
||||
// the answer goes out at the op budget even when one call never returns: a
|
||||
// stat of a dead network mount named as the path, or a disk erroring slowly
|
||||
// under it. That goroutine owns everything it touches; on the stuck path this
|
||||
// function reads nothing of it and answers without it. Such a walk is counted
|
||||
// until it returns, and past folderStuckMax of them no walk starts.
|
||||
func readFolders(asked string, depth int) (foldersReport, error) {
|
||||
stuck := func(partial, note string) foldersReport {
|
||||
return foldersReport{Supported: true, Path: asked, Depth: depth, Folders: []folderSize{},
|
||||
Skipped: []folderSkip{}, Partial: partial, Note: note}
|
||||
}
|
||||
if folderWalksStuck.Load() >= folderStuckMax {
|
||||
return stuck("earlier reads on this machine have not returned",
|
||||
"nothing was sized: "+strconv.Itoa(folderStuckMax)+" earlier walks are still inside a read that "+
|
||||
"has not returned (a network mount that stopped answering, most often); try again once it answers"), nil
|
||||
}
|
||||
ctx, cancel := opBudget()
|
||||
defer cancel()
|
||||
type result struct {
|
||||
rep foldersReport
|
||||
err error
|
||||
}
|
||||
// 0 running, 1 returned, 2 answered without: whichever side moves it
|
||||
// from 0 first decides whether the walk holds a place in folderWalksStuck.
|
||||
var state atomic.Int32
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
rep, err := folderSizer(ctx, asked, depth)
|
||||
done <- result{rep, err}
|
||||
if !state.CompareAndSwap(0, 1) {
|
||||
folderWalksStuck.Add(-1)
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case r := <-done:
|
||||
return r.rep, r.err
|
||||
case <-ctx.Done():
|
||||
select {
|
||||
case r := <-done:
|
||||
return r.rep, r.err
|
||||
case <-time.After(folderStuckGrace):
|
||||
if !state.CompareAndSwap(0, 2) {
|
||||
r := <-done
|
||||
return r.rep, r.err
|
||||
}
|
||||
folderWalksStuck.Add(1)
|
||||
return stuck("a read under this path did not return inside the op budget",
|
||||
"nothing was sized; ask about a folder further down, or try again"), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sizeFolders(ctx context.Context, asked string, depth int) (foldersReport, error) {
|
||||
resolved, err := filepath.EvalSymlinks(asked)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return foldersReport{}, errors.New("there is no folder at that path on this machine")
|
||||
}
|
||||
return foldersReport{}, errors.New("that path cannot be read: " + folderSkipReason(err))
|
||||
}
|
||||
if err := folderPathAllowed(resolved); err != nil {
|
||||
return foldersReport{}, err
|
||||
}
|
||||
var st syscall.Stat_t
|
||||
if err := syscall.Lstat(resolved, &st); err != nil {
|
||||
return foldersReport{}, errors.New("that path cannot be read: " + folderSkipReason(err))
|
||||
}
|
||||
if st.Mode&syscall.S_IFMT != syscall.S_IFDIR {
|
||||
return foldersReport{}, errors.New("that path is not a folder")
|
||||
}
|
||||
rep := foldersReport{Supported: true, Path: asked, Depth: depth}
|
||||
if resolved != asked {
|
||||
rep.Resolved = resolved
|
||||
}
|
||||
mounts := map[string]bool{}
|
||||
if b, err := os.ReadFile("/proc/self/mountinfo"); err == nil {
|
||||
mounts = parseMountPoints(string(b))
|
||||
}
|
||||
w := &folderWalk{ctx: ctx, root: resolved, depth: depth, dev: uint64(st.Dev), mounts: mounts,
|
||||
linked: map[uint64]struct{}{}}
|
||||
rep.TotalBytes = w.dir(resolved, 0, &st)
|
||||
rep.Folders = w.top.sorted()
|
||||
rep.Sized = w.sized
|
||||
rep.Skipped = w.skips.list()
|
||||
rep.SkippedTotal = w.skips.total
|
||||
rep.Complete = !w.cut
|
||||
if w.cut {
|
||||
rep.Partial = "timeout"
|
||||
rep.Note = "the walk ran out of time: every size is at least what is shown, and a folder it never reached is missing"
|
||||
}
|
||||
if w.linkFull {
|
||||
rep.Note = strings.TrimSpace(rep.Note + " More than " + strconv.Itoa(folderLinkCap) +
|
||||
" hard-linked files: past that each link was counted, so sizes may read high.")
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// ── observe:journal ──────────────────────────────────────────────────────────
|
||||
|
||||
// readJournal reads one unit's recent journal. The unit has passed
|
||||
// normalizeUnitName; the error is a refusal when this machine has no such
|
||||
// unit, and every other gap is a reported reason.
|
||||
func readJournal(unit string, minutes int) (journalReport, error) {
|
||||
rep := journalReport{Source: "journal", Unit: unit, Minutes: minutes}
|
||||
if !observeHave("journalctl") {
|
||||
rep.Reason = "journalctl not installed"
|
||||
return rep, nil
|
||||
}
|
||||
if !observeHave("systemctl") {
|
||||
rep.Reason = "systemctl not installed, so this machine cannot say which units it has"
|
||||
return rep, nil
|
||||
}
|
||||
if why := observeJournalAccess(); why != "" {
|
||||
rep.Reason = why
|
||||
return rep, nil
|
||||
}
|
||||
ctx, cancel := opBudget()
|
||||
defer cancel()
|
||||
// list-units is observe:units' read with --all for --failed: every unit
|
||||
// systemd has loaded. A failed run is not an empty list here either —
|
||||
// it would refuse every unit as unknown — so its error is checked.
|
||||
listed, lerr := observeExec(ctx, "systemctl", "list-units", "--all", "--no-legend", "--plain")
|
||||
if ctx.Err() != nil {
|
||||
rep.Reason = "systemctl list-units: timeout"
|
||||
return rep, nil
|
||||
}
|
||||
if lerr != nil && len(listed) == 0 {
|
||||
rep.Reason = "systemctl list-units: " + lerr.Error()
|
||||
return rep, nil
|
||||
}
|
||||
// The same for list-unit-files: without it an installed unit that is not
|
||||
// loaded right now would be refused as one this machine does not have.
|
||||
files, ferr := observeExec(ctx, "systemctl", "list-unit-files", "--no-legend", "--plain")
|
||||
if ctx.Err() != nil {
|
||||
rep.Reason = "systemctl list-unit-files: timeout"
|
||||
return rep, nil
|
||||
}
|
||||
if ferr != nil && len(files) == 0 {
|
||||
rep.Reason = "systemctl list-unit-files: " + ferr.Error()
|
||||
return rep, nil
|
||||
}
|
||||
loaded, installed := parseUnitList(string(listed)), parseUnitFiles(string(files))
|
||||
if !unitKnown(unit, loaded, installed) {
|
||||
return journalReport{}, errUnknownUnit(unit, loaded, installed)
|
||||
}
|
||||
// Bound to their flags with '=', so neither can be read as a flag of its
|
||||
// own whatever it held. --lines is observeLogLineLimit, the line limit
|
||||
// observe:logs asks Loki for: the newest 5000 lines in the window.
|
||||
unitArg := "--unit=" + unit
|
||||
sinceArg := "--since=-" + strconv.Itoa(minutes) + "min"
|
||||
out, err := observeExec(ctx, "journalctl", unitArg, sinceArg, "-o", "short-iso", "--no-pager", "-q", "--lines=5000")
|
||||
if ctx.Err() != nil {
|
||||
rep.Partial = "journalctl: timeout"
|
||||
} else if err != nil && len(out) == 0 {
|
||||
rep.Reason = "journalctl: " + err.Error()
|
||||
return rep, nil
|
||||
}
|
||||
rep.Supported = true
|
||||
rep.Log, rep.Truncated = capLines(string(out), observeLogLineLimit, observeTextCap)
|
||||
if strings.TrimSpace(rep.Log) != "" {
|
||||
rep.Lines = strings.Count(strings.TrimRight(rep.Log, "\n"), "\n") + 1
|
||||
} else {
|
||||
rep.Log = ""
|
||||
rep.Note = "no lines from " + unit + " in the last " + strconv.Itoa(minutes) + " minutes"
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// ── observe:logins ───────────────────────────────────────────────────────────
|
||||
|
||||
// readLogins counts sshd's logins over the window. sshd is matched two ways,
|
||||
// either one enough ("+" is journalctl's OR): by program — sshd, and the
|
||||
// sshd-session and sshd-auth processes newer OpenSSH splits it into — and by
|
||||
// unit, ssh.service on Debian and Ubuntu, sshd.service elsewhere. --lines is
|
||||
// loginsLineCap.
|
||||
//
|
||||
// Each match also needs _UID=0. Any account can run a program named sshd and
|
||||
// log under _COMM=sshd, and one whose shell stays in ssh.service's cgroup (no
|
||||
// pam_systemd) logs under that unit; either could add failures or accepted
|
||||
// logins, or bury the real ones under 50,000 lines. _UID is journald's own,
|
||||
// from the sender's credentials, and every message parsed here is written by
|
||||
// sshd's root monitor (OpenSSH sends its unprivileged child's lines through
|
||||
// it). A sshd run by an ordinary account is not read.
|
||||
func readLogins(hours int) loginsReport {
|
||||
rep := loginsReport{Source: "journal", Hours: hours, FailedTotal: -1, AcceptedTotal: -1,
|
||||
FailedBySource: []loginSource{}, FailedByUser: []loginUser{}, Accepted: []acceptedLogin{}}
|
||||
if !observeHave("journalctl") {
|
||||
rep.Reason = "journalctl not installed"
|
||||
return rep
|
||||
}
|
||||
if why := observeJournalAccess(); why != "" {
|
||||
rep.Reason = why
|
||||
return rep
|
||||
}
|
||||
ctx, cancel := opBudget()
|
||||
defer cancel()
|
||||
sinceArg := "--since=-" + strconv.Itoa(hours) + "h"
|
||||
out, err := observeExec(ctx, "journalctl", sinceArg, "-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")
|
||||
late := ctx.Err() != nil
|
||||
if !late && err != nil && len(out) == 0 {
|
||||
rep.Reason = "journalctl: " + err.Error()
|
||||
return rep
|
||||
}
|
||||
t := parseSSHLogins(string(out))
|
||||
rep.Supported = true
|
||||
t.fill(&rep)
|
||||
rep.Truncated = t.entries >= loginsLineCap
|
||||
if late {
|
||||
rep.Partial = "journalctl: timeout"
|
||||
}
|
||||
if t.entries == 0 {
|
||||
rep.Note = "no sshd entries in the journal in the last " + strconv.Itoa(hours) + " hours"
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
// ── observe:cert ─────────────────────────────────────────────────────────────
|
||||
|
||||
// readCert reads the certificate this machine's listener on port serves. The
|
||||
// listener table is observe:sockets' own read (ss -tlnpH); the error is a
|
||||
// refusal when nothing listens on the port.
|
||||
func readCert(port int, name string) (certReport, error) {
|
||||
rep := certReport{Port: port, ServerName: name, Listening: []string{}, Tried: []string{},
|
||||
CheckedAt: time.Now().UTC().Format(time.RFC3339)}
|
||||
if !observeHave("ss") {
|
||||
rep.Reason = "ss not installed, so this machine cannot say which ports it listens on"
|
||||
return rep, nil
|
||||
}
|
||||
ctx, cancel := opBudget()
|
||||
defer cancel()
|
||||
out, err := observeExec(ctx, "ss", "-tlnpH")
|
||||
// A failed ss is not an empty table (readSockets says why): here an empty
|
||||
// table would refuse every port as not listening.
|
||||
if ctx.Err() != nil {
|
||||
rep.Reason = "ss: timeout"
|
||||
return rep, nil
|
||||
}
|
||||
if err != nil {
|
||||
rep.Reason = "ss: " + err.Error()
|
||||
return rep, nil
|
||||
}
|
||||
targets, listening := certTargets(parseSS("tcp", string(out)), port, observeOwnAddrs())
|
||||
if len(listening) == 0 {
|
||||
return certReport{}, errors.New("nothing on this machine listens on tcp port " + strconv.Itoa(port))
|
||||
}
|
||||
rep.Supported = true
|
||||
rep.Listening = listening
|
||||
dialCert(ctx, &rep, targets, name)
|
||||
return rep, nil
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// stubObserveReads puts every exec of the four ops behind answer and records
|
||||
// each argv, joined by spaces, in the order run. Every tool is present and
|
||||
// the journal readable unless a test says otherwise.
|
||||
func stubObserveReads(t *testing.T, answer func(argv string) ([]byte, error)) func() []string {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
var recorded []string
|
||||
origExec, origHave, origAccess, origOwn := observeExec, observeHave, observeJournalAccess, observeOwnAddrs
|
||||
observeExec = func(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||
argv := strings.Join(append([]string{name}, args...), " ")
|
||||
mu.Lock()
|
||||
recorded = append(recorded, argv)
|
||||
mu.Unlock()
|
||||
return answer(argv)
|
||||
}
|
||||
observeHave = func(string) bool { return true }
|
||||
observeJournalAccess = func() string { return "" }
|
||||
t.Cleanup(func() {
|
||||
observeExec, observeHave, observeJournalAccess, observeOwnAddrs = origExec, origHave, origAccess, origOwn
|
||||
})
|
||||
return func() []string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
out := append([]string(nil), recorded...)
|
||||
recorded = nil
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
func unitAnswers(journal string) func(string) ([]byte, error) {
|
||||
return func(argv string) ([]byte, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(argv, "systemctl list-units "):
|
||||
return []byte(fxListUnits), nil
|
||||
case strings.HasPrefix(argv, "systemctl list-unit-files "):
|
||||
return []byte(fxUnitFiles), nil
|
||||
case strings.HasPrefix(argv, "journalctl "):
|
||||
return []byte(journal), nil
|
||||
}
|
||||
return nil, errors.New("unexpected " + argv)
|
||||
}
|
||||
}
|
||||
|
||||
// ── observe:journal ──────────────────────────────────────────────────────────
|
||||
|
||||
// observe:journal runs exactly three argvs, and the unit reaches the third
|
||||
// only as a name this machine's own lists hold, bound to --unit=.
|
||||
func TestObserveJournalRunsOnlyItsPinnedArgv(t *testing.T) {
|
||||
log := "2026-10-29T10:00:00+00:00 box-a example[42]: started\n" +
|
||||
"2026-10-29T10:00:05+00:00 box-a example[42]: listening on 8080\n"
|
||||
recorded := stubObserveReads(t, unitAnswers(log))
|
||||
out, errOut, code := driveObserve(t, "observe:journal", []string{"example", "5"})
|
||||
if code != 0 {
|
||||
t.Fatalf("exit %d: %s", code, errOut)
|
||||
}
|
||||
want := []string{
|
||||
"systemctl list-units --all --no-legend --plain",
|
||||
"systemctl list-unit-files --no-legend --plain",
|
||||
"journalctl --unit=example.service --since=-5min -o short-iso --no-pager -q --lines=" +
|
||||
strconv.Itoa(observeLogLineLimit),
|
||||
}
|
||||
if got := recorded(); strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("argvs\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
var rep journalReport
|
||||
if err := json.Unmarshal([]byte(out), &rep); err != nil {
|
||||
t.Fatalf("%v: %s", err, out)
|
||||
}
|
||||
if !rep.Supported || rep.Unit != "example.service" || rep.Minutes != 5 || rep.Lines != 2 || rep.Truncated ||
|
||||
!strings.HasSuffix(rep.Log, "listening on 8080") || rep.Note != "" || rep.Source != "journal" {
|
||||
t.Fatalf("%+v", rep)
|
||||
}
|
||||
|
||||
// Installed and unloaded since, and an instance of an installed template:
|
||||
// both are this machine's units.
|
||||
for unit, want := range map[string]string{"report": "--unit=report.service", "worker@3": "--unit=worker@3.service"} {
|
||||
if _, errOut, code := driveObserve(t, "observe:journal", []string{unit}); code != 0 {
|
||||
t.Fatalf("%s: exit %d %s", unit, code, errOut)
|
||||
}
|
||||
got := recorded()
|
||||
if len(got) != 3 || !strings.HasPrefix(got[2], "journalctl "+want+" --since=-15min ") {
|
||||
t.Fatalf("%s: %q", unit, got)
|
||||
}
|
||||
}
|
||||
|
||||
// A unit this machine does not have, or knows only as not-found, is
|
||||
// refused before journalctl runs, and the refusal names the near ones.
|
||||
for unit, hint := range map[string]string{"ssh": "ssh.socket", "gone": "no unit gone.service"} {
|
||||
_, errOut, code := driveObserve(t, "observe:journal", []string{unit})
|
||||
if code != 2 || !strings.Contains(errOut, "refused: this machine has no unit") || !strings.Contains(errOut, hint) {
|
||||
t.Fatalf("%s: code=%d err=%q", unit, code, errOut)
|
||||
}
|
||||
for _, argv := range recorded() {
|
||||
if strings.HasPrefix(argv, "journalctl") {
|
||||
t.Fatalf("%s: journalctl ran for a unit this machine does not have: %q", unit, argv)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveJournalSaysWhyItCannotAnswer(t *testing.T) {
|
||||
recorded := stubObserveReads(t, unitAnswers(""))
|
||||
// Empty: a note that names the unit and the window.
|
||||
out, _, code := driveObserve(t, "observe:journal", []string{"example.service", "1"})
|
||||
var rep journalReport
|
||||
_ = json.Unmarshal([]byte(out), &rep)
|
||||
if code != 0 || !rep.Supported || rep.Lines != 0 || rep.Log != "" ||
|
||||
rep.Note != "no lines from example.service in the last 1 minutes" {
|
||||
t.Fatalf("empty: %d %+v", code, rep)
|
||||
}
|
||||
recorded()
|
||||
|
||||
// An account that cannot read the system journal: said, at exit 0, and
|
||||
// nothing is run — journalctl would show that account's own lines only.
|
||||
observeJournalAccess = func() string { return "cannot read the system journal" }
|
||||
out, _, code = driveObserve(t, "observe:journal", []string{"example"})
|
||||
rep = journalReport{}
|
||||
_ = json.Unmarshal([]byte(out), &rep)
|
||||
if code != 0 || rep.Supported || rep.Reason != "cannot read the system journal" || len(recorded()) != 0 {
|
||||
t.Fatalf("no access: %d %+v", code, rep)
|
||||
}
|
||||
observeJournalAccess = func() string { return "" }
|
||||
|
||||
// A list-units that failed is not an empty list: every unit would be
|
||||
// refused as unknown.
|
||||
observeExec = func(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||
return nil, errors.New("exit status 1")
|
||||
}
|
||||
out, _, code = driveObserve(t, "observe:journal", []string{"example"})
|
||||
rep = journalReport{}
|
||||
_ = json.Unmarshal([]byte(out), &rep)
|
||||
if code != 0 || rep.Supported || !strings.Contains(rep.Reason, "systemctl list-units: exit status 1") {
|
||||
t.Fatalf("failed list: %d %+v", code, rep)
|
||||
}
|
||||
// And a failed list-unit-files: an installed unit that is not loaded now
|
||||
// would otherwise be refused as one this machine does not have.
|
||||
observeExec = func(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||
if args[0] == "list-units" {
|
||||
return []byte(fxListUnits), nil
|
||||
}
|
||||
return nil, errors.New("exit status 1")
|
||||
}
|
||||
out, _, code = driveObserve(t, "observe:journal", []string{"report"})
|
||||
rep = journalReport{}
|
||||
_ = json.Unmarshal([]byte(out), &rep)
|
||||
if code != 0 || rep.Supported || !strings.Contains(rep.Reason, "systemctl list-unit-files: exit status 1") {
|
||||
t.Fatalf("failed unit files: %d %+v", code, rep)
|
||||
}
|
||||
}
|
||||
|
||||
// ── observe:logins ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestObserveLoginsRunsItsPinnedArgv(t *testing.T) {
|
||||
recorded := stubObserveReads(t, func(argv string) ([]byte, error) { return []byte(fxSSHD), nil })
|
||||
out, errOut, code := driveObserve(t, "observe:logins", []string{"48"})
|
||||
if code != 0 {
|
||||
t.Fatalf("exit %d: %s", code, errOut)
|
||||
}
|
||||
want := "journalctl --since=-48h -o short-iso --no-pager -q --lines=" + strconv.Itoa(loginsLineCap) +
|
||||
" _COMM=sshd _COMM=sshd-session _COMM=sshd-auth _UID=0 + _SYSTEMD_UNIT=ssh.service _SYSTEMD_UNIT=sshd.service _UID=0"
|
||||
if got := recorded(); len(got) != 1 || got[0] != want {
|
||||
t.Fatalf("argv\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
var rep loginsReport
|
||||
if err := json.Unmarshal([]byte(out), &rep); err != nil {
|
||||
t.Fatalf("%v: %s", err, out)
|
||||
}
|
||||
if !rep.Supported || rep.Hours != 48 || rep.FailedTotal != 8 || rep.FailedSources != 5 || rep.AcceptedTotal != 2 ||
|
||||
rep.EntriesRead != 16 || rep.Truncated || rep.Note != "" || rep.OldestEntry != "2026-10-29T10:00:01Z" {
|
||||
t.Fatalf("%+v", rep)
|
||||
}
|
||||
if _, _, code := driveObserve(t, "observe:logins", nil); code != 0 || !strings.HasPrefix(recorded()[0], "journalctl --since=-24h ") {
|
||||
t.Fatalf("the default window is 24 hours")
|
||||
}
|
||||
|
||||
// No journal access: unknown, not zero.
|
||||
observeJournalAccess = func() string { return "cannot read the system journal" }
|
||||
out, _, code = driveObserve(t, "observe:logins", nil)
|
||||
rep = loginsReport{}
|
||||
_ = json.Unmarshal([]byte(out), &rep)
|
||||
if code != 0 || rep.Supported || rep.FailedTotal != -1 || rep.AcceptedTotal != -1 || len(recorded()) != 0 {
|
||||
t.Fatalf("no access must read unknown (-1), never 0: %d %+v", code, rep)
|
||||
}
|
||||
observeJournalAccess = func() string { return "" }
|
||||
|
||||
// Nothing logged: zeros, and a note that says which window was empty.
|
||||
observeExec = func(context.Context, string, ...string) ([]byte, error) { return nil, nil }
|
||||
out, _, _ = driveObserve(t, "observe:logins", []string{"1"})
|
||||
rep = loginsReport{}
|
||||
_ = json.Unmarshal([]byte(out), &rep)
|
||||
if !rep.Supported || rep.FailedTotal != 0 || rep.Note != "no sshd entries in the journal in the last 1 hours" ||
|
||||
rep.FailedBySource == nil || rep.Accepted == nil {
|
||||
t.Fatalf("empty: %+v", rep)
|
||||
}
|
||||
}
|
||||
|
||||
// Any account can name a process sshd (copy a logger, call it sshd) and
|
||||
// write lines journald files under _COMM=sshd, and an account whose shell
|
||||
// stays in ssh.service's cgroup (no pam_systemd) writes under that unit. Only
|
||||
// root's lines count: every message this op reads is the root monitor's
|
||||
// (OpenSSH logs its unprivileged child through it), and _UID is journald's
|
||||
// own, from the sender's credentials. Each disjunct carries _UID=0, so no
|
||||
// match reads a non-root line.
|
||||
func TestObserveLoginsCountsOnlyRootsSSHDLines(t *testing.T) {
|
||||
recorded := stubObserveReads(t, func(string) ([]byte, error) { return nil, nil })
|
||||
if _, errOut, code := driveObserve(t, "observe:logins", nil); code != 0 {
|
||||
t.Fatalf("exit %d: %s", code, errOut)
|
||||
}
|
||||
got := recorded()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
f := strings.Fields(got[0])
|
||||
start := 0
|
||||
for i, a := range f {
|
||||
if strings.HasPrefix(a, "--lines=") {
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start == 0 || start >= len(f) {
|
||||
t.Fatalf("no matches after --lines: %q", got[0])
|
||||
}
|
||||
var groups [][]string
|
||||
cur := []string{}
|
||||
for _, a := range f[start:] {
|
||||
if a == "+" {
|
||||
groups, cur = append(groups, cur), []string{}
|
||||
continue
|
||||
}
|
||||
cur = append(cur, a)
|
||||
}
|
||||
groups = append(groups, cur)
|
||||
for _, g := range groups {
|
||||
uid := false
|
||||
for _, a := range g {
|
||||
uid = uid || a == "_UID=0"
|
||||
}
|
||||
if !uid {
|
||||
t.Fatalf("a match reads lines any account can write: %q in %q", g, got[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── observe:cert ─────────────────────────────────────────────────────────────
|
||||
|
||||
func ssLine(local string, port int) string {
|
||||
return "LISTEN 0 4096 " + local + ":" + strconv.Itoa(port) + " 0.0.0.0:* users:((\"example\",pid=10,fd=3))\n"
|
||||
}
|
||||
|
||||
// The port must be one this machine listens on, the listener table is ss's,
|
||||
// and the handshake goes to loopback or this machine's own addresses only.
|
||||
func TestObserveCertReadsOnlyThisMachinesListener(t *testing.T) {
|
||||
cert := observeTestCert(t, "box-a.example", []string{"box-a.example"}, nil, time.Now().Add(10*24*time.Hour+time.Hour))
|
||||
port := observeTLSListener(t, cert)
|
||||
foreign := closedPort(t)
|
||||
ss := ssLine("127.0.0.1", port) + ssLine("192.0.2.50", foreign)
|
||||
recorded := stubObserveReads(t, func(argv string) ([]byte, error) {
|
||||
if argv == "ss -tlnpH" {
|
||||
return []byte(ss), nil
|
||||
}
|
||||
return nil, errors.New("unexpected " + argv)
|
||||
})
|
||||
observeOwnAddrs = func() map[netip.Addr]bool { return map[netip.Addr]bool{netip.MustParseAddr("127.0.0.1"): true} }
|
||||
|
||||
out, errOut, code := driveObserve(t, "observe:cert", []string{strconv.Itoa(port), "box-a.example"})
|
||||
if code != 0 {
|
||||
t.Fatalf("exit %d: %s", code, errOut)
|
||||
}
|
||||
if got := recorded(); len(got) != 1 || got[0] != "ss -tlnpH" {
|
||||
t.Fatalf("the one argv is observe:sockets' tcp read: %q", got)
|
||||
}
|
||||
var rep certReport
|
||||
if err := json.Unmarshal([]byte(out), &rep); err != nil {
|
||||
t.Fatalf("%v: %s", err, out)
|
||||
}
|
||||
if !rep.Supported || rep.ConnectedTo != "127.0.0.1:"+strconv.Itoa(port) || !rep.TLS || rep.ServerName != "box-a.example" ||
|
||||
len(rep.Listening) != 1 || rep.Listening[0] != "127.0.0.1" || rep.DaysLeft == nil || *rep.DaysLeft != 10 ||
|
||||
rep.ChainVerified == nil || *rep.ChainVerified || rep.CheckedAt == "" {
|
||||
t.Fatalf("%s", out)
|
||||
}
|
||||
|
||||
// Nothing listens: refused, and nothing dialled.
|
||||
_, errOut, code = driveObserve(t, "observe:cert", []string{"1"})
|
||||
if code != 2 || !strings.Contains(errOut, "refused: nothing on this machine listens on tcp port 1") {
|
||||
t.Fatalf("code=%d err=%q", code, errOut)
|
||||
}
|
||||
|
||||
// A listener bound to an address this machine does not hold: loopback is
|
||||
// tried and the other address never is — it would reach another machine.
|
||||
out, _, code = driveObserve(t, "observe:cert", []string{strconv.Itoa(foreign)})
|
||||
rep = certReport{}
|
||||
_ = json.Unmarshal([]byte(out), &rep)
|
||||
if code != 0 || !rep.Supported || rep.ConnectedTo != "" || len(rep.Tried) != 2 || rep.TLS {
|
||||
t.Fatalf("%d %s", code, out)
|
||||
}
|
||||
for _, tried := range rep.Tried {
|
||||
if strings.Contains(tried, "192.0.2.50") {
|
||||
t.Fatalf("dialled an address this machine does not hold: %v", rep.Tried)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed ss is not an empty table: it says so instead of refusing.
|
||||
observeExec = func(context.Context, string, ...string) ([]byte, error) { return nil, errors.New("exit status 1") }
|
||||
out, _, code = driveObserve(t, "observe:cert", []string{strconv.Itoa(port)})
|
||||
rep = certReport{}
|
||||
_ = json.Unmarshal([]byte(out), &rep)
|
||||
if code != 0 || rep.Supported || rep.Reason != "ss: exit status 1" {
|
||||
t.Fatalf("%d %s", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
// ── observe:folders ──────────────────────────────────────────────────────────
|
||||
|
||||
func writeFile(t *testing.T, p string, n int) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, bytes.Repeat([]byte("x"), n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// folderTree is a tree with a nested chain, a folder with one file linked
|
||||
// twice, a file linked from a second folder, and an empty folder.
|
||||
func folderTree(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
writeFile(t, root+"/a/a1", 64<<10)
|
||||
writeFile(t, root+"/a/b/b1", 32<<10)
|
||||
writeFile(t, root+"/a/b/c/c1", 16<<10)
|
||||
writeFile(t, root+"/a/b/c/d/d1", 8<<10)
|
||||
writeFile(t, root+"/big/big1", 256<<10)
|
||||
if err := os.Link(root+"/big/big1", root+"/big/big1-again"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(root+"/empty", 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func blocksOf(t *testing.T, p string) int64 {
|
||||
t.Helper()
|
||||
var st syscall.Stat_t
|
||||
if err := syscall.Lstat(p, &st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return int64(st.Blocks) * 512
|
||||
}
|
||||
|
||||
func TestObserveFoldersSizesTheTree(t *testing.T) {
|
||||
root := folderTree(t)
|
||||
rep, err := readFolders(root, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rep.Supported || !rep.Complete || rep.Partial != "" || rep.Path != root || rep.Resolved != "" || rep.Depth != 2 {
|
||||
t.Fatalf("%+v", rep)
|
||||
}
|
||||
// What is expected, from the blocks of each thing: big1 once, though two
|
||||
// names link it.
|
||||
dirs := func(ps ...string) (n int64) {
|
||||
for _, p := range ps {
|
||||
n += blocksOf(t, p)
|
||||
}
|
||||
return n
|
||||
}
|
||||
c := dirs(root+"/a/b/c", root+"/a/b/c/c1", root+"/a/b/c/d", root+"/a/b/c/d/d1")
|
||||
b := dirs(root+"/a/b", root+"/a/b/b1") + c
|
||||
a := dirs(root+"/a", root+"/a/a1") + b
|
||||
bigd := dirs(root+"/big", root+"/big/big1")
|
||||
empty := dirs(root + "/empty")
|
||||
want := map[string]int64{root + "/a": a, root + "/a/b": b, root + "/big": bigd, root + "/empty": empty}
|
||||
if rep.Sized != len(want) || len(rep.Folders) != len(want) {
|
||||
t.Fatalf("depth 2 sizes the folders at 1 and 2 below the path: %+v", rep.Folders)
|
||||
}
|
||||
for _, f := range rep.Folders {
|
||||
if want[f.Path] != f.Bytes {
|
||||
t.Fatalf("%s: %d, want %d", f.Path, f.Bytes, want[f.Path])
|
||||
}
|
||||
if f.Depth != strings.Count(strings.TrimPrefix(f.Path, root), "/") {
|
||||
t.Fatalf("depth of %s: %d", f.Path, f.Depth)
|
||||
}
|
||||
if strings.Contains(f.Path, "/a/b/c") {
|
||||
t.Fatalf("a folder below the depth was named: %s", f.Path)
|
||||
}
|
||||
}
|
||||
if rep.TotalBytes != blocksOf(t, root)+a+bigd+empty {
|
||||
t.Fatalf("total %d", rep.TotalBytes)
|
||||
}
|
||||
if rep.Folders[0].Path != root+"/big" && rep.Folders[0].Path != root+"/a" {
|
||||
t.Fatalf("largest first: %+v", rep.Folders)
|
||||
}
|
||||
// Only folders: no file name anywhere in the answer.
|
||||
raw, _ := json.Marshal(rep)
|
||||
for _, file := range []string{"a1", "b1", "c1", "d1", "big1"} {
|
||||
if strings.Contains(string(raw), "/"+file) {
|
||||
t.Fatalf("a file name reached the answer: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Through a symlink: sized where it leads, and said so.
|
||||
link := filepath.Join(t.TempDir(), "link")
|
||||
if err := os.Symlink(root, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rep, err = readFolders(link, 1)
|
||||
if err != nil || rep.Resolved != root || rep.Path != link || rep.Sized != 3 {
|
||||
t.Fatalf("%v %+v", err, rep)
|
||||
}
|
||||
}
|
||||
|
||||
// The same tree measured by du: the op's numbers are du -x's, folder for
|
||||
// folder. Skipped where there is no GNU du to ask.
|
||||
func TestObserveFoldersAgreesWithDu(t *testing.T) {
|
||||
if _, err := exec.LookPath("du"); err != nil {
|
||||
t.Skip("no du here")
|
||||
}
|
||||
root := folderTree(t)
|
||||
out, err := exec.Command("du", "-x", "-B1", "--max-depth=2", root).Output()
|
||||
if err != nil {
|
||||
t.Skipf("not a du that takes -B1 --max-depth: %v", err)
|
||||
}
|
||||
du := map[string]int64{}
|
||||
for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
n, p, _ := strings.Cut(l, "\t")
|
||||
du[p], _ = strconv.ParseInt(n, 10, 64)
|
||||
}
|
||||
rep, err := readFolders(root, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep.TotalBytes != du[root] {
|
||||
t.Fatalf("total %d, du %d", rep.TotalBytes, du[root])
|
||||
}
|
||||
for _, f := range rep.Folders {
|
||||
if du[f.Path] != f.Bytes {
|
||||
t.Fatalf("%s: %d, du %d", f.Path, f.Bytes, du[f.Path])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A mount point under the path is not crossed and not stat'd, and is
|
||||
// reported against the folder at the asked depth that holds it.
|
||||
func TestObserveFoldersDoesNotCrossAMount(t *testing.T) {
|
||||
root := folderTree(t)
|
||||
var st syscall.Stat_t
|
||||
if err := syscall.Lstat(root, &st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
walk := func(depth int, mounts ...string) *folderWalk {
|
||||
w := &folderWalk{ctx: context.Background(), root: root, depth: depth, dev: uint64(st.Dev),
|
||||
mounts: map[string]bool{}, linked: map[uint64]struct{}{}}
|
||||
for _, m := range mounts {
|
||||
w.mounts[m] = true
|
||||
}
|
||||
w.dir(root, 0, &st)
|
||||
return w
|
||||
}
|
||||
w := walk(2, root+"/a/b")
|
||||
for _, f := range w.top.sorted() {
|
||||
if f.Path == root+"/a/b" {
|
||||
t.Fatalf("a mount point was sized: %+v", f)
|
||||
}
|
||||
if f.Path == root+"/a" && f.Bytes != blocksOf(t, root+"/a")+blocksOf(t, root+"/a/a1") {
|
||||
t.Fatalf("what is under the mount was counted: %+v", f)
|
||||
}
|
||||
}
|
||||
if l := w.skips.list(); len(l) != 1 || l[0].Path != root+"/a/b" || l[0].Reason != reasonOtherFS || l[0].Count != 1 {
|
||||
t.Fatalf("%+v", l)
|
||||
}
|
||||
w = walk(1, root+"/a/b/c/d")
|
||||
if l := w.skips.list(); len(l) != 1 || l[0].Path != root+"/a" {
|
||||
t.Fatalf("a deep skip is named by its folder at the asked depth: %+v", l)
|
||||
}
|
||||
// A different device on a folder, as a mount made after the table was
|
||||
// read looks: not crossed either.
|
||||
w = &folderWalk{ctx: context.Background(), root: root, depth: 2, dev: uint64(st.Dev) + 1,
|
||||
mounts: map[string]bool{}, linked: map[uint64]struct{}{}}
|
||||
w.dir(root, 0, &st)
|
||||
if w.sized != 0 || w.skips.total != 3 {
|
||||
t.Fatalf("every subfolder is on another device here: sized %d skipped %d", w.sized, w.skips.total)
|
||||
}
|
||||
}
|
||||
|
||||
// A folder swapped for a link between the walk's stat and its open, or one
|
||||
// reached through a folder above it that was, is not read: the walk reads
|
||||
// only the folder it stat'd, so a link can never lead it elsewhere, onto
|
||||
// another filesystem or into /proc. Here elsewhere is on the same filesystem,
|
||||
// so the device alone would not tell; the inode does.
|
||||
func TestObserveFoldersReadsOnlyTheFolderItStatd(t *testing.T) {
|
||||
for _, tc := range []struct{ name, swap string }{
|
||||
{"the folder itself", "/a/b"},
|
||||
{"a folder above it", "/a"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := folderTree(t)
|
||||
elsewhere := t.TempDir()
|
||||
writeFile(t, elsewhere+"/b/elsewhere1", 4<<20)
|
||||
target := elsewhere
|
||||
if tc.swap == "/a/b" {
|
||||
target = elsewhere + "/b"
|
||||
}
|
||||
orig := folderBeforeOpen
|
||||
t.Cleanup(func() { folderBeforeOpen = orig })
|
||||
folderBeforeOpen = func(p string) {
|
||||
if p != root+"/a/b" {
|
||||
return
|
||||
}
|
||||
if err := os.Rename(root+tc.swap, root+tc.swap+".moved"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := os.Symlink(target, root+tc.swap); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
rep, err := readFolders(root, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep.TotalBytes >= 4<<20 {
|
||||
t.Fatalf("the walk read a folder it never stat'd: total %d, %+v", rep.TotalBytes, rep.Folders)
|
||||
}
|
||||
for _, f := range rep.Folders {
|
||||
if f.Bytes >= 4<<20 {
|
||||
t.Fatalf("the walk read a folder it never stat'd: %+v", f)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, s := range rep.Skipped {
|
||||
found = found || (s.Path == root+"/a/b" && s.Reason == reasonChanged)
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("the swapped folder is not said: %+v", rep.Skipped)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A walk inside a read that never returns (a hard network mount that went
|
||||
// away) keeps its thread however long that read hangs. The op answers at the
|
||||
// budget either way, and past folderStuckMax such walks it starts no more, so
|
||||
// a Host asking again and again cannot pile threads up until the bastion can
|
||||
// start none. A walk that comes back frees its place.
|
||||
func TestObserveFoldersStartsNoWalkPastTheStuckOnes(t *testing.T) {
|
||||
origSizer, origGrace, origBudget := folderSizer, folderStuckGrace, observeOpBudget
|
||||
release := make(chan struct{})
|
||||
var started atomic.Int32
|
||||
folderSizer = func(context.Context, string, int) (foldersReport, error) {
|
||||
started.Add(1)
|
||||
<-release
|
||||
return foldersReport{}, nil
|
||||
}
|
||||
folderStuckGrace, observeOpBudget = time.Millisecond, time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
folderSizer, folderStuckGrace, observeOpBudget = origSizer, origGrace, origBudget
|
||||
})
|
||||
for i := 0; i < folderStuckMax+3; i++ {
|
||||
rep, err := readFolders("/srv", 1)
|
||||
if err != nil || rep.Partial == "" || len(rep.Folders) != 0 {
|
||||
t.Fatalf("call %d: %v %+v", i, err, rep)
|
||||
}
|
||||
}
|
||||
if n := started.Load(); n != folderStuckMax {
|
||||
t.Fatalf("%d walks started behind reads that never returned; at most %d may", n, folderStuckMax)
|
||||
}
|
||||
close(release)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for folderWalksStuck.Load() != 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if n := folderWalksStuck.Load(); n != 0 {
|
||||
t.Fatalf("walks that came back still hold %d places", n)
|
||||
}
|
||||
folderSizer = func(context.Context, string, int) (foldersReport, error) {
|
||||
started.Add(1)
|
||||
return foldersReport{Supported: true, Complete: true}, nil
|
||||
}
|
||||
observeOpBudget = time.Second
|
||||
if rep, err := readFolders("/srv", 1); err != nil || !rep.Complete {
|
||||
t.Fatalf("a walk once the stuck ones came back: %v %+v", err, rep)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveFoldersRefusalsAndBudget(t *testing.T) {
|
||||
root := folderTree(t)
|
||||
for p, want := range map[string]string{
|
||||
root + "/a/a1": "not a folder",
|
||||
root + "/missing": "no folder at that path",
|
||||
root + "/a/missing": "no folder at that path",
|
||||
} {
|
||||
if _, err := readFolders(p, 2); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("%s: %v", p, err)
|
||||
}
|
||||
}
|
||||
// A link into /proc is refused like /proc.
|
||||
link := filepath.Join(t.TempDir(), "into-proc")
|
||||
if err := os.Symlink("/proc/self", link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readFolders(link, 1); err == nil || err.Error() != errFolderKernelTree {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
// Through the dispatcher: refused at exit 2 without the path.
|
||||
_, errOut, code := driveObserve(t, "observe:folders", []string{root + "/a/a1"})
|
||||
if code != 2 || !strings.Contains(errOut, "not a folder") || strings.Contains(errOut, root) {
|
||||
t.Fatalf("code=%d err=%q", code, errOut)
|
||||
}
|
||||
// An unreadable folder is named in skipped. Root reads every folder, so
|
||||
// this half runs as a user only.
|
||||
if os.Geteuid() != 0 {
|
||||
if err := os.Chmod(root+"/a/b", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Chmod(root+"/a/b", 0o755)
|
||||
rep, err := readFolders(root, 1)
|
||||
if err != nil || len(rep.Skipped) != 1 || rep.Skipped[0].Path != root+"/a" || rep.Skipped[0].Reason != "permission denied" {
|
||||
t.Fatalf("%v %+v", err, rep.Skipped)
|
||||
}
|
||||
_ = os.Chmod(root+"/a/b", 0o755)
|
||||
}
|
||||
// Out of budget: an answer, marked incomplete, never a hang.
|
||||
orig := observeOpBudget
|
||||
observeOpBudget = time.Nanosecond
|
||||
defer func() { observeOpBudget = orig }()
|
||||
rep, err := readFolders(root, 2)
|
||||
if err != nil || rep.Complete || rep.Partial != "timeout" || rep.Note == "" {
|
||||
t.Fatalf("%v %+v", err, rep)
|
||||
}
|
||||
}
|
||||
|
||||
// Through the dispatcher, the answer is the report.
|
||||
func TestObserveFoldersThroughTheLane(t *testing.T) {
|
||||
root := folderTree(t)
|
||||
out, errOut, code := driveObserve(t, "observe:folders", []string{root, "1"})
|
||||
if code != 0 {
|
||||
t.Fatalf("exit %d: %s", code, errOut)
|
||||
}
|
||||
var rep foldersReport
|
||||
if err := json.Unmarshal([]byte(out), &rep); err != nil {
|
||||
t.Fatalf("%v: %s", err, out)
|
||||
}
|
||||
if !rep.Supported || rep.Depth != 1 || rep.Sized != 3 || len(rep.Folders) != 3 || !rep.Complete || rep.Skipped == nil {
|
||||
t.Fatalf("%s", out)
|
||||
}
|
||||
for _, f := range rep.Folders {
|
||||
if f.Depth != 1 {
|
||||
t.Fatalf("depth 1 names only the folders directly under the path: %+v", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The four against this machine, through the real dispatcher with the real
|
||||
// programs — the same switch as TestObserveLive. A machine without systemd
|
||||
// answers journal and logins with a reason, which is an answer.
|
||||
func TestObserveReadsLive(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("runs the real programs")
|
||||
}
|
||||
cert := observeTestCert(t, "box-a.example", []string{"box-a.example"}, nil, time.Now().Add(48*time.Hour))
|
||||
port := strconv.Itoa(observeTLSListener(t, cert))
|
||||
for _, c := range []struct {
|
||||
op string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"observe:folders", []string{"/etc", "1"}, `"supported":true`},
|
||||
{"observe:journal", []string{"systemd-journald", "5"}, `"source":"journal"`},
|
||||
{"observe:logins", []string{"1"}, `"hours":1`},
|
||||
{"observe:cert", []string{port, "box-a.example"}, `"connected_to":"127.0.0.1:` + port + `"`},
|
||||
} {
|
||||
// A machine without ss cannot say which ports it listens on, and the
|
||||
// cert read answers that as its own state (CI's container has no ss).
|
||||
if c.op == "observe:cert" {
|
||||
if _, err := exec.LookPath("ss"); err != nil {
|
||||
c.want = `"reason":"ss not installed`
|
||||
}
|
||||
}
|
||||
out, errOut, code := driveObserve(t, c.op, c.args)
|
||||
if code != 0 || !json.Valid([]byte(out)) || !strings.Contains(out, c.want) {
|
||||
t.Fatalf("%s: exit %d %s %s", c.op, code, errOut, out)
|
||||
}
|
||||
if len(out) > 300 {
|
||||
out = out[:300] + "…"
|
||||
}
|
||||
t.Logf("%s: %s", c.op, strings.TrimSpace(out))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
// observe:folders, observe:journal, observe:logins and observe:cert off Linux:
|
||||
// the handlers check the arguments the same way and answer
|
||||
// {"unsupported": "<goos>"}, as observe_ops_other.go's ops do. These are
|
||||
// never reached (the handlers ask observeHostSupported first) and exist so
|
||||
// the package builds.
|
||||
|
||||
func journalAccess() string { return "" }
|
||||
func readFolders(string, int) (any, error) { return observeUnsupported(), nil }
|
||||
func readJournal(string, int) (any, error) { return observeUnsupported(), nil }
|
||||
func readLogins(int) any { return observeUnsupported() }
|
||||
func readCert(int, string) (any, error) { return observeUnsupported(), nil }
|
||||
@@ -0,0 +1,625 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── fixtures ─────────────────────────────────────────────────────────────────
|
||||
// Invented, in the shapes the programs print: systemctl's --plain lists,
|
||||
// journalctl -o short-iso, /proc/self/mountinfo.
|
||||
|
||||
const fxListUnits = `example.service loaded active running Example service
|
||||
backup-nightly.service loaded inactive dead Nightly backup
|
||||
getty@tty1.service loaded active running Getty on tty1
|
||||
gone.service not-found inactive dead gone.service
|
||||
dev-disk-by\x2dlabel-data.device loaded active plugged /dev/disk/by-label/data
|
||||
`
|
||||
|
||||
const fxUnitFiles = `example.service enabled enabled
|
||||
report.service static -
|
||||
worker@.service static -
|
||||
ssh.socket enabled enabled
|
||||
`
|
||||
|
||||
// One connection per pid. The spoof line names an address inside the user
|
||||
// name; the last line's user name is a sentence; one line uses the older
|
||||
// offset format; the indented one is a continuation, not an entry.
|
||||
const fxSSHD = `2026-10-29T10:00:01+00:00 box-a sshd-session[1001]: Invalid user admin from 203.0.113.5 port 40001
|
||||
2026-10-29T10:00:02+00:00 box-a sshd-session[1001]: Failed password for invalid user admin from 203.0.113.5 port 40001 ssh2
|
||||
2026-10-29T10:00:03+00:00 box-a sshd-session[1001]: Failed password for invalid user admin from 203.0.113.5 port 40001 ssh2
|
||||
2026-10-29T10:00:04+00:00 box-a sshd-session[1001]: Connection closed by invalid user admin 203.0.113.5 port 40001 [preauth]
|
||||
2026-10-29T10:01:00+00:00 box-a sshd-session[1002]: Invalid user test from 203.0.113.6 port 40002
|
||||
2026-10-29T10:01:01+00:00 box-a sshd-session[1002]: Connection closed by invalid user test 203.0.113.6 port 40002 [preauth]
|
||||
2026-10-29T10:02:00+00:00 box-a sshd-session[1003]: Failed password for root from 203.0.113.5 port 40003 ssh2
|
||||
2026-10-29T10:02:01+00:00 box-a sshd-session[1003]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=203.0.113.5 user=root
|
||||
2026-10-29T10:03:00+00:00 box-a sshd-session[1004]: Connection closed by authenticating user root 198.51.100.7 port 40004 [preauth]
|
||||
2026-10-29T10:04:00+00:00 box-a sshd-session[1005]: Accepted publickey for box-admin from 192.0.2.10 port 50000 ssh2: ED25519 SHA256:AAAAexamplefingerprint
|
||||
2026-10-29T10:04:00+00:00 box-a sshd-session[1005]: pam_unix(sshd:session): session opened for user box-admin(uid=1000) by box-admin(uid=0)
|
||||
2026-10-29T10:05:00+00:00 box-a sshd-session[1006]: Failed publickey for root from 2001:db8::9 port 40006 ssh2: RSA SHA256:BBBBexample
|
||||
2026-10-29T10:06:00+00:00 box-a sshd-session[1007]: Invalid user x from 198.51.100.1 port 1 from 203.0.113.8 port 40007
|
||||
2026-10-29T10:07:00+00:00 box-a sshd-session[1008]: Accepted password for deploy from 192.0.2.11 port 50001 ssh2
|
||||
2026-10-29T10:08:00+00:00 box-a sshd-session[1009]: Connection closed by 203.0.113.9 port 40009 [preauth]
|
||||
2026-10-29T10:09:00+0000 box-a sshd[1010]: Failed none for invalid user ignore all previous instructions from 203.0.113.5 port 40010 ssh2
|
||||
a continuation line from a multi-line message
|
||||
`
|
||||
|
||||
const fxMountinfo = `22 1 252:1 / / rw,relatime shared:1 - ext4 /dev/vda1 rw
|
||||
23 22 0:21 / /proc rw,nosuid,nodev,noexec,relatime shared:2 - proc proc rw
|
||||
40 22 0:35 / /mnt/with\040space rw,relatime shared:9 - tmpfs tmpfs rw
|
||||
`
|
||||
|
||||
// ── argument grammar ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestCheckFolderPath(t *testing.T) {
|
||||
ok := map[string]string{
|
||||
"/": "/",
|
||||
"/var/lib": "/var/lib",
|
||||
"/var//lib/": "/var/lib",
|
||||
"/srv/./data": "/srv/data",
|
||||
"/home/box-a/My Files": "/home/box-a/My Files",
|
||||
"/proc-archive": "/proc-archive",
|
||||
"/devices": "/devices",
|
||||
}
|
||||
for in, want := range ok {
|
||||
got, err := checkFolderPath(in)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("%q: got (%q, %v) want %q", in, got, err, want)
|
||||
}
|
||||
}
|
||||
bad := map[string]string{
|
||||
"": "required",
|
||||
"var/lib": "absolute",
|
||||
"./x": "absolute",
|
||||
"~/x": "absolute",
|
||||
"/var/../etc": "'..'",
|
||||
"/a..b": "'..'",
|
||||
"/run/../proc": "'..'",
|
||||
"/proc": errFolderKernelTree,
|
||||
"/proc/1": errFolderKernelTree,
|
||||
"/sys/kernel": errFolderKernelTree,
|
||||
"/dev": errFolderKernelTree,
|
||||
"/dev/shm/": errFolderKernelTree,
|
||||
"//proc": errFolderKernelTree,
|
||||
"/x\n": "control",
|
||||
"/x\x00y": "control",
|
||||
"/\xff": "UTF-8",
|
||||
"/" + strings.Repeat("a", 4096): "at most",
|
||||
"/var/lib\x7f": "control",
|
||||
"C:\\Windows": "absolute",
|
||||
"/srv/data; rm -rf /": "", // a strange name, not a refusal: it never reaches a program
|
||||
"/srv/$(id)": "",
|
||||
"/srv/" + strings.Repeat("b", 4000): "",
|
||||
}
|
||||
for in, want := range bad {
|
||||
_, err := checkFolderPath(in)
|
||||
if want == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("%q must be accepted (the path goes to a Go walk, never a program): %v", in, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("%q: want a refusal saying %q, got %v", in, want, err)
|
||||
}
|
||||
if want != errFolderKernelTree && len(in) > 1 && strings.Contains(err.Error(), in) {
|
||||
t.Fatalf("%q: a refusal must not repeat the value: %v", in, err)
|
||||
}
|
||||
}
|
||||
if _, d, err := parseFolderArgs([]string{"/srv"}); err != nil || d != folderDepthDefault {
|
||||
t.Fatalf("default depth: %d %v", d, err)
|
||||
}
|
||||
for _, d := range []string{"0", "4", "-1", "x", "2.5", ""} {
|
||||
if _, _, err := parseFolderArgs([]string{"/srv", d}); err == nil {
|
||||
t.Fatalf("depth %q must be refused", d)
|
||||
}
|
||||
}
|
||||
if _, d, err := parseFolderArgs([]string{"/srv", "3"}); err != nil || d != 3 {
|
||||
t.Fatalf("depth 3: %d %v", d, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeUnitName(t *testing.T) {
|
||||
ok := map[string]string{
|
||||
"example": "example.service",
|
||||
"example.service": "example.service",
|
||||
"getty@tty1": "getty@tty1.service",
|
||||
"foo.bar": "foo.bar.service",
|
||||
"data.mount": "data.mount",
|
||||
"backup.timer": "backup.timer",
|
||||
`dev-disk-by\x2dlabel-data.device`: `dev-disk-by\x2dlabel-data.device`,
|
||||
"user-1000.slice": "user-1000.slice",
|
||||
"session-4.scope": "session-4.scope",
|
||||
"systemd-fsck@dev-vda1.service": "systemd-fsck@dev-vda1.service",
|
||||
"a:b_c": "a:b_c.service",
|
||||
strings.Repeat("a", 255-len(".mount")) + ".mount": strings.Repeat("a", 255-len(".mount")) + ".mount",
|
||||
}
|
||||
for in, want := range ok {
|
||||
got, err := normalizeUnitName(in)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("%q: got (%q, %v) want %q", in, got, err, want)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"", "-.mount", ".service", "@x.service", "foo bar", "foo;id", "../x", "a/b",
|
||||
"worker@.service", "worker@", "a@b@c.service", "$(id)", "--output=/tmp/x", "-u", "ünit",
|
||||
strings.Repeat("a", 250), "x\n", "*.service", "ssh?", "[a].service", "a'b", `a"b`, "a|b"} {
|
||||
if got, err := normalizeUnitName(in); err == nil {
|
||||
t.Fatalf("%q must be refused, got %q", in, got)
|
||||
}
|
||||
}
|
||||
if _, m, err := parseJournalArgs([]string{"example"}); err != nil || m != observeLogMinutesDefault {
|
||||
t.Fatalf("default window: %d %v", m, err)
|
||||
}
|
||||
for _, m := range []string{"0", "61", "x", "-5"} {
|
||||
if _, _, err := parseJournalArgs([]string{"example", m}); err == nil {
|
||||
t.Fatalf("minutes %q must be refused", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLoginsArgsAndCertArgs(t *testing.T) {
|
||||
if h, err := parseLoginsArgs(nil); err != nil || h != loginsHoursDefault {
|
||||
t.Fatalf("default hours: %d %v", h, err)
|
||||
}
|
||||
if h, err := parseLoginsArgs([]string{"168"}); err != nil || h != 168 {
|
||||
t.Fatalf("168: %d %v", h, err)
|
||||
}
|
||||
for _, h := range []string{"0", "169", "x", "24h", "-1", ""} {
|
||||
if _, err := parseLoginsArgs([]string{h}); err == nil {
|
||||
t.Fatalf("hours %q must be refused", h)
|
||||
}
|
||||
}
|
||||
if p, n, err := parseCertArgs([]string{"443"}); err != nil || p != 443 || n != "" {
|
||||
t.Fatalf("port alone: %d %q %v", p, n, err)
|
||||
}
|
||||
if p, n, err := parseCertArgs([]string{"8443", "Box-A.Example"}); err != nil || p != 8443 || n != "box-a.example" {
|
||||
t.Fatalf("port and name: %d %q %v", p, n, err)
|
||||
}
|
||||
if _, n, err := parseCertArgs([]string{"443", "192.0.2.10"}); err != nil || n != "192.0.2.10" {
|
||||
t.Fatalf("an IPv4 literal is a name a certificate can carry: %q %v", n, err)
|
||||
}
|
||||
for _, args := range [][]string{{"0"}, {"65536"}, {"https"}, {"443", ""}, {"443", "-x.example"},
|
||||
{"443", ".example"}, {"443", "example."}, {"443", "a..b"}, {"443", "a b"}, {"443", "a_b.example"},
|
||||
{"443", "*.example"}, {"443", "[::1]"}, {"443", strings.Repeat("a", 64) + ".example"},
|
||||
{"443", "a-.example"}, {"443", "a;id"}} {
|
||||
if _, _, err := parseCertArgs(args); err == nil {
|
||||
t.Fatalf("%q must be refused", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── observe:folders helpers ──────────────────────────────────────────────────
|
||||
|
||||
func TestFolderAtFoldsToTheAskedDepth(t *testing.T) {
|
||||
cases := []struct {
|
||||
root, p string
|
||||
depth int
|
||||
want string
|
||||
}{
|
||||
{"/", "/", 2, "/"},
|
||||
{"/", "/a", 2, "/a"},
|
||||
{"/", "/a/b", 2, "/a/b"},
|
||||
{"/", "/a/b/c/d", 2, "/a/b"},
|
||||
{"/srv", "/srv", 1, "/srv"},
|
||||
{"/srv", "/srv/x", 1, "/srv/x"},
|
||||
{"/srv", "/srv/x/y/z", 1, "/srv/x"},
|
||||
{"/srv", "/srv/x/y/z", 3, "/srv/x/y/z"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := folderAt(c.root, c.p, c.depth); got != c.want {
|
||||
t.Fatalf("folderAt(%q, %q, %d) = %q, want %q", c.root, c.p, c.depth, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderTopKeepsTheLargestInOrder(t *testing.T) {
|
||||
var top folderTop
|
||||
for i := 0; i < 40; i++ {
|
||||
top.offer(folderSize{Path: "/f" + strconv.Itoa(i), Bytes: int64(i % 30), Depth: 1})
|
||||
}
|
||||
got := top.sorted()
|
||||
if len(got) != folderTopN {
|
||||
t.Fatalf("want %d rows, got %d", folderTopN, len(got))
|
||||
}
|
||||
for i := 1; i < len(got); i++ {
|
||||
if got[i].Bytes > got[i-1].Bytes || (got[i].Bytes == got[i-1].Bytes && got[i].Path < got[i-1].Path) {
|
||||
t.Fatalf("not largest first, ties in path order: %+v", got)
|
||||
}
|
||||
}
|
||||
// Sizes 0-29 and 0-9 again: the 25 largest are 29 down to 10, both 9s,
|
||||
// both 8s and one 7, the 7 whose path sorts first.
|
||||
if got[0].Bytes != 29 || got[len(got)-1] != (folderSize{Path: "/f37", Bytes: 7, Depth: 1}) {
|
||||
t.Fatalf("the largest were not the ones kept: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderSkipsFoldAndBound(t *testing.T) {
|
||||
var s folderSkips
|
||||
for i := 0; i < 3; i++ {
|
||||
s.add("/var/lib", "permission denied")
|
||||
}
|
||||
s.add("/var/lib", reasonOtherFS)
|
||||
for i := 0; i < folderSkipKeys+50; i++ {
|
||||
s.add("/x"+strconv.Itoa(i), "permission denied")
|
||||
}
|
||||
l := s.list()
|
||||
if len(l) != folderSkipCap || s.total != 4+folderSkipKeys+50 {
|
||||
t.Fatalf("list %d total %d", len(l), s.total)
|
||||
}
|
||||
if l[0].Path != "/var/lib" || l[0].Count != 3 || l[0].Reason != "permission denied" {
|
||||
t.Fatalf("most first: %+v", l[0])
|
||||
}
|
||||
if len(s.by) > folderSkipKeys {
|
||||
t.Fatalf("the fold map grew past its bound: %d", len(s.by))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMountPoints(t *testing.T) {
|
||||
m := parseMountPoints(fxMountinfo)
|
||||
for _, p := range []string{"/", "/proc", "/mnt/with space"} {
|
||||
if !m[p] {
|
||||
t.Fatalf("%q missing: %v", p, m)
|
||||
}
|
||||
}
|
||||
if len(m) != 3 {
|
||||
t.Fatalf("%v", m)
|
||||
}
|
||||
if got := unescapeMountField(`/a\134b\011c\012`); got != "/a\\b\tc\n" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
if got := unescapeMountField(`/a\9`); got != `/a\9` {
|
||||
t.Fatalf("a short escape is left as written: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── observe:journal helpers ──────────────────────────────────────────────────
|
||||
|
||||
func TestUnitKnownReadsBothLists(t *testing.T) {
|
||||
loaded, files := parseUnitList(fxListUnits), parseUnitFiles(fxUnitFiles)
|
||||
for _, u := range []string{"example.service", "backup-nightly.service", "report.service", "getty@tty1.service",
|
||||
"worker@3.service", `dev-disk-by\x2dlabel-data.device`, "ssh.socket"} {
|
||||
if !unitKnown(u, loaded, files) {
|
||||
t.Fatalf("%s is on this machine", u)
|
||||
}
|
||||
}
|
||||
for _, u := range []string{"gone.service", "nope.service", "getty@tty2.service", "worker.service", "example.socket"} {
|
||||
if unitKnown(u, loaded, files) {
|
||||
t.Fatalf("%s is not on this machine", u)
|
||||
}
|
||||
}
|
||||
// The bullet a systemd might print in front of a failed unit is skipped.
|
||||
if l := parseUnitList("● bad.service loaded failed failed Bad\n"); l["bad.service"] != "loaded" {
|
||||
t.Fatalf("%v", l)
|
||||
}
|
||||
err := errUnknownUnit("ssh.service", loaded, files)
|
||||
if !strings.Contains(err.Error(), "no unit ssh.service") || !strings.Contains(err.Error(), "ssh.socket") {
|
||||
t.Fatalf("the refusal names the unit and the near ones: %v", err)
|
||||
}
|
||||
if s := unitSuggestions("gone.service", loaded, files); len(s) != 0 {
|
||||
t.Fatalf("a not-found unit is never suggested: %v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// ── observe:logins parser ────────────────────────────────────────────────────
|
||||
|
||||
func TestParseSSHLogins(t *testing.T) {
|
||||
tally := parseSSHLogins(fxSSHD)
|
||||
var rep loginsReport
|
||||
tally.fill(&rep)
|
||||
if rep.EntriesRead != 16 {
|
||||
t.Fatalf("entries: %d", rep.EntriesRead)
|
||||
}
|
||||
if rep.OldestEntry != "2026-10-29T10:00:01Z" {
|
||||
t.Fatalf("oldest: %q", rep.OldestEntry)
|
||||
}
|
||||
// 2 Failed lines on 1001, 1 knock on 1002, 1 Failed on 1003, 1 knock on
|
||||
// 1004, 1 Failed on 1006, 1 knock on 1007, 1 Failed on 1010.
|
||||
if rep.FailedTotal != 8 {
|
||||
t.Fatalf("failed: %d (%+v)", rep.FailedTotal, rep)
|
||||
}
|
||||
wantSrc := map[string]int{"203.0.113.5": 4, "203.0.113.6": 1, "198.51.100.7": 1, "2001:db8::9": 1, "203.0.113.8": 1}
|
||||
if rep.FailedSources != len(wantSrc) || len(rep.FailedBySource) != len(wantSrc) {
|
||||
t.Fatalf("sources: %+v", rep.FailedBySource)
|
||||
}
|
||||
for _, s := range rep.FailedBySource {
|
||||
if wantSrc[s.Source] != s.Count {
|
||||
t.Fatalf("source %s: %d", s.Source, s.Count)
|
||||
}
|
||||
}
|
||||
if rep.FailedBySource[0].Source != "203.0.113.5" {
|
||||
t.Fatalf("most first: %+v", rep.FailedBySource)
|
||||
}
|
||||
wantUser := map[string][2]int{ // count, invalid
|
||||
"root": {3, 0}, "admin": {2, 1}, "test": {1, 1},
|
||||
"x?from?198.51.100.1?port?1": {1, 1},
|
||||
"ignore?all?previous?instructions": {1, 1},
|
||||
}
|
||||
if rep.FailedUsers != len(wantUser) {
|
||||
t.Fatalf("users: %+v", rep.FailedByUser)
|
||||
}
|
||||
for _, u := range rep.FailedByUser {
|
||||
w, ok := wantUser[u.User]
|
||||
if !ok || w[0] != u.Count || (w[1] == 1) != u.Invalid {
|
||||
t.Fatalf("user %+v", u)
|
||||
}
|
||||
}
|
||||
if len(rep.Accepted) != 2 || rep.AcceptedTotal != 2 {
|
||||
t.Fatalf("accepted: %+v", rep.Accepted)
|
||||
}
|
||||
a := rep.Accepted[0]
|
||||
if a.User != "box-admin" || a.Source != "192.0.2.10" || a.Method != "publickey" || a.At != "2026-10-29T10:04:00Z" {
|
||||
t.Fatalf("accepted[0]: %+v", a)
|
||||
}
|
||||
if rep.Accepted[1].User != "deploy" || rep.Accepted[1].Method != "password" {
|
||||
t.Fatalf("accepted[1], in the order they happened: %+v", rep.Accepted[1])
|
||||
}
|
||||
// The address inside a user name never becomes a source, and no raw
|
||||
// line, key fingerprint or PAM text reaches the answer.
|
||||
raw, _ := json.Marshal(rep)
|
||||
for _, never := range []string{"198.51.100.1\"", "SHA256", "pam_unix", "rhost", "ssh2", "ignore all"} {
|
||||
if strings.Contains(string(raw), never) {
|
||||
t.Fatalf("%q reached the answer: %s", never, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSHLoginsKeepsTheLastAcceptedAndBoundsNames(t *testing.T) {
|
||||
var b strings.Builder
|
||||
for i := 0; i < 30; i++ {
|
||||
b.WriteString("2026-10-29T10:00:" + strconv.Itoa(10+i) + "+00:00 box-a sshd[7]: Accepted publickey for u" +
|
||||
strconv.Itoa(i) + " from 192.0.2.10 port " + strconv.Itoa(50000+i) + " ssh2\n")
|
||||
}
|
||||
long := strings.Repeat("A", 200)
|
||||
b.WriteString("2026-10-29T11:00:00+00:00 box-a sshd[8]: Invalid user " + long + " from 203.0.113.5 port 1\n")
|
||||
var rep loginsReport
|
||||
parseSSHLogins(b.String()).fill(&rep)
|
||||
if rep.AcceptedTotal != 30 || len(rep.Accepted) != loginsTopN || rep.Accepted[0].User != "u10" ||
|
||||
rep.Accepted[loginsTopN-1].User != "u29" {
|
||||
t.Fatalf("the last %d, oldest first: %+v", loginsTopN, rep.Accepted)
|
||||
}
|
||||
if len(rep.FailedByUser) != 1 || len(rep.FailedByUser[0].User) != loginNameMax {
|
||||
t.Fatalf("a name is cut at %d: %+v", loginNameMax, rep.FailedByUser)
|
||||
}
|
||||
for _, src := range []string{"not an address!", "a/b", "$(id)"} {
|
||||
if _, ok := loginSourceOf(src); ok {
|
||||
t.Fatalf("%q is not a source", src)
|
||||
}
|
||||
}
|
||||
if loginMethod("keyboard-interactive/pam") != "keyboard-interactive/pam" || loginMethod("x y") != "other" {
|
||||
t.Fatalf("a method keeps sshd's own shape and nothing else")
|
||||
}
|
||||
if s, ok := loginSourceOf("::ffff:203.0.113.5"); !ok || s != "203.0.113.5" {
|
||||
t.Fatalf("a mapped address reads as v4: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// ── observe:cert helpers ─────────────────────────────────────────────────────
|
||||
|
||||
func TestSSLocalAddr(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
addr string
|
||||
wild, ok bool
|
||||
}{
|
||||
"*": {"", true, true},
|
||||
"0.0.0.0": {"0.0.0.0", true, true},
|
||||
"[::]": {"::", true, true},
|
||||
"127.0.0.1": {"127.0.0.1", false, true},
|
||||
"[::1]": {"::1", false, true},
|
||||
"127.0.0.53%lo": {"127.0.0.53", false, true},
|
||||
"[fe80::1]%eth0": {"fe80::1%eth0", false, true},
|
||||
"10.99.0.1": {"10.99.0.1", false, true},
|
||||
"not-an-address": {"", false, false},
|
||||
}
|
||||
for in, want := range cases {
|
||||
a, wild, ok := ssLocalAddr(in)
|
||||
if ok != want.ok || wild != want.wild || (want.addr != "" && a.String() != want.addr) {
|
||||
t.Fatalf("%q: got (%v, %v, %v) want %+v", in, a, wild, ok, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertTargetsAreThisMachinesOwn(t *testing.T) {
|
||||
socks := []listenSocket{
|
||||
{Proto: "tcp", Local: "0.0.0.0", Port: 443},
|
||||
{Proto: "tcp", Local: "10.99.0.1", Port: 8443},
|
||||
{Proto: "tcp", Local: "192.0.2.50", Port: 8443}, // bound, but not an address this machine holds
|
||||
{Proto: "tcp", Local: "[::1]", Port: 8443},
|
||||
{Proto: "tcp", Local: "127.0.0.53%lo", Port: 8443},
|
||||
{Proto: "udp", Local: "10.99.0.2", Port: 8443},
|
||||
}
|
||||
own := map[netip.Addr]bool{netip.MustParseAddr("10.99.0.1"): true, netip.MustParseAddr("10.99.0.2"): true}
|
||||
targets, listening := certTargets(socks, 8443, own)
|
||||
want := []string{"127.0.0.1:8443", "[::1]:8443", "10.99.0.1:8443", "127.0.0.53:8443"}
|
||||
if strings.Join(targets, " ") != strings.Join(want, " ") {
|
||||
t.Fatalf("targets %v want %v", targets, want)
|
||||
}
|
||||
if strings.Join(listening, " ") != "10.99.0.1 192.0.2.50 [::1] 127.0.0.53%lo" {
|
||||
t.Fatalf("listening %v", listening)
|
||||
}
|
||||
targets, listening = certTargets(socks, 443, own)
|
||||
if len(targets) != 2 || len(listening) != 1 {
|
||||
t.Fatalf("a wildcard listener is reached on loopback: %v %v", targets, listening)
|
||||
}
|
||||
if _, listening = certTargets(socks, 22, own); len(listening) != 0 {
|
||||
t.Fatalf("nothing on 22: %v", listening)
|
||||
}
|
||||
}
|
||||
|
||||
// observeTestCert makes a self-signed certificate for the names given.
|
||||
func observeTestCert(t *testing.T, cn string, dns []string, ips []net.IP, notAfter time.Time) tls.Certificate {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpl := &x509.Certificate{SerialNumber: big.NewInt(7), Subject: pkix.Name{CommonName: cn, Organization: []string{"Example"}},
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: notAfter, DNSNames: dns, IPAddresses: ips,
|
||||
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
|
||||
}
|
||||
|
||||
// observeTLSListener serves cert on 127.0.0.1 until the test ends.
|
||||
func observeTLSListener(t *testing.T, cert tls.Certificate) int {
|
||||
t.Helper()
|
||||
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
_ = c.(*tls.Conn).Handshake()
|
||||
_ = c.Close()
|
||||
}()
|
||||
}
|
||||
}()
|
||||
return ln.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// closedPort is a loopback port nothing listens on.
|
||||
func closedPort(t *testing.T) int {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := ln.Addr().(*net.TCPAddr).Port
|
||||
_ = ln.Close()
|
||||
return p
|
||||
}
|
||||
|
||||
func TestDialCertReadsTheFirstListenerThatAnswers(t *testing.T) {
|
||||
cert := observeTestCert(t, "box-a.example", []string{"box-a.example", "www.box-a.example"},
|
||||
[]net.IP{net.ParseIP("127.0.0.1")}, time.Now().Add(30*24*time.Hour+time.Hour))
|
||||
port := observeTLSListener(t, cert)
|
||||
served := "127.0.0.1:" + strconv.Itoa(port)
|
||||
refused := "127.0.0.1:" + strconv.Itoa(closedPort(t))
|
||||
rep := certReport{Tried: []string{}}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
dialCert(ctx, &rep, []string{refused, served}, "box-a.example")
|
||||
if len(rep.Tried) != 1 || !strings.Contains(rep.Tried[0], "refused") || rep.ConnectedTo != served {
|
||||
t.Fatalf("tried %v, connected %q", rep.Tried, rep.ConnectedTo)
|
||||
}
|
||||
if !rep.TLS || rep.TLSVersion == "" || !strings.Contains(rep.Subject, "CN=box-a.example") ||
|
||||
!strings.Contains(rep.Issuer, "O=Example") {
|
||||
t.Fatalf("%+v", rep)
|
||||
}
|
||||
if strings.Join(rep.SANs, " ") != "box-a.example www.box-a.example 127.0.0.1" {
|
||||
t.Fatalf("sans %v", rep.SANs)
|
||||
}
|
||||
if rep.DaysLeft == nil || *rep.DaysLeft != 30 || rep.ChainLength != 1 {
|
||||
t.Fatalf("days %v chain %d", rep.DaysLeft, rep.ChainLength)
|
||||
}
|
||||
// Self-signed: read, and reported as not verifying, for the name asked.
|
||||
if rep.ChainVerified == nil || *rep.ChainVerified || rep.VerifyError == "" || rep.VerifiedFor != "box-a.example" {
|
||||
t.Fatalf("verification: %+v", rep)
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, rep.NotAfter); err != nil {
|
||||
t.Fatalf("not_after %q", rep.NotAfter)
|
||||
}
|
||||
|
||||
// A listener that is not TLS: connected, and the handshake's failure is
|
||||
// the answer.
|
||||
plain, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer plain.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, err := plain.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = c.Write([]byte("HTTP/1.1 400 Bad Request\r\n\r\n"))
|
||||
_ = c.Close()
|
||||
}
|
||||
}()
|
||||
rep = certReport{Tried: []string{}}
|
||||
dialCert(ctx, &rep, []string{plain.Addr().String(), served}, "")
|
||||
if rep.ConnectedTo != plain.Addr().String() || rep.TLS || rep.TLSError == "" || rep.Subject != "" {
|
||||
t.Fatalf("the first connection decides: %+v", rep)
|
||||
}
|
||||
}
|
||||
|
||||
// ── through the dispatcher, on every platform ────────────────────────────────
|
||||
|
||||
// A value that breaks the grammar is refused at exit 2 with the rule it
|
||||
// broke, and never repeated back; on every platform, before anything is read.
|
||||
func TestObserveReadsRefuseBadValuesWithTheRule(t *testing.T) {
|
||||
cases := []struct {
|
||||
op string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"observe:folders", []string{"etc/passwd-dir"}, "absolute"},
|
||||
{"observe:folders", []string{"/srv/../etc"}, "'..'"},
|
||||
{"observe:folders", []string{"/proc/1/root"}, "/proc, /sys and /dev"},
|
||||
{"observe:folders", []string{"/srv", "9"}, "depth must be between 1 and 3"},
|
||||
{"observe:journal", []string{"nginx;reboot"}, "unit name uses only"},
|
||||
{"observe:journal", []string{"-x.service"}, "cannot start with"},
|
||||
{"observe:journal", []string{"worker@.service"}, "template"},
|
||||
{"observe:journal", []string{"example", "600"}, "minutes must be between 1 and 60"},
|
||||
{"observe:logins", []string{"1000"}, "hours must be between 1 and 168"},
|
||||
{"observe:logins", []string{"a week"}, "hours must be a whole number"},
|
||||
{"observe:cert", []string{"99999"}, "port must be between 1 and 65535"},
|
||||
{"observe:cert", []string{"443", "box a"}, "server name uses"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
out, errOut, code := driveObserve(t, c.op, c.args)
|
||||
if code != 2 || out != "" || !strings.Contains(errOut, "refused: ") || !strings.Contains(errOut, c.want) {
|
||||
t.Fatalf("%s %q: code=%d out=%q err=%q (want %q)", c.op, c.args, code, out, errOut, c.want)
|
||||
}
|
||||
for _, a := range c.args {
|
||||
if len(a) > 3 && strings.Contains(errOut, a) {
|
||||
t.Fatalf("%s repeated the value %q: %q", c.op, a, errOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
for op, args := range map[string][]string{
|
||||
"observe:folders": nil, "observe:journal": {"a", "1", "x"}, "observe:logins": {"1", "2"}, "observe:cert": {},
|
||||
} {
|
||||
if _, errOut, code := driveObserve(t, op, args); code != 2 || !strings.Contains(errOut, observeArgRefusal[op]) {
|
||||
t.Fatalf("%s %q: code=%d err=%q", op, args, code, errOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Off Linux the four check their arguments and then say they cannot answer
|
||||
// here, at exit 0, in the shape observe:host uses.
|
||||
func TestObserveReadsUnsupportedOffLinux(t *testing.T) {
|
||||
if observeHostSupported {
|
||||
t.Skip("the Linux drivers answer here (observe_reads_linux_test.go)")
|
||||
}
|
||||
for op, args := range map[string][]string{
|
||||
"observe:folders": {"/"}, "observe:journal": {"example"}, "observe:logins": nil, "observe:cert": {"443"},
|
||||
} {
|
||||
out, errOut, code := driveObserve(t, op, args)
|
||||
if code != 0 || strings.TrimSpace(out) != `{"unsupported":"`+observeUnsupported()["unsupported"]+`"}` {
|
||||
t.Fatalf("%s: code=%d out=%q err=%q", op, code, out, errOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
+168
-42
@@ -9,7 +9,9 @@ package main
|
||||
// params (a typed schema), effects (the FILES it may touch),
|
||||
// time_limit, undo
|
||||
// run the program, run by one of a fixed set of interpreters with a
|
||||
// fixed argv and the params as JSON on stdin
|
||||
// fixed argv and the params as JSON on stdin; absent, and refused
|
||||
// if present, for an action op with interpreter: none, whose verbs
|
||||
// are its whole work (ops_action.go verbsOnly)
|
||||
// test/ what the host's build ran it against (not read here)
|
||||
//
|
||||
// Its identity is the sha256 of the directory's canonical form (opDirDigest):
|
||||
@@ -29,8 +31,11 @@ package main
|
||||
// one use. Purpose op_run|server|op|sha256|sha256(params)|expires
|
||||
// op_grant "From now on": this op, this machine, param BOUNDS, expires ≤
|
||||
// 30 d, a use count; privilege user only — root is never reusable.
|
||||
// Held in ops.json beside grants.json. Purpose
|
||||
// op_grant|server|op|sha256|sha256(bounds)|expires|uses
|
||||
// The bounds may also hold the run to a window and a rate, which
|
||||
// the machine reads on its own clock and from its own run log
|
||||
// (ops_bounds.go). Held in ops.json beside grants.json, with the
|
||||
// key that approved it and this machine's seal over the record.
|
||||
// Purpose op_grant|server|op|sha256|sha256(bounds)|expires|uses
|
||||
//
|
||||
// The executor (ops_exec_linux.go) runs the op inside an overlay sandbox and
|
||||
// diffs what it changed; a files op has FILE effects only, and an op whose
|
||||
@@ -98,6 +103,10 @@ var (
|
||||
opsMu sync.Mutex
|
||||
)
|
||||
|
||||
// opInterpreterNone is an action op with no program: no run file, nothing
|
||||
// executed but the bastion's own verbs (ops_action.go verbsOnly).
|
||||
const opInterpreterNone = "none"
|
||||
|
||||
// opInterpreters is the fixed set. The bastion execs `<interpreter> <run>
|
||||
// --mode <mode>`; nothing the host wrote chooses the program that runs.
|
||||
var opInterpreters = map[string]string{
|
||||
@@ -184,8 +193,14 @@ func parseOpManifest(raw []byte) (*opManifest, error) {
|
||||
if m.Privilege != "user" && m.Privilege != "root" {
|
||||
return nil, errors.New("op.yaml: privilege must be 'user' or 'root'")
|
||||
}
|
||||
if _, ok := opInterpreters[m.Interpreter]; !ok {
|
||||
return nil, fmt.Errorf("op.yaml: interpreter must be one of %s", strings.Join(opInterpreterNames(), ", "))
|
||||
if m.Interpreter == opInterpreterNone {
|
||||
// No program at all: an action op whose verbs are its whole work
|
||||
// (ops_action.go verbsOnly), judged with the action fields below.
|
||||
if m.Kind != opKindAction {
|
||||
return nil, errors.New("op.yaml: interpreter: none is an action op whose verbs are its whole work (kind: action)")
|
||||
}
|
||||
} else if _, ok := opInterpreters[m.Interpreter]; !ok {
|
||||
return nil, fmt.Errorf("op.yaml: interpreter must be one of %s, or none for an action op that is verbs only", strings.Join(opInterpreterNames(), ", "))
|
||||
}
|
||||
if m.TimeLimit <= 0 || time.Duration(m.TimeLimit)*time.Second > opTimeLimitMax {
|
||||
return nil, fmt.Errorf("op.yaml: time_limit must be 1..%d seconds", int(opTimeLimitMax.Seconds()))
|
||||
@@ -622,11 +637,21 @@ type opBound struct {
|
||||
Fixed *bool `json:"fixed,omitempty"` // boolean
|
||||
}
|
||||
|
||||
// parseBounds is the params half of an op_grant's bounds (parseGrantBounds,
|
||||
// ops_bounds.go, reads the whole: the params and the run's window and rate).
|
||||
func parseBounds(m *opManifest, raw string) (map[string]opBound, error) {
|
||||
b, err := parseGrantBounds(m, raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.Params, nil
|
||||
}
|
||||
|
||||
// Strict: a field the param's type does not read is refused, not ignored —
|
||||
// the card shows the bounds text as sent, so a bound the box would not
|
||||
// enforce would read as a limit the person never got. Each bound must sit
|
||||
// inside the schema's own.
|
||||
func parseBounds(m *opManifest, raw string) (map[string]opBound, error) {
|
||||
func parseParamBounds(m *opManifest, raw string) (map[string]opBound, error) {
|
||||
if len(raw) > opParamsMaxBytes {
|
||||
return nil, errors.New("bounds too large")
|
||||
}
|
||||
@@ -785,7 +810,8 @@ func opDirDigest(files []opFile) (string, error) {
|
||||
}
|
||||
|
||||
// opBundleOK checks the bundle's shape before anything is written: relative
|
||||
// clean paths, no links, op.yaml and run present, bounded size.
|
||||
// clean paths, no links, op.yaml present, bounded size. Whether it must carry
|
||||
// run is the manifest's to say (opBundleFitsManifest).
|
||||
func opBundleOK(files []opFile) error {
|
||||
total := 0
|
||||
have := map[string]bool{}
|
||||
@@ -810,7 +836,24 @@ func opBundleOK(files []opFile) error {
|
||||
}
|
||||
have[p] = true
|
||||
}
|
||||
if !have["op.yaml"] || !have["run"] {
|
||||
if !have["op.yaml"] {
|
||||
return errors.New("an op bundle needs op.yaml")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// opBundleFitsManifest: a program op ships its run; an op with no program
|
||||
// (interpreter: none) ships none — a run file there would be a program the
|
||||
// manifest says is not there.
|
||||
func opBundleFitsManifest(files []opFile, m *opManifest) error {
|
||||
hasRun := false
|
||||
for _, f := range files {
|
||||
hasRun = hasRun || f.Path == "run"
|
||||
}
|
||||
if m.verbsOnly() && hasRun {
|
||||
return errors.New("an op with interpreter: none ships no run: its verbs are its whole work")
|
||||
}
|
||||
if !m.verbsOnly() && !hasRun {
|
||||
return errors.New("an op bundle needs op.yaml and run")
|
||||
}
|
||||
return nil
|
||||
@@ -893,7 +936,14 @@ func opLoad(dcnDir, sha string) (*opManifest, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseOpManifest(raw)
|
||||
m, err := parseOpManifest(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := opBundleFitsManifest(files, m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ── the terms ───────────────────────────────────────────────────────────────
|
||||
@@ -1035,6 +1085,15 @@ type opGrantRec struct {
|
||||
InstalledAt string `json:"installed_at"`
|
||||
UsesLeft int `json:"uses_left"`
|
||||
Dropped string `json:"dropped,omitempty"` // when and why, kept for the record
|
||||
// Runs: when each apply that spent a use began (RFC3339Nano, oldest
|
||||
// first): the rate is counted from it, so it holds across a restart.
|
||||
Runs []string `json:"runs,omitempty"`
|
||||
// Approver: the key that approved the grant — "device:<public key>" or
|
||||
// "console:<factor fingerprint>" (ops_bounds.go). The grant dies with it.
|
||||
Approver string `json:"approver,omitempty"`
|
||||
// MAC: this machine's seal over the record (opGrantRecMAC). A record that
|
||||
// does not check out holds no grant.
|
||||
MAC string `json:"mac,omitempty"`
|
||||
}
|
||||
|
||||
type opsFile struct {
|
||||
@@ -1062,36 +1121,42 @@ func opsSave(dcnDir string, f *opsFile) error {
|
||||
}
|
||||
|
||||
// opsHeldGrant re-verifies a held grant record (a tampered ops.json holds
|
||||
// nothing) and returns its terms and uses left.
|
||||
// nothing): the terms under the pinned key, the record under this machine's
|
||||
// own seal with its run log in order, the approving key still pinned, a use
|
||||
// left. Returns the terms, the record and the run times. The caller holds
|
||||
// opsMu.
|
||||
func opsHeldGrant(dcnDir, nonce string) (*opTerms, *opGrantRec, error) {
|
||||
t, rec, _, err := opsHeldGrantRuns(dcnDir, nonce)
|
||||
return t, rec, err
|
||||
}
|
||||
|
||||
func opsHeldGrantRuns(dcnDir, nonce string) (*opTerms, *opGrantRec, []time.Time, error) {
|
||||
f := opsLoad(dcnDir)
|
||||
rec, ok := f.Grants[nonce]
|
||||
if !ok || rec.Dropped != "" {
|
||||
return nil, nil, errors.New("no such op grant is held here")
|
||||
return nil, nil, nil, errors.New("no such op grant is held here")
|
||||
}
|
||||
t, err := verifyOpTerms(dcnDir, rec.TermsCanonical, rec.Signature, opKindGrant)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("held op grant does not verify: %v", err)
|
||||
return nil, nil, nil, fmt.Errorf("held op grant does not verify: %v", err)
|
||||
}
|
||||
runs, err := opGrantRecCheck(nonce, &rec, t, opNow())
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if !opApproverLive(dcnDir, rec.Approver) {
|
||||
return nil, nil, nil, fmt.Errorf("the key that approved this grant (%s) is no longer pinned on this machine; the grant is dead", opApproverWords(rec.Approver))
|
||||
}
|
||||
if rec.UsesLeft <= 0 {
|
||||
return nil, nil, errors.New("op grant has no uses left")
|
||||
return nil, nil, nil, errors.New("op grant has no uses left")
|
||||
}
|
||||
return t, &rec, nil
|
||||
return t, &rec, runs, nil
|
||||
}
|
||||
|
||||
// opsDropGrant — the console (or a host revoke) removes a held op grant.
|
||||
func opsDropGrant(dcnDir, nonce, why string) (bool, error) {
|
||||
opsMu.Lock()
|
||||
defer opsMu.Unlock()
|
||||
f := opsLoad(dcnDir)
|
||||
rec, ok := f.Grants[nonce]
|
||||
if !ok || rec.Dropped != "" {
|
||||
return false, nil
|
||||
}
|
||||
rec.Dropped = time.Now().UTC().Format(time.RFC3339) + " " + why
|
||||
rec.UsesLeft = 0
|
||||
f.Grants[nonce] = rec
|
||||
return true, opsSave(dcnDir, f)
|
||||
d, err := opsDropWhere(dcnDir, why, func(n string, _ *opGrantRec) bool { return n == nonce })
|
||||
return len(d) > 0, err
|
||||
}
|
||||
|
||||
// ── the run record ──────────────────────────────────────────────────────────
|
||||
@@ -1166,6 +1231,17 @@ func opFail(ws *websocket.Conn, why string) {
|
||||
|
||||
const noSandboxHere = "this machine cannot sandbox an op"
|
||||
|
||||
// noVerbToolHere is the refusal of a verb whose tool this machine lacks: the
|
||||
// machine's own state, never a strike (quarantine.go refusalCounts).
|
||||
const noVerbToolHere = "this machine cannot run the verb"
|
||||
|
||||
// Seams, so the tests can stand in for a machine with no overlay, or no
|
||||
// action sandbox.
|
||||
var (
|
||||
opSandboxCheck = opSandboxAvailable
|
||||
opActionCheck = opActionAvailable
|
||||
)
|
||||
|
||||
// op_sync <terms_b64> <sig_b64> <bundle_b64>: hold the op the terms name,
|
||||
// once the bundle hashes to it and its manifest parses. For op_grant terms
|
||||
// the grant is installed too (its approval consumed, like a store grant).
|
||||
@@ -1224,6 +1300,10 @@ func handleOpSync(ws *websocket.Conn, dir string, args []string) {
|
||||
opFail(ws, "op_sync refused: the manifest and the terms disagree on name, version or privilege")
|
||||
return
|
||||
}
|
||||
if err := opBundleFitsManifest(files, m); err != nil {
|
||||
opFail(ws, "op_sync refused: "+err.Error())
|
||||
return
|
||||
}
|
||||
if entries, err := os.ReadDir(opsDir(dir)); err == nil && len(entries) > opsMaxHeldOps {
|
||||
opFail(ws, fmt.Sprintf("op_sync refused: this box holds %d ops already; drop some", opsMaxHeldOps))
|
||||
return
|
||||
@@ -1237,7 +1317,7 @@ func handleOpSync(ws *websocket.Conn, dir string, args []string) {
|
||||
}
|
||||
out := map[string]any{"state": "held", "op": t.Op, "sha256": sha, "kind": t.Kind}
|
||||
if t.Kind == opKindGrant {
|
||||
if _, err := parseBounds(m, t.Bounds); err != nil {
|
||||
if _, err := parseGrantBounds(m, t.Bounds); err != nil {
|
||||
opFail(ws, "op_sync refused: "+err.Error())
|
||||
return
|
||||
}
|
||||
@@ -1251,14 +1331,22 @@ func handleOpSync(ws *websocket.Conn, dir string, args []string) {
|
||||
if prev, held := f.Grants[t.Nonce]; !held || prev.Dropped != "" {
|
||||
// THE PERSON'S APPROVAL, spent here: one approval installs the
|
||||
// grant once; a dropped grant cannot be re-installed under it.
|
||||
id, err := consumeApproval(dir, t.Approval, purpose)
|
||||
// The key that gave it is kept with the grant, which dies with
|
||||
// that key (ops_bounds.go).
|
||||
id, approver, err := consumeApprovalKeyed(dir, t.Approval, purpose)
|
||||
if err != nil {
|
||||
opsMu.Unlock()
|
||||
opFail(ws, "op_grant refused: "+err.Error())
|
||||
return
|
||||
}
|
||||
f.Grants[t.Nonce] = opGrantRec{TermsCanonical: args[0], Signature: args[1],
|
||||
InstalledAt: time.Now().UTC().Format(time.RFC3339), UsesLeft: t.Uses}
|
||||
rec := opGrantRec{TermsCanonical: args[0], Signature: args[1],
|
||||
InstalledAt: opNow().UTC().Format(time.RFC3339), UsesLeft: t.Uses, Approver: approver}
|
||||
if err := opGrantRecSeal(t.Nonce, &rec); err != nil {
|
||||
opsMu.Unlock()
|
||||
opFail(ws, "op_grant: could not hold the grant: "+err.Error())
|
||||
return
|
||||
}
|
||||
f.Grants[t.Nonce] = rec
|
||||
if err := opsSave(dir, f); err != nil {
|
||||
opsMu.Unlock()
|
||||
opFail(ws, "op_grant: could not hold the grant: "+err.Error())
|
||||
@@ -1359,9 +1447,15 @@ func handleOpApply(ws *websocket.Conn, dir string, args []string) {
|
||||
sendMgmtExit(ws, 0)
|
||||
}
|
||||
|
||||
// opGrantExec runs the terms of one run under a grant (a seam, so the tests
|
||||
// can stand in for the sandbox and judge the gate alone).
|
||||
var opGrantExec = opRunTerms
|
||||
|
||||
// op_grant_run <grant nonce> <mode> <params_json>: a run under a held grant —
|
||||
// dry_run or apply — with the params checked against the schema AND the
|
||||
// approved bounds; an apply spends one use.
|
||||
// approved bounds; an apply spends one use. An apply outside the grant's
|
||||
// window or rate is NOT NOW (ops_bounds.go): nothing runs, nothing is spent,
|
||||
// no strike. Identity and arguments are judged first and stay refusals.
|
||||
func handleOpGrantRun(ws *websocket.Conn, dir string, args []string) {
|
||||
if dir == "" || len(args) < 3 {
|
||||
opFail(ws, "op_grant_run: need <grant> <dry_run|apply> <params_json>")
|
||||
@@ -1393,12 +1487,12 @@ func handleOpGrantRun(ws *websocket.Conn, dir string, args []string) {
|
||||
opFail(ws, "op_grant_run refused: "+err.Error())
|
||||
return
|
||||
}
|
||||
bounds, err := parseBounds(m, t.Bounds)
|
||||
bounds, err := parseGrantBounds(m, t.Bounds)
|
||||
if err != nil {
|
||||
opFail(ws, "op_grant_run refused: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := checkBounds(m, bounds, vals); err != nil {
|
||||
if err := checkBounds(m, bounds.Params, vals); err != nil {
|
||||
opFail(ws, "op_grant_run refused: "+err.Error())
|
||||
return
|
||||
}
|
||||
@@ -1413,16 +1507,31 @@ func handleOpGrantRun(ws *websocket.Conn, dir string, args []string) {
|
||||
return
|
||||
}
|
||||
if mode == opModeApply {
|
||||
// Spend the use BEFORE the run (the same order the approval takes).
|
||||
// Spend the use BEFORE the run (the same order the approval takes),
|
||||
// and log the run with it, in one write — after the window and the
|
||||
// rate, read from that same record, say it may run now.
|
||||
opsMu.Lock()
|
||||
f := opsLoad(dir)
|
||||
r := f.Grants[t.Nonce]
|
||||
if r.UsesLeft <= 0 || r.Dropped != "" {
|
||||
_, _, runs, err := opsHeldGrantRuns(dir, t.Nonce)
|
||||
if err != nil {
|
||||
opsMu.Unlock()
|
||||
opFail(ws, "op_grant_run refused: op grant has no uses left")
|
||||
opFail(ws, "op_grant_run refused: "+err.Error())
|
||||
return
|
||||
}
|
||||
now := opNow()
|
||||
if why, bound, next := opNotNowWhy(bounds, runs, now); why != "" {
|
||||
opsMu.Unlock()
|
||||
sendNotNow(ws, dir, t, why, bound, next)
|
||||
return
|
||||
}
|
||||
f := opsLoad(dir)
|
||||
r := f.Grants[t.Nonce]
|
||||
r.UsesLeft--
|
||||
r.Runs = append(r.Runs, now.UTC().Format(time.RFC3339Nano))
|
||||
if err := opGrantRecSeal(t.Nonce, &r); err != nil {
|
||||
opsMu.Unlock()
|
||||
opFail(ws, "op_grant_run refused: "+err.Error())
|
||||
return
|
||||
}
|
||||
f.Grants[t.Nonce] = r
|
||||
if err := opsSave(dir, f); err != nil {
|
||||
opsMu.Unlock()
|
||||
@@ -1432,7 +1541,7 @@ func handleOpGrantRun(ws *websocket.Conn, dir string, args []string) {
|
||||
opsMu.Unlock()
|
||||
_ = rec
|
||||
}
|
||||
rep, record, err := opRunTerms(dir, &run, mode, "grant:"+t.Nonce, "", creds)
|
||||
rep, record, err := opGrantExec(dir, &run, mode, "grant:"+t.Nonce, "", creds)
|
||||
record.Kind = opKindGrant
|
||||
opRecordRun(dir, record)
|
||||
opAuditRun(dir, "op_grant_run", record)
|
||||
@@ -1464,6 +1573,7 @@ func handleOpList(ws *websocket.Conn, dir string, _ []string) {
|
||||
if m.isAction() {
|
||||
item["kind"] = opKindAction
|
||||
}
|
||||
item["verbs_only"] = m.verbsOnly()
|
||||
} else {
|
||||
item["error"] = err.Error()
|
||||
}
|
||||
@@ -1475,9 +1585,20 @@ func handleOpList(ws *websocket.Conn, dir string, _ []string) {
|
||||
f := opsLoad(dir)
|
||||
opsMu.Unlock()
|
||||
for nonce, rec := range f.Grants {
|
||||
g := map[string]any{"grant": nonce, "uses_left": rec.UsesLeft, "installed_at": rec.InstalledAt, "dropped": rec.Dropped}
|
||||
g := map[string]any{"grant": nonce, "uses_left": rec.UsesLeft, "installed_at": rec.InstalledAt, "dropped": rec.Dropped,
|
||||
"runs": len(rec.Runs), "approver": rec.Approver}
|
||||
if len(rec.Runs) > 0 {
|
||||
g["last_run"] = rec.Runs[len(rec.Runs)-1]
|
||||
}
|
||||
if t, err := verifyOpTerms(dir, rec.TermsCanonical, rec.Signature, opKindGrant); err == nil {
|
||||
g["op"], g["sha256"], g["expires"], g["bounds"] = t.Op, t.SHA256, t.Expires, t.Bounds
|
||||
if rec.Dropped == "" {
|
||||
if _, err := opGrantRecCheck(nonce, &rec, t, opNow()); err != nil {
|
||||
g["error"] = err.Error()
|
||||
} else if !opApproverLive(dir, rec.Approver) {
|
||||
g["error"] = "the key that approved it is no longer pinned on this machine"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
g["error"] = err.Error()
|
||||
}
|
||||
@@ -1606,9 +1727,14 @@ func opRunTerms(dir string, t *opTerms, mode, approvalID, termsB64 string, creds
|
||||
rec.Error = err.Error()
|
||||
return nil, rec, err
|
||||
}
|
||||
if ok, why := opSandboxAvailable(); !ok {
|
||||
rec.Error = noSandboxHere + ": " + why
|
||||
return nil, rec, errors.New(rec.Error)
|
||||
// The sandbox is for a program. An op with none (interpreter: none) runs
|
||||
// only the bastion's verbs, which need their own tools instead
|
||||
// (opRunAction): it runs where no overlay is.
|
||||
if !m.verbsOnly() {
|
||||
if ok, why := opSandboxCheck(); !ok {
|
||||
rec.Error = noSandboxHere + ": " + why
|
||||
return nil, rec, errors.New(rec.Error)
|
||||
}
|
||||
}
|
||||
if m.isAction() {
|
||||
// An action op (ops_action.go): its program in a transient unit, its
|
||||
|
||||
@@ -41,6 +41,12 @@ package main
|
||||
// else is written into the manifest, so what a verb can reach is fixed when
|
||||
// the op is approved.
|
||||
//
|
||||
// An action op may have no program at all (interpreter: none, no run file):
|
||||
// its verbs are its whole work. Nothing the Host wrote executes, so it needs
|
||||
// no sandbox and runs on a machine with no overlay; each verb needs only its
|
||||
// own tool (systemctl, journalctl, git, kubectl), and the beat reports which
|
||||
// this machine has (enforcing.verbs).
|
||||
//
|
||||
// Every verb line, with the state it moves from, is in the dry run's change
|
||||
// list, so an approval binds that list and an apply refuses when the machine
|
||||
// moved under it. An op with any verb is privilege root: its verbs run with
|
||||
@@ -394,10 +400,29 @@ func opParseDest(s string) (opDest, error) {
|
||||
}
|
||||
} else if why := opForbiddenDest(ip); why != "" {
|
||||
return opDest{}, errors.New(why)
|
||||
} else if why := opLocalNetworkDest(ip); why != "" {
|
||||
return opDest{}, errors.New(why)
|
||||
}
|
||||
return opDest{Host: h, Port: uint16(port)}, nil
|
||||
}
|
||||
|
||||
// opLocalNetworkDest: "" when an address may be a destination on this box as
|
||||
// it stands, else why not. An anchored machine is a fleet's, and its ship
|
||||
// steps reach the fleet's private control planes, so it keeps private
|
||||
// destinations. On any other machine (a person's own, which no fleet has
|
||||
// anchored) an op reaches the public internet and nothing around the
|
||||
// machine: a private, ULA, CGNAT or reserved address (internalIP, the rule
|
||||
// the egress lane holds every box to) is refused, by address here and, for a
|
||||
// name, by every address it resolves to at apply (opResolveDests). Another
|
||||
// computer of theirs is reached through a bastion on that computer.
|
||||
func opLocalNetworkDest(ip net.IP) string {
|
||||
if currentAnchor() != nil || !internalIP(ip) {
|
||||
return ""
|
||||
}
|
||||
return "a destination is never an address on a private network (a home network, a VPN or a carrier NAT) " +
|
||||
"on a machine no fleet has anchored; to reach another computer on it, install a bastion on that computer"
|
||||
}
|
||||
|
||||
// opMetadataAddrs are the cloud metadata services that do not sit in
|
||||
// link-local space (Alibaba's, AWS's IPv6 one). They hand out the machine's
|
||||
// own credentials to anything that asks from it.
|
||||
@@ -445,6 +470,12 @@ func opCredEnv(name string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// verbsOnly: an action op with no program (interpreter: none). Its whole work
|
||||
// is its verbs, done by the bastion with their arguments fixed in the
|
||||
// manifest; nothing the Host wrote executes, so it needs no sandbox (a machine
|
||||
// with no overlay runs it), only the tools its verbs run.
|
||||
func (m *opManifest) verbsOnly() bool { return m.isAction() && m.Interpreter == opInterpreterNone }
|
||||
|
||||
// opActionManifestOK is parseOpManifest's judgement of an action op.
|
||||
func opActionManifestOK(m *opManifest) error {
|
||||
if len(m.Effects.Files)+len(m.Effects.Units)+len(m.Effects.Packages)+len(m.Effects.Network) > 0 {
|
||||
@@ -489,6 +520,25 @@ func opActionManifestOK(m *opManifest) error {
|
||||
if (len(verbs) > 0) != (m.Privilege == "root") {
|
||||
return errors.New("op.yaml: an action op is privilege root exactly when it has verbs: they run with the bastion's own root")
|
||||
}
|
||||
if m.verbsOnly() {
|
||||
if len(verbs) == 0 {
|
||||
return errors.New("op.yaml: interpreter: none is an op whose verbs are its whole work, and it names none")
|
||||
}
|
||||
if len(m.Destinations) > 0 || m.Builder || len(m.Reads) > 0 {
|
||||
return errors.New("op.yaml: destinations, builder and reads are a program's, and interpreter: none has no program")
|
||||
}
|
||||
named := map[string]bool{}
|
||||
for _, v := range verbs {
|
||||
if c := v.Args["credential"]; c != "" {
|
||||
named[c] = true
|
||||
}
|
||||
}
|
||||
for _, c := range m.Credentials {
|
||||
if !named[c] {
|
||||
return fmt.Errorf("op.yaml: credential %q is named by no verb, and interpreter: none has no program to hand it to", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -350,7 +350,9 @@ type opResolvedDests struct {
|
||||
// what the unit is allowed to reach, and the names go in the run's own
|
||||
// /etc/hosts (the machine's resolver is not reachable from the sandbox). A
|
||||
// destination that resolves to this machine, its loopback or nothing is
|
||||
// refused: a local service is never an op's destination.
|
||||
// refused: a local service is never an op's destination. On a machine no
|
||||
// fleet has anchored, so is one that resolves to a private network
|
||||
// (opLocalNetworkDest).
|
||||
func opResolveDests(ctx context.Context, dests []string) (*opResolvedDests, error) {
|
||||
own := map[string]bool{}
|
||||
for _, ip := range opActionOwnAddrs() {
|
||||
@@ -379,6 +381,9 @@ func opResolveDests(ctx context.Context, dests []string) (*opResolvedDests, erro
|
||||
if why := opForbiddenDest(ip); why != "" {
|
||||
return nil, fmt.Errorf("destination %s resolves to %s: %s", d, ip, why)
|
||||
}
|
||||
if why := opLocalNetworkDest(ip); why != "" {
|
||||
return nil, fmt.Errorf("destination %s resolves to %s: %s", d, ip, why)
|
||||
}
|
||||
if own[ip.String()] {
|
||||
return nil, fmt.Errorf("destination %s resolves to %s, which is this machine", d, ip)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// RESULT: red before opLocalNetworkDest — on a local box a name that resolves
|
||||
// to a home network's address was put in the apply's allowlist.
|
||||
func TestALocalBoxRefusesANameThatResolvesToAPrivateNetworkAtApply(t *testing.T) {
|
||||
localOpBox(t)
|
||||
prevOwn, prevRes := opActionOwnAddrs, opActionResolve
|
||||
opActionOwnAddrs = func() []net.IP { return nil }
|
||||
opActionResolve = func(_ context.Context, h string) ([]net.IP, error) {
|
||||
switch h {
|
||||
case "nas.home.example":
|
||||
return []net.IP{net.ParseIP("10.0.0.2")}, nil
|
||||
case "split.example.com":
|
||||
return []net.IP{net.ParseIP("203.0.113.10"), net.ParseIP("192.168.1.2")}, nil
|
||||
case "forge.example.com":
|
||||
return []net.IP{net.ParseIP("203.0.113.10")}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no")
|
||||
}
|
||||
t.Cleanup(func() { opActionOwnAddrs, opActionResolve = prevOwn, prevRes })
|
||||
|
||||
for _, d := range []string{"nas.home.example:80", "split.example.com:443"} {
|
||||
if _, err := opResolveDests(context.Background(), []string{d}); err == nil ||
|
||||
!strings.Contains(err.Error(), "private network") {
|
||||
t.Errorf("%s: a local box resolves it into the allowlist: %v", d, err)
|
||||
}
|
||||
}
|
||||
got, err := opResolveDests(context.Background(), []string{"forge.example.com:443"})
|
||||
if err != nil || len(got.Addrs) != 1 || got.Addrs[0] != "203.0.113.10" {
|
||||
t.Fatalf("a public name resolves to its address: %+v %v", got, err)
|
||||
}
|
||||
|
||||
// An anchored fleet keeps the same name.
|
||||
setupBox(t)
|
||||
if got, err := opResolveDests(context.Background(), []string{"nas.home.example:80"}); err != nil || got.Addrs[0] != "10.0.0.2" {
|
||||
t.Fatalf("an anchored box resolves a private control plane: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A destination written as an address is judged again at apply by the anchor
|
||||
// the machine has then: an op held while the machine was anchored does not
|
||||
// reach a private address after the anchor is gone.
|
||||
func TestAPrivateDestinationHeldWhileAnchoredIsRefusedAtApplyOnceTheAnchorIsGone(t *testing.T) {
|
||||
setupBox(t)
|
||||
prevOwn := opActionOwnAddrs
|
||||
opActionOwnAddrs = func() []net.IP { return nil }
|
||||
t.Cleanup(func() { opActionOwnAddrs = prevOwn })
|
||||
for _, d := range []string{"10.0.0.2:80", "[::ffff:10.0.0.2]:80"} {
|
||||
if _, err := opResolveDests(context.Background(), []string{d}); err != nil {
|
||||
t.Fatalf("%s: an anchored box reaches its private control plane: %v", d, err)
|
||||
}
|
||||
}
|
||||
localOpBox(t)
|
||||
for _, d := range []string{"10.0.0.2:80", "[::ffff:10.0.0.2]:80"} {
|
||||
if _, err := opResolveDests(context.Background(), []string{d}); err == nil || !strings.Contains(err.Error(), "private network") {
|
||||
t.Errorf("%s: held while anchored, reached after the anchor is gone: %v", d, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A verb fetches too: tree.checkout fetches its repo, and k8s.set_image
|
||||
// fetches its repo to check ancestry, each with the bastion's own root and
|
||||
// outside the program's sandbox. On a local machine that fetch is held to
|
||||
// the rule a destination is: a repository whose host resolves to a private
|
||||
// network is never fetched, and no git runs for it. An interpreter: none op
|
||||
// is nothing but verbs, so this is the whole of its reach.
|
||||
//
|
||||
// RESULT: red before opFetchPin — the ancestry fetch ran git against the
|
||||
// name, which git resolved itself, so a name on a home network was fetched.
|
||||
func TestALocalBoxNeverFetchesAVerbsRepositoryFromAPrivateNetwork(t *testing.T) {
|
||||
localOpBox(t)
|
||||
prevOwn, prevRes := opActionOwnAddrs, opActionResolve
|
||||
opActionOwnAddrs = func() []net.IP { return nil }
|
||||
opActionResolve = func(_ context.Context, h string) ([]net.IP, error) {
|
||||
switch h {
|
||||
case "nas.home.example":
|
||||
return []net.IP{net.ParseIP("10.0.0.2")}, nil
|
||||
case "split.example.com":
|
||||
return []net.IP{net.ParseIP("203.0.113.10"), net.ParseIP("::ffff:192.168.1.2")}, nil
|
||||
case "meta.example.com":
|
||||
return []net.IP{net.ParseIP("169.254.169.254")}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no")
|
||||
}
|
||||
t.Cleanup(func() { opActionOwnAddrs, opActionResolve = prevOwn, prevRes })
|
||||
|
||||
older, newer := strings.Repeat("a", 40), strings.Repeat("b", 40)
|
||||
for repo, want := range map[string]string{
|
||||
"https://nas.home.example/acme/app.git": "private network",
|
||||
"https://split.example.com/acme/app.git": "private network",
|
||||
"https://meta.example.com/acme/app.git": "link-local",
|
||||
} {
|
||||
err := opAncestryByFetch(repo, older, newer, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: a local box fetched it: %v", repo, err)
|
||||
}
|
||||
}
|
||||
// The pin a public repository is fetched under: git is held to the
|
||||
// address checked here and never looks the name up again.
|
||||
prevGit := opGitPinnable
|
||||
opGitPinnable = func() string { return "" }
|
||||
t.Cleanup(func() { opGitPinnable = prevGit })
|
||||
opActionResolve = func(_ context.Context, h string) ([]net.IP, error) {
|
||||
return []net.IP{net.ParseIP("2001:db8::10")}, nil
|
||||
}
|
||||
if pin, err := opFetchPin(context.Background(), "https://forge.example.com:8443/acme/app.git"); err != nil ||
|
||||
strings.Join(pin, " ") != "-c http.curloptResolve=forge.example.com:8443:[2001:db8::10]" {
|
||||
t.Fatalf("a public repository is fetched pinned to its address: %q %v", pin, err)
|
||||
}
|
||||
// A git that would drop the pin is refused, never run unpinned.
|
||||
opGitPinnable = func() string { return "git 2.34 cannot hold a fetch to one address" }
|
||||
if _, err := opFetchPin(context.Background(), "https://forge.example.com/acme/app.git"); err == nil {
|
||||
t.Fatal("a git that ignores the pin fetched unpinned")
|
||||
}
|
||||
// An anchored fleet fetches from its private forge, pinned to it.
|
||||
opGitPinnable = func() string { return "" }
|
||||
setupBox(t)
|
||||
opActionResolve = func(_ context.Context, h string) ([]net.IP, error) {
|
||||
return []net.IP{net.ParseIP("10.0.0.2")}, nil
|
||||
}
|
||||
if pin, err := opFetchPin(context.Background(), "https://nas.home.example/acme/app.git"); err != nil ||
|
||||
strings.Join(pin, " ") != "-c http.curloptResolve=nas.home.example:443:10.0.0.2" {
|
||||
t.Fatalf("an anchored box fetches from its private forge: %q %v", pin, err)
|
||||
}
|
||||
}
|
||||
|
||||
// An anchored machine keeps its fleet's private forges, but a verb's fetch
|
||||
// there is still never this machine, its loopback, a link-local address or a
|
||||
// cloud's metadata service: a name steered to one is refused before git runs.
|
||||
//
|
||||
// RESULT: red before opFetchPin ran on anchored machines — git fetched each
|
||||
// name itself, whatever it resolved to.
|
||||
func TestAnAnchoredBoxNeverFetchesAVerbsRepositoryFromItselfOrTheMetadataService(t *testing.T) {
|
||||
setupBox(t)
|
||||
prevOwn, prevRes := opActionOwnAddrs, opActionResolve
|
||||
opActionOwnAddrs = func() []net.IP { return []net.IP{net.ParseIP("203.0.113.99")} }
|
||||
opActionResolve = func(_ context.Context, h string) ([]net.IP, error) {
|
||||
switch h {
|
||||
case "lo.example.com":
|
||||
return []net.IP{net.ParseIP("127.0.0.1")}, nil
|
||||
case "mapped.example.com":
|
||||
return []net.IP{net.ParseIP("203.0.113.10"), net.ParseIP("::ffff:127.0.0.1")}, nil
|
||||
case "meta.example.com":
|
||||
return []net.IP{net.ParseIP("169.254.169.254")}, nil
|
||||
case "ali.example.com":
|
||||
return []net.IP{net.ParseIP("100.100.100.200")}, nil
|
||||
case "self.example.com":
|
||||
return []net.IP{net.ParseIP("203.0.113.99")}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no")
|
||||
}
|
||||
t.Cleanup(func() { opActionOwnAddrs, opActionResolve = prevOwn, prevRes })
|
||||
older, newer := strings.Repeat("a", 40), strings.Repeat("b", 40)
|
||||
for repo, want := range map[string]string{
|
||||
"https://lo.example.com/acme/app.git": "loopback",
|
||||
"https://mapped.example.com/acme/app.git": "loopback",
|
||||
"https://meta.example.com/acme/app.git": "link-local",
|
||||
"https://ali.example.com/acme/app.git": "metadata service",
|
||||
"https://self.example.com/acme/app.git": "this machine",
|
||||
} {
|
||||
err := opAncestryByFetch(repo, older, newer, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: an anchored box fetched it: %v", repo, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// k8s.set_image hands an image to the cluster, and the machine's container
|
||||
// runtime pulls it by name, outside the bastion's walls. On a local machine
|
||||
// an image whose registry is on the private network around it is refused
|
||||
// where the bastion takes the image, so the runtime is never sent there; a
|
||||
// Docker Hub repository and a public registry pass; an anchored fleet keeps
|
||||
// its private registry.
|
||||
//
|
||||
// RESULT: red before opRegistryOK — the plan took a receipt for an image on a
|
||||
// home network's registry and went on to the live image's record.
|
||||
func TestALocalBoxNeverPutsLiveAnImageFromARegistryOnItsNetwork(t *testing.T) {
|
||||
localOpBox(t)
|
||||
prevOwn, prevRes := opActionOwnAddrs, opActionResolve
|
||||
opActionOwnAddrs = func() []net.IP { return nil }
|
||||
opActionResolve = func(_ context.Context, h string) ([]net.IP, error) {
|
||||
switch h {
|
||||
case "nas.home.example":
|
||||
return []net.IP{net.ParseIP("10.0.0.2")}, nil
|
||||
case "registry.example.com":
|
||||
return []net.IP{net.ParseIP("203.0.113.10")}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no")
|
||||
}
|
||||
t.Cleanup(func() { opActionOwnAddrs, opActionResolve = prevOwn, prevRes })
|
||||
useAncestry(t, func(_, _, _ string, _ []string) error { return nil })
|
||||
ctx := context.Background()
|
||||
commit := strings.Repeat("c", 40)
|
||||
for i, tc := range []struct{ image, want string }{
|
||||
{"nas.home.example:5000/acme/web", "private network"},
|
||||
{"10.0.0.2:5000/acme/web", "private network"},
|
||||
{"192.168.1.2/acme/web", "private network"},
|
||||
{"localhost:5000/acme/web", "loopback"},
|
||||
{"169.254.169.254/acme/web", "link-local"},
|
||||
{"registry.example.com/acme/web", ""},
|
||||
{"acme/web", ""},
|
||||
} {
|
||||
digest := "sha256:" + strings.Repeat(fmt.Sprintf("%x", i+1), 64)
|
||||
if _, err := opIssueReceipts("r", "web-build", strings.Repeat("e", 64), "{}", []opResultImage{{Image: tc.image,
|
||||
Digest: digest, Source: tRepo, Commit: commit}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeKube{containers: map[string]string{"bridge": tc.image + "@sha256:" + strings.Repeat("0", 64)}}
|
||||
useFakeKube(t, f)
|
||||
line := setImageLine(digest)
|
||||
line.Args["image"] = tc.image
|
||||
_, err := opImagePlan(ctx, line, nil)
|
||||
if tc.want != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "comes from the registry") {
|
||||
t.Errorf("%s: a local box would put it live: %v", tc.image, err)
|
||||
}
|
||||
if _, err := opImageDo(ctx, line, nil, nil, ""); err == nil || len(f.sets()) != 0 {
|
||||
t.Errorf("%s: and set it: %v %v", tc.image, err, f.sets())
|
||||
}
|
||||
} else if err != nil && strings.Contains(err.Error(), "comes from the registry") {
|
||||
t.Errorf("%s: a public registry is refused: %v", tc.image, err)
|
||||
}
|
||||
}
|
||||
// An anchored fleet keeps its private registry.
|
||||
setupBox(t)
|
||||
if err := opRegistryOK(ctx, "nas.home.example:5000/acme/web@sha256:"+strings.Repeat("1", 64)); err != nil {
|
||||
t.Fatalf("an anchored box pulls from its private registry: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// An action op on a person's own machine (local, unanchored) reaches the
|
||||
// public internet and nothing around the machine; an anchored fleet keeps its
|
||||
// private destinations, because its ship steps reach private control planes.
|
||||
// The addresses are documentation and private examples, invented here.
|
||||
|
||||
// localOpBox: a local machine, anchored by nobody, with one pairing whose
|
||||
// pairing dir pins a signing key we hold.
|
||||
func localOpBox(t *testing.T) (dcn string, priv ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
p, _ := modePath()
|
||||
if err := os.WriteFile(p, []byte(`{"mode":"local"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writePairing(t, tAnchor)
|
||||
if currentAnchor() != nil {
|
||||
t.Fatal("the local box must not be anchored")
|
||||
}
|
||||
dcn = t.TempDir()
|
||||
pub, priv, _ := ed25519.GenerateKey(nil)
|
||||
if err := dcnSaveFile(dcn, &dcnGrantFile{Stores: map[string]dcnGrantRec{},
|
||||
VerifyPubkey: base64.StdEncoding.EncodeToString(pub)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dcn, priv
|
||||
}
|
||||
|
||||
// actionBundle: an unprivileged action op declaring one destination.
|
||||
func actionBundle(dest string) []opFile {
|
||||
man := `name: optest-reach
|
||||
version: 1.0.0
|
||||
summary: An action op that reaches one destination.
|
||||
kind: action
|
||||
target: machine
|
||||
privilege: user
|
||||
interpreter: /bin/sh
|
||||
time_limit: 30
|
||||
destinations: ["` + dest + `"]
|
||||
modes: [dry_run, apply, verify]
|
||||
undo: none
|
||||
`
|
||||
return []opFile{
|
||||
{Path: "op.yaml", Mode: 0o644, B64: base64.StdEncoding.EncodeToString([]byte(man))},
|
||||
{Path: "run", Mode: 0o755, B64: base64.StdEncoding.EncodeToString([]byte("#!/bin/sh\ntrue\n"))},
|
||||
}
|
||||
}
|
||||
|
||||
// syncAction drives op_sync for an action op declaring dest, under op_run
|
||||
// terms signed by priv; it returns the op's output, its error text and code.
|
||||
func syncAction(t *testing.T, dcn string, priv ed25519.PrivateKey, dest string) (string, string, int) {
|
||||
t.Helper()
|
||||
files := actionBundle(dest)
|
||||
sha, err := opDirDigest(files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
terms := opTerm(opKindRun, sha, `{}`, "", 1, time.Hour, "user")
|
||||
terms["op"] = "optest-reach"
|
||||
pair := signTermsPair(t, priv, terms)
|
||||
return driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(pair, bundleB64(files))}, "", dcn)
|
||||
}
|
||||
|
||||
// RESULT: red before opLocalNetworkDest — a local box held an op declaring
|
||||
// 10.0.0.2:80 (a home network's address), and its apply would have reached it.
|
||||
func TestALocalBoxRefusesAnActionOpThatDeclaresAPrivateNetwork(t *testing.T) {
|
||||
dcn, priv := localOpBox(t)
|
||||
for _, dest := range []string{
|
||||
"10.0.0.2:80", // RFC 1918
|
||||
"192.168.1.2:443", // a home router's range
|
||||
"172.16.0.2:8080", // RFC 1918
|
||||
"100.64.0.2:22", // carrier NAT, and VPN meshes that use it
|
||||
"[fd00::2]:80", // ULA
|
||||
"[64:ff9b::a00:2]:80", // 10.0.0.2 through NAT64
|
||||
"[2002:a00:2::1]:80", // 10.0.0.2 through 6to4
|
||||
"198.18.0.2:80", // benchmarking, never a public host
|
||||
} {
|
||||
out, errOut, code := syncAction(t, dcn, priv, dest)
|
||||
if code == 0 || strings.Contains(out, `"held"`) {
|
||||
t.Errorf("%s: a local box held an op declaring it: %d %s %s", dest, code, out, errOut)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(errOut, "private network") || !strings.Contains(errOut, "install a bastion on that computer") {
|
||||
t.Errorf("%s: the refusal must say why and what to do instead: %q", dest, errOut)
|
||||
}
|
||||
}
|
||||
// The public internet is still a destination.
|
||||
if out, errOut, code := syncAction(t, dcn, priv, "203.0.113.10:443"); code != 0 || !strings.Contains(out, `"held"`) {
|
||||
t.Fatalf("a public destination is held on a local box: %d %s %s", code, out, errOut)
|
||||
}
|
||||
// A name is judged at apply, by every address it resolves to
|
||||
// (opResolveDests); at sync it is only a name.
|
||||
if out, errOut, code := syncAction(t, dcn, priv, "forge.example.com:443"); code != 0 || !strings.Contains(out, `"held"`) {
|
||||
t.Fatalf("a destination by name is held on a local box: %d %s %s", code, out, errOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAnchoredFleetKeepsItsPrivateDestinations(t *testing.T) {
|
||||
dcn, priv := opTestBox(t)
|
||||
enrollFactor(t, dcn)
|
||||
for _, dest := range []string{"10.0.0.2:80", "100.64.0.2:6443", "[fd00::2]:80"} {
|
||||
if out, errOut, code := syncAction(t, dcn, priv, dest); code != 0 || !strings.Contains(out, `"held"`) {
|
||||
t.Errorf("%s: an anchored box holds a ship step to a private control plane: %d %s %s", dest, code, out, errOut)
|
||||
}
|
||||
}
|
||||
// What every box refuses stays refused.
|
||||
if _, errOut, code := syncAction(t, dcn, priv, "127.0.0.1:80"); code == 0 || !strings.Contains(errOut, "loopback") {
|
||||
t.Fatalf("loopback stays refused on an anchored box: %d %q", code, errOut)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
package main
|
||||
|
||||
// ops_bounds.go — what an op_grant bounds besides the params, and the record
|
||||
// that lets this machine hold it there (bastion 0.58.0).
|
||||
//
|
||||
// The bounds text an op_grant carries (ops.go parseBounds) names params by
|
||||
// their names, which match ^[a-z]. Two keys beginning with "_" bound the RUN
|
||||
// rather than a param, so they can never collide with one:
|
||||
//
|
||||
// "_window": {"days": ["mon", …, "sun"], "from": "HH:MM", "to": "HH:MM", "tz": "Europe/Berlin"}
|
||||
// applies run only inside this window of the machine's clock, read
|
||||
// in tz (an IANA name, "UTC" when absent). days is optional (every
|
||||
// day when absent). from > to wraps midnight: the window opens on a
|
||||
// listed day and closes the next morning.
|
||||
// "_rate": {"per_day": n, "min_interval_s": s}
|
||||
// at most n applies in any 24 hours, and s seconds between two.
|
||||
// Either may be absent, not both.
|
||||
//
|
||||
// Either key may be null, which bounds nothing. Any other "_" key is refused:
|
||||
// a bound this machine does not enforce would read on the card as a limit the
|
||||
// person never got. An older box refuses both as "names no param", so a grant
|
||||
// carrying them fails closed there.
|
||||
//
|
||||
// An apply outside the window or the rate is NOT NOW, not refused: nothing
|
||||
// runs, no use is spent, and it is no strike toward the quarantine — the Host
|
||||
// asked for something the person allows, at a time they do not. The answer is
|
||||
// an 'O' frame {"state": "not_now", …} and exit 75 (sysexits EX_TEMPFAIL).
|
||||
// Identity and argument failures stay refusals, as before.
|
||||
//
|
||||
// THE RECORD. ops.json keeps, for each grant, the times of the applies that
|
||||
// spent a use (the run log, which the rate is counted from, so it holds across
|
||||
// a restart), the key that approved it, and a MAC over the record under a key
|
||||
// this machine derives from its own encryption key. A record edited outside
|
||||
// the bastion — a run removed, a use given back, the approver changed — no
|
||||
// longer checks out, and the grant is unusable until the person drops it and
|
||||
// approves again. A grant installed by a bastion older than this one carries
|
||||
// neither and is unusable too (none were held when it shipped).
|
||||
//
|
||||
// THE APPROVER. The device key that signed the approval, or the factor enrolled
|
||||
// at this console. A grant whose approver is no longer pinned (the device key
|
||||
// unpinned, the factor re-enrolled) is dead: unpinning a device drops every
|
||||
// grant it approved, here and in every pairing.
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
// The zone database is in the binary: a window's tz reads the same on a
|
||||
// machine with no tzdata installed, and a grant is never refused at one
|
||||
// box and accepted at another for want of a file.
|
||||
_ "time/tzdata"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
opBoundWindow = "_window"
|
||||
opBoundRate = "_rate"
|
||||
opRateMinInterval = 30 * 24 * 60 * 60 // seconds: the longest a grant lives
|
||||
// opNotNowExit is the exit an apply that was held to its window or its
|
||||
// rate ends with (sysexits.h EX_TEMPFAIL: "try again later").
|
||||
opNotNowExit = 75
|
||||
opNotNow = "not_now"
|
||||
)
|
||||
|
||||
var (
|
||||
opHHMMRe = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
|
||||
opTZRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+){0,2}$`)
|
||||
opDays = []string{"sun", "mon", "tue", "wed", "thu", "fri", "sat"} // time.Weekday order
|
||||
// opNow is the machine's clock as the run gate reads it (a seam for the
|
||||
// tests, which move it).
|
||||
opNow = time.Now
|
||||
)
|
||||
|
||||
type opWindow struct {
|
||||
Days []string `json:"days,omitempty"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
TZ string `json:"tz,omitempty"`
|
||||
|
||||
loc *time.Location
|
||||
from, to int // minutes past midnight
|
||||
day [7]bool
|
||||
}
|
||||
|
||||
type opRate struct {
|
||||
PerDay *int `json:"per_day,omitempty"`
|
||||
MinInterval *int `json:"min_interval_s,omitempty"`
|
||||
}
|
||||
|
||||
// opGrantBounds is the whole of what an op_grant's bounds text says.
|
||||
type opGrantBounds struct {
|
||||
Params map[string]opBound
|
||||
Window *opWindow
|
||||
Rate *opRate
|
||||
}
|
||||
|
||||
func strictJSON(raw []byte, into any) error {
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(into); err != nil {
|
||||
return err
|
||||
}
|
||||
if dec.More() {
|
||||
return errors.New("trailing data")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseOpWindow(raw json.RawMessage) (*opWindow, error) {
|
||||
if string(raw) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
var w opWindow
|
||||
if err := strictJSON(raw, &w); err != nil {
|
||||
return nil, errors.New(`bound "_window": {"days": [mon..sun], "from": "HH:MM", "to": "HH:MM", "tz": "<IANA zone>"}`)
|
||||
}
|
||||
if !opHHMMRe.MatchString(w.From) || !opHHMMRe.MatchString(w.To) {
|
||||
return nil, errors.New(`bound "_window": from and to are 24-hour "HH:MM"`)
|
||||
}
|
||||
if w.From == w.To {
|
||||
return nil, errors.New(`bound "_window": from and to are the same minute, which is no window`)
|
||||
}
|
||||
hm := func(s string) int { h, _ := strconv.Atoi(s[:2]); m, _ := strconv.Atoi(s[3:]); return h*60 + m }
|
||||
w.from, w.to = hm(w.From), hm(w.To)
|
||||
if w.Days != nil {
|
||||
if len(w.Days) == 0 || len(w.Days) > 7 {
|
||||
return nil, errors.New(`bound "_window": days names 1..7 days, or is left out for every day`)
|
||||
}
|
||||
for _, d := range w.Days {
|
||||
i := opDayIndex(d)
|
||||
if i < 0 {
|
||||
return nil, fmt.Errorf(`bound "_window": %q is not a day (mon, tue, wed, thu, fri, sat, sun)`, d)
|
||||
}
|
||||
if w.day[i] {
|
||||
return nil, fmt.Errorf(`bound "_window": %q is named twice`, d)
|
||||
}
|
||||
w.day[i] = true
|
||||
}
|
||||
} else {
|
||||
for i := range w.day {
|
||||
w.day[i] = true
|
||||
}
|
||||
}
|
||||
tz := w.TZ
|
||||
if tz == "" {
|
||||
tz = "UTC"
|
||||
}
|
||||
if !opTZRe.MatchString(tz) || strings.EqualFold(tz, "local") {
|
||||
return nil, fmt.Errorf(`bound "_window": tz %q is not an IANA zone name`, w.TZ)
|
||||
}
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`bound "_window": tz %q is not a zone this machine knows`, w.TZ)
|
||||
}
|
||||
w.loc = loc
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
func opDayIndex(d string) int {
|
||||
for i, s := range opDays {
|
||||
if s == d {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func parseOpRate(raw json.RawMessage) (*opRate, error) {
|
||||
if string(raw) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
var r opRate
|
||||
if err := strictJSON(raw, &r); err != nil {
|
||||
return nil, errors.New(`bound "_rate": {"per_day": n, "min_interval_s": s}`)
|
||||
}
|
||||
if r.PerDay == nil && r.MinInterval == nil {
|
||||
return nil, errors.New(`bound "_rate": names per_day, min_interval_s or both`)
|
||||
}
|
||||
if r.PerDay != nil && (*r.PerDay < 1 || *r.PerDay > opGrantUsesMax) {
|
||||
return nil, fmt.Errorf(`bound "_rate": per_day is 1..%d`, opGrantUsesMax)
|
||||
}
|
||||
if r.MinInterval != nil && (*r.MinInterval < 1 || *r.MinInterval > opRateMinInterval) {
|
||||
return nil, fmt.Errorf(`bound "_rate": min_interval_s is 1..%d`, opRateMinInterval)
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// parseGrantBounds reads an op_grant's bounds text: the params (parseBounds'
|
||||
// rules, each inside the schema) and the run bounds above.
|
||||
func parseGrantBounds(m *opManifest, raw string) (*opGrantBounds, error) {
|
||||
if len(raw) > opParamsMaxBytes {
|
||||
return nil, errors.New("bounds too large")
|
||||
}
|
||||
var top map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &top); err != nil || top == nil {
|
||||
return nil, errors.New("bounds are not a JSON object of {param: {min, max | values | prefix | fixed}, _window, _rate}")
|
||||
}
|
||||
out := &opGrantBounds{}
|
||||
params := map[string]json.RawMessage{}
|
||||
for k, v := range top {
|
||||
var err error
|
||||
switch {
|
||||
case k == opBoundWindow:
|
||||
out.Window, err = parseOpWindow(v)
|
||||
case k == opBoundRate:
|
||||
out.Rate, err = parseOpRate(v)
|
||||
case strings.HasPrefix(k, "_"):
|
||||
err = fmt.Errorf("bound %q is not one this machine enforces (%s, %s)", k, opBoundWindow, opBoundRate)
|
||||
default:
|
||||
params[k] = v
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
prest, _ := json.Marshal(params)
|
||||
p, err := parseParamBounds(m, string(prest))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Params = p
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// inside: is t inside the window? A window that wraps midnight belongs to the
|
||||
// day it opens on.
|
||||
func (w *opWindow) inside(t time.Time) bool {
|
||||
l := t.In(w.loc)
|
||||
mins := l.Hour()*60 + l.Minute()
|
||||
today := int(l.Weekday())
|
||||
if w.from < w.to {
|
||||
return w.day[today] && mins >= w.from && mins < w.to
|
||||
}
|
||||
yesterday := (today + 6) % 7
|
||||
return (w.day[today] && mins >= w.from) || (w.day[yesterday] && mins < w.to)
|
||||
}
|
||||
|
||||
// next is when the window next opens after t (zero when it never does, which
|
||||
// a parsed window cannot be).
|
||||
func (w *opWindow) next(t time.Time) time.Time {
|
||||
l := t.In(w.loc)
|
||||
for i := 0; i <= 8; i++ {
|
||||
d := l.AddDate(0, 0, i)
|
||||
open := time.Date(d.Year(), d.Month(), d.Day(), w.from/60, w.from%60, 0, 0, w.loc)
|
||||
if open.After(t) && w.day[int(open.Weekday())] {
|
||||
return open
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func (w *opWindow) words() string {
|
||||
days := "every day"
|
||||
if len(w.Days) > 0 && len(w.Days) < 7 {
|
||||
days = strings.Join(w.Days, ",")
|
||||
}
|
||||
return fmt.Sprintf("%s %s-%s %s", days, w.From, w.To, w.loc.String())
|
||||
}
|
||||
|
||||
// opNotNowWhy is the run gate on window and rate: "" when an apply may run at
|
||||
// now, else why not, which bound held it, and when it next could.
|
||||
func opNotNowWhy(b *opGrantBounds, runs []time.Time, now time.Time) (why, bound string, next time.Time) {
|
||||
if b.Window != nil && !b.Window.inside(now) {
|
||||
return "outside the approved window (" + b.Window.words() + ")", opBoundWindow, b.Window.next(now)
|
||||
}
|
||||
if b.Rate == nil || len(runs) == 0 {
|
||||
return "", "", time.Time{}
|
||||
}
|
||||
last := runs[len(runs)-1]
|
||||
if b.Rate.MinInterval != nil {
|
||||
gap := time.Duration(*b.Rate.MinInterval) * time.Second
|
||||
if now.Sub(last) < gap {
|
||||
return fmt.Sprintf("the last run was %s ago; the approval keeps %s between runs",
|
||||
now.Sub(last).Round(time.Second), gap), opBoundRate, last.Add(gap)
|
||||
}
|
||||
}
|
||||
if b.Rate.PerDay != nil {
|
||||
var day []time.Time
|
||||
for _, r := range runs {
|
||||
if now.Sub(r) < 24*time.Hour {
|
||||
day = append(day, r)
|
||||
}
|
||||
}
|
||||
if len(day) >= *b.Rate.PerDay {
|
||||
return fmt.Sprintf("%d run(s) in the last 24 hours; the approval allows %d", len(day), *b.Rate.PerDay),
|
||||
opBoundRate, day[len(day)-*b.Rate.PerDay].Add(24 * time.Hour)
|
||||
}
|
||||
}
|
||||
return "", "", time.Time{}
|
||||
}
|
||||
|
||||
// sendNotNow answers an apply that was held to its window or rate: recorded,
|
||||
// never a strike, nothing spent.
|
||||
func sendNotNow(ws *websocket.Conn, dir string, t *opTerms, why, bound string, next time.Time) {
|
||||
out := map[string]any{"state": opNotNow, "grant": t.Nonce, "op": t.Op, "why": why, "bound": bound}
|
||||
if !next.IsZero() {
|
||||
out["next"] = next.UTC().Format(time.RFC3339)
|
||||
}
|
||||
dcnAudit(dir, "op_grant_run", 0, t.Op+"@"+t.SHA256[:12], "not now "+t.Nonce[:8]+": "+truncate(why, mgmtAuditWhyMax))
|
||||
b, _ := json.Marshal(out)
|
||||
sendMgmt(ws, 'O', b)
|
||||
sendMgmtExit(ws, opNotNowExit)
|
||||
}
|
||||
|
||||
// ── the record ──────────────────────────────────────────────────────────────
|
||||
|
||||
// opGrantMACKey is this machine's key for its grant records, derived from its
|
||||
// encryption key (which lives in the machine's record, masked from every op,
|
||||
// and is never silently replaced).
|
||||
func opGrantMACKey() ([]byte, error) {
|
||||
_, priv, err := opEncKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h := sha256.New()
|
||||
h.Write([]byte("privatae-bastion op-grant record v1\x00"))
|
||||
h.Write(priv[:])
|
||||
return h.Sum(nil), nil
|
||||
}
|
||||
|
||||
// opGrantRecMAC covers every field of the record, the grant's nonce with it,
|
||||
// each framed by its length (argsDigest's framing).
|
||||
func opGrantRecMAC(nonce string, rec *opGrantRec) (string, error) {
|
||||
key, err := opGrantMACKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts := []string{"v1", nonce, rec.TermsCanonical, rec.Signature, rec.InstalledAt,
|
||||
strconv.Itoa(rec.UsesLeft), rec.Dropped, rec.Approver, strconv.Itoa(len(rec.Runs))}
|
||||
parts = append(parts, rec.Runs...)
|
||||
m := hmac.New(sha256.New, key)
|
||||
m.Write([]byte(argsDigest(parts)))
|
||||
return hex.EncodeToString(m.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func opGrantRecSeal(nonce string, rec *opGrantRec) error {
|
||||
mac, err := opGrantRecMAC(nonce, rec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("this machine cannot seal the grant's record: %v", err)
|
||||
}
|
||||
rec.MAC = mac
|
||||
return nil
|
||||
}
|
||||
|
||||
const opGrantTampered = "the record of this grant on this machine does not check out (changed outside the bastion); " +
|
||||
"it is held unusable — drop it and approve it again"
|
||||
|
||||
// opGrantRecCheck: the record is this machine's own (its MAC), the run log
|
||||
// reads in order and none of it is in the future, and the uses left are the
|
||||
// uses granted less the runs logged. Returns the run times.
|
||||
func opGrantRecCheck(nonce string, rec *opGrantRec, t *opTerms, now time.Time) ([]time.Time, error) {
|
||||
if rec.MAC == "" || rec.Approver == "" {
|
||||
return nil, errors.New("this grant was installed before this machine kept who approved it and what ran under it; approve it again")
|
||||
}
|
||||
want, err := opGrantRecMAC(nonce, rec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hmac.Equal([]byte(want), []byte(rec.MAC)) {
|
||||
return nil, errors.New(opGrantTampered)
|
||||
}
|
||||
runs := make([]time.Time, 0, len(rec.Runs))
|
||||
for _, s := range rec.Runs {
|
||||
r, err := time.Parse(time.RFC3339Nano, s)
|
||||
if err != nil || r.After(now.Add(deviceClockSkew)) || (len(runs) > 0 && r.Before(runs[len(runs)-1])) {
|
||||
return nil, errors.New(opGrantTampered)
|
||||
}
|
||||
runs = append(runs, r)
|
||||
}
|
||||
if t != nil && rec.UsesLeft != t.Uses-len(runs) {
|
||||
return nil, errors.New(opGrantTampered)
|
||||
}
|
||||
return runs, nil
|
||||
}
|
||||
|
||||
// ── the approver ────────────────────────────────────────────────────────────
|
||||
|
||||
// stepupFactorFingerprint names the factor enrolled at this console for a
|
||||
// pairing, without its secret: a re-enrolled factor is another one. "" when
|
||||
// none is enrolled.
|
||||
func stepupFactorFingerprint(dir string) string {
|
||||
f, err := stepupLoad(dir)
|
||||
if err != nil || f.SecretB32 == "" || f.EnrolledAt == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte("privatae-bastion stepup factor v1\x00" + f.SecretB32 + "\x00" + f.EnrolledAt))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
// consumeApprovalKeyed is consumeApproval that also names the key that gave
|
||||
// the approval: "device:<public key>" or "console:<factor fingerprint>".
|
||||
func consumeApprovalKeyed(dir, approval, purpose string) (id, key string, err error) {
|
||||
if isDeviceApproval(approval) {
|
||||
var a deviceApproval
|
||||
_ = json.Unmarshal([]byte(strings.TrimSpace(approval)), &a)
|
||||
if id, err = consumeApproval(dir, approval, purpose); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return id, "device:" + a.Key, nil
|
||||
}
|
||||
fp := stepupFactorFingerprint(dir)
|
||||
if id, err = consumeApproval(dir, approval, purpose); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if fp == "" {
|
||||
return "", "", errors.New("the approval came from this console with no factor enrolled to keep it under")
|
||||
}
|
||||
return id, "console:" + fp, nil
|
||||
}
|
||||
|
||||
// opApproverLive: is the key that approved a grant still pinned here?
|
||||
func opApproverLive(dir, approver string) bool {
|
||||
kind, key, _ := strings.Cut(approver, ":")
|
||||
switch kind {
|
||||
case "device":
|
||||
for _, k := range deviceKeys() {
|
||||
if k == key && key != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case "console":
|
||||
return key != "" && key == stepupFactorFingerprint(dir)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func opApproverWords(approver string) string {
|
||||
kind, key, _ := strings.Cut(approver, ":")
|
||||
if kind == "device" {
|
||||
return "approval device " + truncate(key, 12)
|
||||
}
|
||||
return "this console's factor " + truncate(key, 12)
|
||||
}
|
||||
|
||||
// ── taking grants back ──────────────────────────────────────────────────────
|
||||
|
||||
// opsDropWhere drops every held grant in one pairing that match says to,
|
||||
// sealing each record again. Returns the nonces dropped.
|
||||
func opsDropWhere(dcnDir, why string, match func(nonce string, rec *opGrantRec) bool) ([]string, error) {
|
||||
opsMu.Lock()
|
||||
defer opsMu.Unlock()
|
||||
f := opsLoad(dcnDir)
|
||||
var dropped []string
|
||||
for nonce, rec := range f.Grants {
|
||||
if rec.Dropped != "" || !match(nonce, &rec) {
|
||||
continue
|
||||
}
|
||||
rec.Dropped = opNow().UTC().Format(time.RFC3339) + " " + why
|
||||
rec.UsesLeft = 0
|
||||
// A record that can no longer be sealed is still dropped: the drop is
|
||||
// what makes it unusable, and an unsealed record is unusable anyway.
|
||||
_ = opGrantRecSeal(nonce, &rec)
|
||||
f.Grants[nonce] = rec
|
||||
dropped = append(dropped, nonce)
|
||||
}
|
||||
if len(dropped) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return dropped, opsSave(dcnDir, f)
|
||||
}
|
||||
|
||||
// opsDropApprovedBy drops, in every pairing, the grants a device key approved
|
||||
// (the key is being unpinned). Returns how many.
|
||||
func opsDropApprovedBy(approver string) int {
|
||||
cfgs, err := loadPairings()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, c := range cfgs {
|
||||
if c == nil || c.dcnDir() == "" {
|
||||
continue
|
||||
}
|
||||
d, _ := opsDropWhere(c.dcnDir(), "its approving key was unpinned", func(_ string, rec *opGrantRec) bool {
|
||||
return rec.Approver == approver
|
||||
})
|
||||
n += len(d)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// op_grant_drop <grant nonce | all>: the Host (or the app, through it) takes
|
||||
// a grant back. A tighten (writes.go opTightens): never held by the
|
||||
// quarantine, never waiting on an approval. A grant not held here is already
|
||||
// gone, which is what was asked for.
|
||||
func handleOpGrantDrop(ws *websocket.Conn, dir string, args []string) {
|
||||
if dir == "" || len(args) < 1 || strings.TrimSpace(args[0]) == "" {
|
||||
opFail(ws, "op_grant_drop: need <grant> or all")
|
||||
return
|
||||
}
|
||||
which := strings.TrimSpace(args[0])
|
||||
dropped, err := opsDropWhere(dir, "taken back by the host", func(nonce string, _ *opGrantRec) bool {
|
||||
return which == "all" || nonce == which
|
||||
})
|
||||
if err != nil {
|
||||
opFail(ws, "op_grant_drop: could not record the drop: "+err.Error())
|
||||
return
|
||||
}
|
||||
if dropped == nil {
|
||||
dropped = []string{}
|
||||
}
|
||||
for _, n := range dropped {
|
||||
dcnAudit(dir, "op_grant_drop", 0, truncate(n, 8), "dropped by the host")
|
||||
}
|
||||
b, _ := json.Marshal(map[string]any{"state": "dropped", "dropped": dropped})
|
||||
sendMgmt(ws, 'O', b)
|
||||
sendMgmtExit(ws, 0)
|
||||
}
|
||||
@@ -79,6 +79,15 @@ func opRunAction(_ string, _ *opManifest, _ *opTerms, _, _ string, _ []string, _
|
||||
return nil, errors.New(why)
|
||||
}
|
||||
|
||||
// opVerbsReport: no verb runs on this platform.
|
||||
func opVerbsReport() map[string]any {
|
||||
out := map[string]any{}
|
||||
for _, verb := range opVerbNames() {
|
||||
out[verb] = map[string]any{"ok": false, "why": "no op runs on this platform yet"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func opActionOuterMain(_ string) int { return 125 }
|
||||
func opActionChildMain(_ string) int { return 125 }
|
||||
func opActionProbeMain(_ string) int { return 125 }
|
||||
|
||||
@@ -245,8 +245,10 @@ func planRunLine(dir, planB64, nArg, recB64 string, credBlobs []string) (map[str
|
||||
if _, err := opOpenCreds(credBlobs, m, p.Nonce, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok, why := opActionAvailable(); !ok {
|
||||
return nil, errors.New("this machine cannot run an action op: " + why)
|
||||
if !m.verbsOnly() {
|
||||
if ok, why := opActionCheck(); !ok {
|
||||
return nil, errors.New("this machine cannot run an action op: " + why)
|
||||
}
|
||||
}
|
||||
if err := planRecordVersion(p.Pipeline, p.Version); err != nil {
|
||||
return nil, fmt.Errorf("could not record the plan's version: %v", err)
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
// ops_skill_bounds_test.go — a grant held to a window and a rate on the
|
||||
// machine (ops_bounds.go): "not now" is no strike and spends nothing, the run
|
||||
// log holds across a restart and refuses being edited, a grant dies with the
|
||||
// key that approved it, and a drop goes through a quarantine. And the op with
|
||||
// no program (interpreter: none): no run file, no sandbox needed, while a
|
||||
// program op still needs one.
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func userFixture() []opFile {
|
||||
files := opFixture("/srv/optest", "")
|
||||
files[0].B64 = base64.StdEncoding.EncodeToString([]byte(strings.Replace(fmt.Sprintf(opTestManifest, "/srv/optest"),
|
||||
"privilege: root", "privilege: user", 1)))
|
||||
return files
|
||||
}
|
||||
|
||||
// grantBox installs a user grant with these bounds under a console approval,
|
||||
// and stands in for the sandbox: each run under it is counted, never run.
|
||||
func grantBox(t *testing.T, bounds string, uses int, approve func(dcn, purpose string) string) (dcn, nonce string, ran *int) {
|
||||
t.Helper()
|
||||
dcn, priv := opTestBox(t)
|
||||
files := userFixture()
|
||||
sha, _ := opDirDigest(files)
|
||||
gt := opTerm(opKindGrant, sha, "", bounds, uses, time.Hour, "user")
|
||||
purpose, err := purposeForOpGrant(&opTerms{Server: gt["server"].(string), Op: "write-hello", SHA256: sha,
|
||||
Bounds: bounds, Expires: gt["expires"].(string), Uses: uses})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if approve == nil {
|
||||
approve = grantConsoleApproval(t)
|
||||
}
|
||||
gt["approval"] = approve(dcn, purpose)
|
||||
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(signTermsPair(t, priv, gt), bundleB64(files))}, "", dcn); code != 0 {
|
||||
t.Fatalf("the grant installs: %d %s", code, errOut)
|
||||
}
|
||||
n := 0
|
||||
prev := opGrantExec
|
||||
opGrantExec = func(_ string, tm *opTerms, mode, _, _ string, _ []string) (*opReport, *opRunRecord, error) {
|
||||
n++
|
||||
id, _ := newID()
|
||||
return &opReport{Mode: mode, Changes: []opChange{}}, &opRunRecord{ID: id, Op: tm.Op, SHA256: tm.SHA256, Mode: mode}, nil
|
||||
}
|
||||
t.Cleanup(func() { opGrantExec = prev })
|
||||
return dcn, gt["nonce"].(string), &n
|
||||
}
|
||||
|
||||
func grantConsoleApproval(t *testing.T) func(dcn, purpose string) string {
|
||||
return func(dcn, purpose string) string {
|
||||
t.Helper()
|
||||
enrollFactor(t, dcn)
|
||||
raw, _ := b32decode(tSecret)
|
||||
a, err := stepupIssueApproval(dcn, hotp(raw, time.Now().Unix()/stepupPeriod), purpose)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
}
|
||||
|
||||
func atClock(t *testing.T, at *time.Time) {
|
||||
prev := opNow
|
||||
opNow = func() time.Time { return *at }
|
||||
t.Cleanup(func() { opNow = prev })
|
||||
}
|
||||
|
||||
const grantParams = `{"name":"x","count":1,"kind":"a"}`
|
||||
|
||||
func grantApply(t *testing.T, dcn, nonce string) (string, string, int) {
|
||||
t.Helper()
|
||||
return driveJob(t, tAnchor, mgmtJob{Op: "op_grant_run", Args: []string{nonce, opModeApply, grantParams}}, "", dcn)
|
||||
}
|
||||
|
||||
func strikes() int { return len(quarantineLoad().Pairing[tAnchor].refusalsNow()) }
|
||||
|
||||
func usesLeft(t *testing.T, dcn, nonce string) int {
|
||||
t.Helper()
|
||||
rec, ok := opsLoad(dcn).Grants[nonce]
|
||||
if !ok {
|
||||
t.Fatal("no record")
|
||||
}
|
||||
return rec.UsesLeft
|
||||
}
|
||||
|
||||
// ── the grammar ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestGrantRunBoundsGrammar(t *testing.T) {
|
||||
setupBox(t)
|
||||
m, _ := parseOpManifest([]byte(fmt.Sprintf(opTestManifest, "/srv/optest")))
|
||||
for _, ok := range []string{
|
||||
`{"_window":{"from":"02:00","to":"04:00"}}`,
|
||||
`{"_window":{"days":["mon","fri"],"from":"22:00","to":"06:00","tz":"Europe/Berlin"},"count":{"max":2}}`,
|
||||
`{"_rate":{"per_day":3,"min_interval_s":600}}`,
|
||||
`{"_rate":{"per_day":1}}`,
|
||||
`{"_window":null,"_rate":null}`,
|
||||
} {
|
||||
if _, err := parseGrantBounds(m, ok); err != nil {
|
||||
t.Errorf("%s parses: %v", ok, err)
|
||||
}
|
||||
}
|
||||
for name, bad := range map[string]string{
|
||||
"an unknown run bound": `{"_when":{"from":"02:00","to":"04:00"}}`,
|
||||
"a day that is not one": `{"_window":{"days":["mon","funday"],"from":"02:00","to":"04:00"}}`,
|
||||
"a day named twice": `{"_window":{"days":["mon","mon"],"from":"02:00","to":"04:00"}}`,
|
||||
"no days at all": `{"_window":{"days":[],"from":"02:00","to":"04:00"}}`,
|
||||
"a window of no length": `{"_window":{"from":"02:00","to":"02:00"}}`,
|
||||
"a clock past 23:59": `{"_window":{"from":"24:00","to":"02:00"}}`,
|
||||
"a zone nobody has": `{"_window":{"from":"02:00","to":"04:00","tz":"Mars/Olympus"}}`,
|
||||
"the machine's local zone": `{"_window":{"from":"02:00","to":"04:00","tz":"Local"}}`,
|
||||
"a field the window lacks": `{"_window":{"from":"02:00","to":"04:00","every":"day"}}`,
|
||||
"a rate that bounds none": `{"_rate":{}}`,
|
||||
"zero a day": `{"_rate":{"per_day":0}}`,
|
||||
"more a day than uses": `{"_rate":{"per_day":1001}}`,
|
||||
"an interval past 30 days": `{"_rate":{"min_interval_s":2592001}}`,
|
||||
"a field the rate lacks": `{"_rate":{"per_hour":3}}`,
|
||||
"a rate as a string": `{"_rate":"3/day"}`,
|
||||
} {
|
||||
if _, err := parseGrantBounds(m, bad); err == nil {
|
||||
t.Errorf("%s must be refused: %s", name, bad)
|
||||
}
|
||||
}
|
||||
// A window that wraps midnight belongs to the day it opens on.
|
||||
b, _ := parseGrantBounds(m, `{"_window":{"days":["fri"],"from":"22:00","to":"06:00","tz":"UTC"}}`)
|
||||
for at, want := range map[string]bool{
|
||||
"2026-10-30T23:00:00Z": true, // Friday night
|
||||
"2026-10-31T03:00:00Z": true, // Saturday morning, the Friday window
|
||||
"2026-10-30T03:00:00Z": false, // Friday morning: Thursday's window, not listed
|
||||
"2026-10-31T07:00:00Z": false,
|
||||
} {
|
||||
ts, _ := time.Parse(time.RFC3339, at)
|
||||
if got := b.Window.inside(ts); got != want {
|
||||
t.Errorf("%s inside=%v, want %v", at, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── not now: the window ─────────────────────────────────────────────────────
|
||||
|
||||
func TestGrantOutsideItsWindowIsNotNowAndSpendsNothing(t *testing.T) {
|
||||
// Thursday noon UTC; the window is Monday 02:00-04:00 in Berlin.
|
||||
now, _ := time.Parse(time.RFC3339, "2026-10-29T12:00:00Z")
|
||||
atClock(t, &now)
|
||||
dcn, nonce, ran := grantBox(t, `{"_window":{"days":["mon"],"from":"02:00","to":"04:00","tz":"Europe/Berlin"}}`, 5, nil)
|
||||
before := strikes()
|
||||
out, errOut, code := grantApply(t, dcn, nonce)
|
||||
if code != opNotNowExit || !strings.Contains(out, `"state":"not_now"`) || !strings.Contains(out, `"bound":"_window"`) {
|
||||
t.Fatalf("outside the window: not now: %d %s %s", code, out, errOut)
|
||||
}
|
||||
// Berlin is UTC+1 after 25 October: Monday 02:00 there is 01:00 UTC.
|
||||
if !strings.Contains(out, `"next":"2026-11-02T01:00:00Z"`) {
|
||||
t.Fatalf("it says when the window opens: %s", out)
|
||||
}
|
||||
if *ran != 0 || usesLeft(t, dcn, nonce) != 5 || strikes() != before {
|
||||
t.Fatalf("nothing ran, no use spent, no strike: ran=%d uses=%d strikes %d->%d", *ran, usesLeft(t, dcn, nonce), before, strikes())
|
||||
}
|
||||
// A dry run changes nothing and is never held to the window.
|
||||
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_grant_run", Args: []string{nonce, opModeDryRun, grantParams}}, "", dcn); code != 0 {
|
||||
t.Fatalf("a dry run outside the window: %d %s", code, errOut)
|
||||
}
|
||||
// Bad params outside the window are still refused, with the strike.
|
||||
if _, _, code := driveJob(t, tAnchor, mgmtJob{Op: "op_grant_run", Args: []string{nonce, opModeApply, `{"name":"x","count":9,"kind":"a"}`}}, "", dcn); code != 126 {
|
||||
t.Fatalf("arguments outside the op are refused, window or not: %d", code)
|
||||
}
|
||||
now, _ = time.Parse(time.RFC3339, "2026-11-02T01:30:00Z")
|
||||
if _, errOut, code := grantApply(t, dcn, nonce); code != 0 || *ran != 2 || usesLeft(t, dcn, nonce) != 4 {
|
||||
t.Fatalf("inside the window it runs and spends one use: %d %s ran=%d uses=%d", code, errOut, *ran, usesLeft(t, dcn, nonce))
|
||||
}
|
||||
}
|
||||
|
||||
// ── not now: the rate ───────────────────────────────────────────────────────
|
||||
|
||||
func TestGrantInsideItsMinIntervalDoesNotRun(t *testing.T) {
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
atClock(t, &now)
|
||||
dcn, nonce, ran := grantBox(t, `{"_rate":{"min_interval_s":600}}`, 5, nil)
|
||||
if _, errOut, code := grantApply(t, dcn, nonce); code != 0 {
|
||||
t.Fatalf("the first run: %d %s", code, errOut)
|
||||
}
|
||||
before := strikes()
|
||||
first := now
|
||||
now = now.Add(5 * time.Minute)
|
||||
out, _, code := grantApply(t, dcn, nonce)
|
||||
if code != opNotNowExit || !strings.Contains(out, `"bound":"_rate"`) ||
|
||||
!strings.Contains(out, `"next":"`+first.Add(10*time.Minute).Format(time.RFC3339)+`"`) {
|
||||
t.Fatalf("five minutes after a run, with ten approved between: not now: %d %s", code, out)
|
||||
}
|
||||
if *ran != 1 || usesLeft(t, dcn, nonce) != 4 || strikes() != before {
|
||||
t.Fatalf("the second did not run, spend or strike: ran=%d uses=%d", *ran, usesLeft(t, dcn, nonce))
|
||||
}
|
||||
now = first.Add(11 * time.Minute)
|
||||
if _, _, code := grantApply(t, dcn, nonce); code != 0 || *ran != 2 {
|
||||
t.Fatalf("past the interval it runs: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantPerDayHoldsAcrossARestart(t *testing.T) {
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
atClock(t, &now)
|
||||
dcn, nonce, ran := grantBox(t, `{"_rate":{"per_day":2}}`, 10, nil)
|
||||
start := now
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, errOut, code := grantApply(t, dcn, nonce); code != 0 {
|
||||
t.Fatalf("run %d: %d %s", i, code, errOut)
|
||||
}
|
||||
now = now.Add(time.Hour)
|
||||
}
|
||||
// The run log is on disk, in the record, not in this process.
|
||||
raw, err := os.ReadFile(opsFilePath(dcn))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var f opsFile
|
||||
if json.Unmarshal(raw, &f) != nil || len(f.Grants[nonce].Runs) != 2 {
|
||||
t.Fatalf("both runs are in ops.json: %s", raw)
|
||||
}
|
||||
// A restart: another process reads the same record. Copy the pairing's
|
||||
// state to a fresh place and ask there.
|
||||
moved := t.TempDir()
|
||||
if err := copyTree(dcn, moved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _, code := grantApply(t, moved, nonce)
|
||||
if code != opNotNowExit || !strings.Contains(out, "2 run(s) in the last 24 hours") {
|
||||
t.Fatalf("the third run in a day, after a restart: not now: %d %s", code, out)
|
||||
}
|
||||
if *ran != 2 {
|
||||
t.Fatalf("it did not run: %d", *ran)
|
||||
}
|
||||
// 24 hours after the first, one slot is free again.
|
||||
now = start.Add(24*time.Hour + time.Second)
|
||||
if _, errOut, code := grantApply(t, moved, nonce); code != 0 {
|
||||
t.Fatalf("a day after the first run: %d %s", code, errOut)
|
||||
}
|
||||
}
|
||||
|
||||
func copyTree(from, to string) error {
|
||||
return filepath.Walk(from, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(from, p)
|
||||
dst := filepath.Join(to, rel)
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(dst, 0o755)
|
||||
}
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, raw, info.Mode().Perm())
|
||||
})
|
||||
}
|
||||
|
||||
// ── the record ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestGrantWithATamperedRunLogIsNoGrant(t *testing.T) {
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
atClock(t, &now)
|
||||
for name, edit := range map[string]func(r *opGrantRec){
|
||||
"the run taken out and the use given back": func(r *opGrantRec) { r.Runs = nil; r.UsesLeft++ },
|
||||
"the run taken out": func(r *opGrantRec) { r.Runs = nil },
|
||||
"a use given back": func(r *opGrantRec) { r.UsesLeft++ },
|
||||
"the run moved a day back": func(r *opGrantRec) { r.Runs[0] = now.Add(-25 * time.Hour).Format(time.RFC3339Nano) },
|
||||
"another approver": func(r *opGrantRec) { r.Approver = "console:0000" },
|
||||
"no seal (an older bastion's record)": func(r *opGrantRec) { r.MAC = "" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dcn, nonce, ran := grantBox(t, `{"_rate":{"per_day":1}}`, 5, nil)
|
||||
if _, errOut, code := grantApply(t, dcn, nonce); code != 0 {
|
||||
t.Fatalf("the first run: %d %s", code, errOut)
|
||||
}
|
||||
f := opsLoad(dcn)
|
||||
r := f.Grants[nonce]
|
||||
edit(&r)
|
||||
f.Grants[nonce] = r
|
||||
if err := opsSave(dcn, f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, errOut, code := grantApply(t, dcn, nonce)
|
||||
if code != 126 || (!strings.Contains(errOut, "does not check out") && !strings.Contains(errOut, "approve it again")) {
|
||||
t.Fatalf("an edited record holds no grant: %d %q", code, errOut)
|
||||
}
|
||||
if *ran != 1 {
|
||||
t.Fatalf("nothing ran under it: %d", *ran)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantDiesWithTheKeyThatApprovedIt(t *testing.T) {
|
||||
var pub string
|
||||
byDevice := func(_, purpose string) string {
|
||||
var err error
|
||||
if pub, err = deviceInit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pinDeviceKey(pub); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, err := deviceSign(purpose, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
dcn, nonce, ran := grantBox(t, `{"count":{"max":2}}`, 5, byDevice)
|
||||
if rec := opsLoad(dcn).Grants[nonce]; rec.Approver != "device:"+pub {
|
||||
t.Fatalf("the approving key is kept with the grant: %q", rec.Approver)
|
||||
}
|
||||
// The grant as the pairing holds it, in the pairing's own state folder
|
||||
// (unpinning walks every pairing's).
|
||||
cfgs, err := loadPairings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
home := pairingFor(cfgs, tAnchor).dcnDir()
|
||||
if err := copyTree(dcn, home); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dcn = home
|
||||
if _, errOut, code := grantApply(t, dcn, nonce); code != 0 {
|
||||
t.Fatalf("a run under it: %d %s", code, errOut)
|
||||
}
|
||||
if err := unpinDeviceKey(pub); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, errOut, code := grantApply(t, dcn, nonce); code != 126 || !strings.Contains(errOut, "no longer pinned") {
|
||||
t.Fatalf("its key unpinned, the grant is dead: %d %q", code, errOut)
|
||||
}
|
||||
// Unpinning drops what the key approved, so pinning it again does not
|
||||
// bring the grant back.
|
||||
if n := opsDropApprovedBy("device:" + pub); n != 1 {
|
||||
t.Fatalf("one grant dropped with its key: %d", n)
|
||||
}
|
||||
if err := pinDeviceKey(pub); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, errOut, code := grantApply(t, dcn, nonce); code != 126 || !strings.Contains(errOut, "no such op grant") {
|
||||
t.Fatalf("pinned again, the grant stays dropped: %d %q", code, errOut)
|
||||
}
|
||||
if *ran != 1 {
|
||||
t.Fatalf("one run only: %d", *ran)
|
||||
}
|
||||
// A console approval dies with the factor: re-enrolled is another one.
|
||||
dcn2, nonce2, _ := grantBox(t, `{"count":{"max":2}}`, 5, nil)
|
||||
if err := stepupSave(dcn2, &stepupFile{SecretB32: tSecret, EnrolledAt: "2026-10-29T00:00:00Z", LastCounter: -1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, errOut, code := grantApply(t, dcn2, nonce2); code != 126 || !strings.Contains(errOut, "no longer pinned") {
|
||||
t.Fatalf("its factor re-enrolled, the grant is dead: %d %q", code, errOut)
|
||||
}
|
||||
}
|
||||
|
||||
// ── taking it back ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestGrantDropGoesThroughAQuarantine(t *testing.T) {
|
||||
if !opTightens("op_grant_drop") || !ownApproval("op_grant_drop") {
|
||||
t.Fatal("op_grant_drop is a tighten: no approval, no quarantine")
|
||||
}
|
||||
dcn, nonce, _ := grantBox(t, `{"count":{"max":2}}`, 5, nil)
|
||||
now := time.Now()
|
||||
for i := 0; i < quarantineStrikes; i++ {
|
||||
noteRefusal(tAnchor, "op_apply", "test strike", true, now)
|
||||
}
|
||||
if quarantinedUntil(tAnchor, time.Now()).IsZero() {
|
||||
t.Fatal("the host is quarantined here")
|
||||
}
|
||||
if _, errOut, code := grantApply(t, dcn, nonce); code != 126 || !strings.Contains(errOut, "quarantined") {
|
||||
t.Fatalf("a run while quarantined is refused: %d %q", code, errOut)
|
||||
}
|
||||
out, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_grant_drop", Args: []string{nonce}}, "", dcn)
|
||||
if code != 0 || !strings.Contains(out, nonce) {
|
||||
t.Fatalf("a drop while quarantined goes through: %d %s %s", code, out, errOut)
|
||||
}
|
||||
if _, _, err := opsHeldGrant(dcn, nonce); err == nil {
|
||||
t.Fatal("the grant is gone")
|
||||
}
|
||||
// A drop of what is not held is already done; all takes every one.
|
||||
if out, _, code := driveJob(t, tAnchor, mgmtJob{Op: "op_grant_drop", Args: []string{nonce}}, "", dcn); code != 0 || !strings.Contains(out, `"dropped":[]`) {
|
||||
t.Fatalf("dropping it again: %d %s", code, out)
|
||||
}
|
||||
dcn2, nonce2, _ := grantBox(t, `{"count":{"max":2}}`, 5, nil)
|
||||
for i := 0; i < quarantineStrikes; i++ {
|
||||
noteRefusal(tAnchor, "op_apply", "test strike", true, time.Now())
|
||||
}
|
||||
if out, _, code := driveJob(t, tAnchor, mgmtJob{Op: "op_grant_drop", Args: []string{"all"}}, "", dcn2); code != 0 || !strings.Contains(out, nonce2) {
|
||||
t.Fatalf("drop all: %d %s", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
// ── interpreter: none ───────────────────────────────────────────────────────
|
||||
|
||||
const verbsOnlyManifest = `name: restart-web
|
||||
version: 1.0.0
|
||||
summary: Restart the web service.
|
||||
kind: action
|
||||
target: machine
|
||||
privilege: root
|
||||
interpreter: none
|
||||
time_limit: 120
|
||||
verbs:
|
||||
- {verb: unit.restart, unit: web.service}
|
||||
- {verb: logs.check, unit: web.service, must_not: "emerg", lines: "200"}
|
||||
modes: [dry_run, apply, verify]
|
||||
undo: none
|
||||
`
|
||||
|
||||
func verbsOnlyFiles(withRun bool) []opFile {
|
||||
files := []opFile{{Path: "op.yaml", Mode: 0o644, B64: base64.StdEncoding.EncodeToString([]byte(verbsOnlyManifest))}}
|
||||
if withRun {
|
||||
files = append(files, opFile{Path: "run", Mode: 0o755, B64: base64.StdEncoding.EncodeToString([]byte("#!/bin/sh\nid\n"))})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func TestVerbsOnlyManifest(t *testing.T) {
|
||||
setupBox(t)
|
||||
m, err := parseOpManifest([]byte(verbsOnlyManifest))
|
||||
if err != nil || !m.verbsOnly() {
|
||||
t.Fatalf("a verbs-only op parses: %v", err)
|
||||
}
|
||||
for name, bad := range map[string]string{
|
||||
"interpreter none on a files op": strings.Replace(fmt.Sprintf(opTestManifest, "/srv/optest"), "interpreter: /bin/sh", "interpreter: none", 1),
|
||||
"no verbs": strings.Replace(strings.Replace(verbsOnlyManifest, "privilege: root", "privilege: user", 1), "verbs:\n - {verb: unit.restart, unit: web.service}\n - {verb: logs.check, unit: web.service, must_not: \"emerg\", lines: \"200\"}\n", "", 1),
|
||||
"a destination": strings.Replace(verbsOnlyManifest, "time_limit: 120\n", "time_limit: 120\ndestinations: [\"forge.example.com:443\"]\n", 1),
|
||||
"a builder": strings.Replace(verbsOnlyManifest, "time_limit: 120\n", "time_limit: 120\nbuilder: true\n", 1),
|
||||
"a credential no verb names": strings.Replace(verbsOnlyManifest, "time_limit: 120\n", "time_limit: 120\ncredentials: [forge-read]\n", 1),
|
||||
"a read": strings.Replace(verbsOnlyManifest, "time_limit: 120\n", "time_limit: 120\nreads: [\"/srv/data/\"]\n", 1),
|
||||
} {
|
||||
if _, err := parseOpManifest([]byte(bad)); err == nil {
|
||||
t.Errorf("%s must be refused", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerbsOnlyOpShippingRunIsRefused(t *testing.T) {
|
||||
dcn, priv := opTestBox(t)
|
||||
files := verbsOnlyFiles(true)
|
||||
sha, _ := opDirDigest(files)
|
||||
run := opTerm(opKindRun, sha, `{}`, "", 1, time.Hour, "root")
|
||||
run["op"] = "restart-web"
|
||||
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(signTermsPair(t, priv, run), bundleB64(files))}, "", dcn); code != 126 || !strings.Contains(errOut, "ships no run") {
|
||||
t.Fatalf("a verbs-only op that ships a program is refused: %d %q", code, errOut)
|
||||
}
|
||||
files = verbsOnlyFiles(false)
|
||||
sha, _ = opDirDigest(files)
|
||||
run["sha256"] = sha
|
||||
if out, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(signTermsPair(t, priv, run), bundleB64(files))}, "", dcn); code != 0 || !strings.Contains(out, `"held"`) {
|
||||
t.Fatalf("without it, held: %d %s %s", code, out, errOut)
|
||||
}
|
||||
// And a program op without its run is refused, as before.
|
||||
prog := opFixture("/srv/optest", "")[:1]
|
||||
psha, _ := opDirDigest(prog)
|
||||
pr := opTerm(opKindRun, psha, grantParams, "", 1, time.Hour, "root")
|
||||
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(signTermsPair(t, priv, pr), bundleB64(prog))}, "", dcn); code != 126 || !strings.Contains(errOut, "needs op.yaml and run") {
|
||||
t.Fatalf("a program op with no run: %d %q", code, errOut)
|
||||
}
|
||||
}
|
||||
|
||||
func noOverlay(t *testing.T) {
|
||||
ps, pa := opSandboxCheck, opActionCheck
|
||||
opSandboxCheck = func() (bool, string) { return false, "the kernel offers no overlay filesystem" }
|
||||
opActionCheck = func() (bool, string) { return false, "the kernel offers no overlay filesystem" }
|
||||
t.Cleanup(func() { opSandboxCheck, opActionCheck = ps, pa })
|
||||
}
|
||||
|
||||
func TestProgramOpStillRefusedWithoutTheSandbox(t *testing.T) {
|
||||
noOverlay(t)
|
||||
dcn, priv := opTestBox(t)
|
||||
files := opFixture("/srv/optest", "")
|
||||
sha, _ := opDirDigest(files)
|
||||
pair := signTermsPair(t, priv, opTerm(opKindRun, sha, grantParams, "", 1, time.Hour, "root"))
|
||||
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(pair, bundleB64(files))}, "", dcn); code != 0 {
|
||||
t.Fatalf("held: %s", errOut)
|
||||
}
|
||||
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_dry_run", Args: pair}, "", dcn); code != 126 || !strings.Contains(errOut, noSandboxHere) {
|
||||
t.Fatalf("a files op with no sandbox: %d %q", code, errOut)
|
||||
}
|
||||
// An action op with a program and verbs: the program needs the sandbox.
|
||||
man := strings.Replace(verbsOnlyManifest, "interpreter: none", "interpreter: /bin/sh", 1)
|
||||
afiles := []opFile{{Path: "op.yaml", Mode: 0o644, B64: base64.StdEncoding.EncodeToString([]byte(man))},
|
||||
{Path: "run", Mode: 0o755, B64: base64.StdEncoding.EncodeToString([]byte("#!/bin/sh\n"))}}
|
||||
asha, _ := opDirDigest(afiles)
|
||||
at := opTerm(opKindRun, asha, `{}`, "", 1, time.Hour, "root")
|
||||
at["op"] = "restart-web"
|
||||
apair := signTermsPair(t, priv, at)
|
||||
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(apair, bundleB64(afiles))}, "", dcn); code != 0 {
|
||||
t.Fatalf("held: %s", errOut)
|
||||
}
|
||||
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_dry_run", Args: apair}, "", dcn); code != 126 || !strings.Contains(errOut, noSandboxHere) {
|
||||
t.Fatalf("an action op with a program and no sandbox: %d %q", code, errOut)
|
||||
}
|
||||
}
|
||||
+283
-13
@@ -19,10 +19,13 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -81,10 +84,16 @@ func opCmd(ctx context.Context, name string, args ...string) (string, error) {
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
// opRunAction runs one mode of an action op (ops.go opRunTerms).
|
||||
// opRunAction runs one mode of an action op (ops.go opRunTerms). An op with
|
||||
// no program (interpreter: none) runs no unit and needs no sandbox: its dry
|
||||
// run is the change list its verbs read, its apply the verbs and the log
|
||||
// checks, and what it needs of the machine is each verb's own tool
|
||||
// (opVerbProbe).
|
||||
func opRunAction(opDir string, m *opManifest, t *opTerms, mode, runID string, blobs []string, pc *opPlanCtx) (*opReport, error) {
|
||||
if ok, why := opActionAvailable(); !ok {
|
||||
return nil, errors.New("this machine cannot run an action op: " + why)
|
||||
if !m.verbsOnly() {
|
||||
if ok, why := opActionCheck(); !ok {
|
||||
return nil, errors.New("this machine cannot run an action op: " + why)
|
||||
}
|
||||
}
|
||||
if mode != opModeDryRun && mode != opModeApply {
|
||||
return nil, errors.New("an action op is run as dry_run or apply; its verify runs inside the apply")
|
||||
@@ -97,6 +106,11 @@ func opRunAction(opDir string, m *opManifest, t *opTerms, mode, runID string, bl
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.verbsOnly() {
|
||||
if why := opVerbProbe(verbs); why != "" {
|
||||
return nil, errors.New(noVerbToolHere + ": " + why)
|
||||
}
|
||||
}
|
||||
creds, err := opOpenCreds(blobs, m, t.Nonce, mode == opModeDryRun)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -108,6 +122,9 @@ func opRunAction(opDir string, m *opManifest, t *opTerms, mode, runID string, bl
|
||||
}
|
||||
planSHA := opChangesDigest(plan)
|
||||
if mode == opModeDryRun {
|
||||
if m.verbsOnly() {
|
||||
return &opReport{Mode: opModeDryRun, Changes: plan, ChangesSHA256: planSHA}, nil
|
||||
}
|
||||
rep, _, err := opRunActionProgram(opDir, m, opModeDryRun, t.Params, nil, runID)
|
||||
if rep != nil {
|
||||
rep.Changes, rep.ChangesSHA256 = plan, planSHA
|
||||
@@ -122,9 +139,15 @@ func opRunAction(opDir string, m *opManifest, t *opTerms, mode, runID string, bl
|
||||
truncate(planSHA, 12), truncate(t.Changes, 12))
|
||||
}
|
||||
start := time.Now()
|
||||
rep, result, err := opRunActionProgram(opDir, m, opModeApply, t.Params, opProgramCreds(verbs, creds), runID)
|
||||
if rep == nil {
|
||||
return nil, err
|
||||
var rep *opReport
|
||||
var result []byte
|
||||
if m.verbsOnly() {
|
||||
rep = &opReport{Mode: opModeApply}
|
||||
} else {
|
||||
rep, result, err = opRunActionProgram(opDir, m, opModeApply, t.Params, opProgramCreds(verbs, creds), runID)
|
||||
if rep == nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
rep.Changes, rep.ChangesSHA256 = plan, planSHA
|
||||
if err != nil {
|
||||
@@ -135,6 +158,11 @@ func opRunAction(opDir string, m *opManifest, t *opTerms, mode, runID string, bl
|
||||
if err != nil {
|
||||
return rep, err
|
||||
}
|
||||
for _, im := range images {
|
||||
if err := opRegistryOK(context.Background(), im.Image); err != nil {
|
||||
return rep, err
|
||||
}
|
||||
}
|
||||
receipts, err := opIssueReceipts(runID, t.Op, t.SHA256, t.Params, images)
|
||||
rep.Receipts = receipts
|
||||
if err != nil {
|
||||
@@ -171,10 +199,12 @@ func opRunAction(opDir string, m *opManifest, t *opTerms, mode, runID string, bl
|
||||
// image), then each log check, reading only what came after the apply
|
||||
// began.
|
||||
var failed []string
|
||||
if ver, _, err := opRunActionProgram(opDir, m, opModeVerify, t.Params, nil, runID); err != nil {
|
||||
failed = append(failed, "verify: "+err.Error())
|
||||
} else if ver.Exit != 0 {
|
||||
failed = append(failed, fmt.Sprintf("verify exited %d", ver.Exit))
|
||||
if !m.verbsOnly() {
|
||||
if ver, _, err := opRunActionProgram(opDir, m, opModeVerify, t.Params, nil, runID); err != nil {
|
||||
failed = append(failed, "verify: "+err.Error())
|
||||
} else if ver.Exit != 0 {
|
||||
failed = append(failed, fmt.Sprintf("verify exited %d", ver.Exit))
|
||||
}
|
||||
}
|
||||
for _, v := range verbs {
|
||||
if v.Verb != opVerbLogs {
|
||||
@@ -508,7 +538,7 @@ func opCredHeaderEnv(creds map[string]string, name string) ([]string, error) {
|
||||
return []string{"GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=http.extraHeader", "GIT_CONFIG_VALUE_0=Authorization: token " + tok}, nil
|
||||
}
|
||||
|
||||
func opTreeDo(_ context.Context, v opVerbLine, creds map[string]string, want string) (string, error) {
|
||||
func opTreeDo(ctx context.Context, v opVerbLine, creds map[string]string, want string) (string, error) {
|
||||
defer opLockTarget(v)()
|
||||
path, to := v.Args["path"], v.Args["commit"]
|
||||
head, branch, err := opTreeState(v)
|
||||
@@ -525,7 +555,11 @@ func opTreeDo(_ context.Context, v opVerbLine, creds map[string]string, want str
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out, err := opTreeGit(path, env, "fetch", "--no-tags", "--quiet", v.Args["repo"], "+refs/heads/*:refs/remotes/origin/*"); err != nil {
|
||||
pin, err := opFetchPin(ctx, v.Args["repo"])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out, err := opTreeGit(path, env, append(pin, "fetch", "--no-tags", "--quiet", v.Args["repo"], "+refs/heads/*:refs/remotes/origin/*")...); err != nil {
|
||||
return "", errors.New("fetch failed: " + truncate(out, 300))
|
||||
}
|
||||
if _, err := opTreeGit(path, nil, "cat-file", "-e", to+"^{commit}"); err != nil {
|
||||
@@ -702,6 +736,9 @@ func opImageCheck(ctx context.Context, v opVerbLine, pc *opPlanCtx) (*opImageFac
|
||||
if rc.Image != a["image"] {
|
||||
return nil, fmt.Errorf("the receipt for %s is for %s, not %s", truncate(a["digest"], 19), rc.Image, a["image"])
|
||||
}
|
||||
if err := opRegistryOK(ctx, a["image"]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The repository ancestry is checked in is the manifest's, never one a
|
||||
// program reported: the check fetches it, with the verb's credential.
|
||||
if rc.Source != a["repo"] {
|
||||
@@ -806,12 +843,156 @@ func opImageDo(ctx context.Context, v opVerbLine, creds map[string]string, pc *o
|
||||
return truncate(f.liveDigest, 19) + " → " + truncate(a["digest"], 19) + ", rolled out", nil
|
||||
}
|
||||
|
||||
// opFetchPin is the git options a verb's fetch of repo carries. A verb
|
||||
// fetches with the bastion's own root, outside the program's sandbox
|
||||
// (tree.checkout its tree's repository, k8s.set_image its source to check
|
||||
// ancestry), so it is held to the rule a destination is: the repository's
|
||||
// host is resolved here, once, every address it answers must be one an op may
|
||||
// reach, and git is held to the first (http.curloptResolve, as the mirror
|
||||
// pins its fetch) so a second lookup cannot answer another. On every machine
|
||||
// that is never this machine, its loopback, a link-local address or a cloud's
|
||||
// metadata service (opForbiddenDest); on a machine no fleet has anchored,
|
||||
// never a private network either (opLocalNetworkDest). An anchored machine's
|
||||
// ship steps reach its fleet's private forges, so it keeps those.
|
||||
func opFetchPin(ctx context.Context, repo string) ([]string, error) {
|
||||
u, err := url.Parse(repo)
|
||||
if err != nil {
|
||||
return nil, errors.New("the repository is not a URL this bastion can read")
|
||||
}
|
||||
if u.Scheme == "file" && slices.Contains(opTreeProtocols, "file") {
|
||||
return nil, nil // a test's own repository: no network
|
||||
}
|
||||
if u.Scheme != "https" || u.Hostname() == "" {
|
||||
return nil, fmt.Errorf("%s: a verb fetches a repository over https from a named host", repo)
|
||||
}
|
||||
host, port := u.Hostname(), u.Port()
|
||||
if port == "" {
|
||||
port = "443"
|
||||
}
|
||||
ips, err := opVerbHostAddrs(ctx, host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the repository's host %s does not resolve on this machine", host)
|
||||
}
|
||||
own := map[string]bool{}
|
||||
for _, ip := range opActionOwnAddrs() {
|
||||
own[ip.String()] = true
|
||||
}
|
||||
for _, ip := range ips {
|
||||
why := opForbiddenDest(ip)
|
||||
if why == "" {
|
||||
why = opLocalNetworkDest(ip)
|
||||
}
|
||||
if why == "" && own[ip.String()] {
|
||||
why = "it is this machine"
|
||||
}
|
||||
if why != "" {
|
||||
return nil, fmt.Errorf("the repository's host %s resolves to %s: %s", host, ip, why)
|
||||
}
|
||||
}
|
||||
if why := opGitPinnable(); why != "" {
|
||||
return nil, errors.New(why)
|
||||
}
|
||||
addr := ips[0].String()
|
||||
if strings.Contains(addr, ":") {
|
||||
addr = "[" + addr + "]"
|
||||
}
|
||||
return []string{"-c", "http.curloptResolve=" + host + ":" + port + ":" + addr}, nil
|
||||
}
|
||||
|
||||
// opRegistryOK: nil when the registry an image is pulled from may be
|
||||
// reached from this machine, else why not. The bastion never pulls an image
|
||||
// itself: k8s.set_image hands the reference to the cluster, and the
|
||||
// machine's container runtime pulls it, by the name, with its own lookup and
|
||||
// none of the bastion's walls. So on a machine no fleet has anchored the
|
||||
// registry is judged where the bastion takes the image, when a program's
|
||||
// result names it and again when k8s.set_image would put it live: written as
|
||||
// an address or resolving to one that is this machine, its loopback, a
|
||||
// link-local address, a cloud's metadata service or a private network, it is
|
||||
// refused. A first part with no dot, no port and not localhost is a Docker
|
||||
// Hub repository, as the runtime reads it. An anchored machine's fleet pulls
|
||||
// from its own private registries, so it keeps them.
|
||||
func opRegistryOK(ctx context.Context, image string) error {
|
||||
if currentAnchor() != nil {
|
||||
return nil
|
||||
}
|
||||
ref, _ := opSplitImage(image)
|
||||
first, _, ok := strings.Cut(ref, "/")
|
||||
if !ok || (!strings.ContainsAny(first, ".:") && first != "localhost") {
|
||||
return nil
|
||||
}
|
||||
host := first
|
||||
if h, _, err := net.SplitHostPort(first); err == nil {
|
||||
host = h
|
||||
}
|
||||
ips, err := opVerbHostAddrs(ctx, host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the image's registry %s does not resolve on this machine", host)
|
||||
}
|
||||
own := map[string]bool{}
|
||||
for _, ip := range opActionOwnAddrs() {
|
||||
own[ip.String()] = true
|
||||
}
|
||||
for _, ip := range ips {
|
||||
why := opForbiddenDest(ip)
|
||||
if why == "" {
|
||||
why = opLocalNetworkDest(ip)
|
||||
}
|
||||
if why == "" && own[ip.String()] {
|
||||
why = "it is this machine"
|
||||
}
|
||||
if why != "" {
|
||||
return fmt.Errorf("the image %s comes from the registry %s, which resolves to %s: %s", truncate(ref, 90), host, ip, why)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// opVerbHostAddrs is every address host stands for on this machine: itself
|
||||
// when it is one, loopback for localhost and a name under it (which never
|
||||
// leave the machine, whatever a resolver says), else what it resolves to.
|
||||
func opVerbHostAddrs(ctx context.Context, host string) ([]net.IP, error) {
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return []net.IP{ip}, nil
|
||||
}
|
||||
if l := strings.ToLower(host); l == "localhost" || strings.HasSuffix(l, ".localhost") {
|
||||
return []net.IP{net.IPv4(127, 0, 0, 1)}, nil
|
||||
}
|
||||
rctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
ips, err := opActionResolve(rctx, host)
|
||||
if err == nil && len(ips) == 0 {
|
||||
err = errors.New("no address")
|
||||
}
|
||||
return ips, err
|
||||
}
|
||||
|
||||
// opGitPinnable: "" when this machine's git holds a fetch to the address
|
||||
// opFetchPin checked, else why not. http.curloptResolve arrived in git 2.37,
|
||||
// and an older git ignores it without an error, which would drop the pin; so
|
||||
// an older git does not fetch for a verb. A seam for the tests.
|
||||
var opGitPinnable = func() string {
|
||||
out, err := runGitEnv(15*time.Second, nil, "version")
|
||||
if err != nil {
|
||||
return "git on this machine did not report its version"
|
||||
}
|
||||
if gitVersionRefusal(out) != "" {
|
||||
return "a verb's fetch is held to the address the bastion checked, " +
|
||||
"which needs git 2.37 or newer, and this machine has " + truncate(strings.TrimSpace(out), 40) +
|
||||
": install git 2.37 or newer"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// opAncestryByFetch fetches the source repository into the bastion's own
|
||||
// cache and asks git whether newer descends from older.
|
||||
func opAncestryByFetch(source, older, newer string, env []string) error {
|
||||
if older == newer {
|
||||
return nil
|
||||
}
|
||||
pin, err := opFetchPin(context.Background(), source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d, err := configDir()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -826,7 +1007,7 @@ func opAncestryByFetch(source, older, newer string, env []string) error {
|
||||
return errors.New("ancestry cache: " + truncate(out, 200))
|
||||
}
|
||||
}
|
||||
if out, err := opTreeGit(cache, env, "fetch", "--no-tags", "--quiet", source, "+refs/heads/*:refs/heads/*"); err != nil {
|
||||
if out, err := opTreeGit(cache, env, append(pin, "fetch", "--no-tags", "--quiet", source, "+refs/heads/*:refs/heads/*")...); err != nil {
|
||||
return errors.New("fetching the source to check ancestry failed: " + truncate(out, 300))
|
||||
}
|
||||
for _, c := range []string{older, newer} {
|
||||
@@ -906,6 +1087,95 @@ func opProgramCreds(verbs []opVerbLine, creds map[string]string) map[string]stri
|
||||
return out
|
||||
}
|
||||
|
||||
// ── what each verb needs of the machine ─────────────────────────────────────
|
||||
|
||||
// opLookPath finds a tool a verb runs (a seam for the tests).
|
||||
var opLookPath = exec.LookPath
|
||||
|
||||
// opVerbProbe: "" when this machine can run every line, else why not. The
|
||||
// verbs run with the bastion's own root and each runs one tool. A seam, so a
|
||||
// test can stand in for a machine that has them.
|
||||
var opVerbProbe = func(lines []opVerbLine) string {
|
||||
if !processIsRoot() {
|
||||
return "the bastion is not root, and a verb runs with its root"
|
||||
}
|
||||
for _, v := range lines {
|
||||
for _, tool := range opVerbTools(v.Verb, v.Args) {
|
||||
if _, err := opLookPath(tool); err != nil {
|
||||
return fmt.Sprintf("%s %s runs %s, which is not installed here", v.Verb, v.target(), tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// opVerbTools is the tool one verb line runs (logs.check: the journal for a
|
||||
// unit, kubectl for a workload).
|
||||
func opVerbTools(verb string, args map[string]string) []string {
|
||||
switch verb {
|
||||
case "unit.reload", "unit.restart":
|
||||
return []string{"systemctl"}
|
||||
case "tree.checkout":
|
||||
return []string{"git"}
|
||||
case "k8s.set_image":
|
||||
// kubectl sets the image; git fetches the source to check the new
|
||||
// commit descends from the live one (opAncestryByFetch).
|
||||
return []string{"kubectl", "git"}
|
||||
case opVerbLogs:
|
||||
if args["unit"] != "" {
|
||||
return []string{"journalctl"}
|
||||
}
|
||||
return []string{"kubectl"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// opVerbsReport is the beat's enforcing.verbs: for each verb this bastion
|
||||
// runs, whether this machine has what it needs, and why not when it does
|
||||
// not. logs.check is ready when either of its tools is (the line decides
|
||||
// which it reads).
|
||||
func opVerbsReport() map[string]any {
|
||||
root := processIsRoot()
|
||||
out := map[string]any{}
|
||||
for _, verb := range opVerbNames() {
|
||||
var tools [][]string
|
||||
if verb == opVerbLogs {
|
||||
tools = [][]string{{"journalctl"}, {"kubectl"}}
|
||||
} else {
|
||||
tools = [][]string{opVerbTools(verb, nil)}
|
||||
}
|
||||
why := ""
|
||||
if !root {
|
||||
why = "the bastion is not root, and a verb runs with its root"
|
||||
} else {
|
||||
why = "needs " + strings.Join(tools[0], ", ")
|
||||
for _, set := range tools {
|
||||
ok := true
|
||||
for _, tool := range set {
|
||||
if _, err := opLookPath(tool); err != nil {
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
why = ""
|
||||
break
|
||||
}
|
||||
}
|
||||
if why == "" && verb == "k8s.set_image" {
|
||||
if _, err := os.Stat(opKubeconfig); err != nil {
|
||||
why = "no kubeconfig at " + opKubeconfig
|
||||
}
|
||||
}
|
||||
}
|
||||
if why == "" {
|
||||
out[verb] = map[string]any{"ok": true}
|
||||
} else {
|
||||
out[verb] = map[string]any{"ok": false, "why": why}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// opVerbTo is the state a verb line moves its target to: a commit, an image
|
||||
// reference; "" for a unit, which has no state to move back from.
|
||||
func opVerbTo(v opVerbLine) string {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package main
|
||||
|
||||
// ops_verbs_only_linux_test.go — an op with no program (interpreter: none)
|
||||
// runs on a machine with no overlay (the edges), through op_dry_run and an
|
||||
// approved op_apply, with only the verbs' own tools. Stand-ins for systemd
|
||||
// and the probe; nothing here needs root.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVerbsOnlyOpRunsWithNoOverlay(t *testing.T) {
|
||||
noOverlay(t)
|
||||
prevProbe := opVerbProbe
|
||||
opVerbProbe = func([]opVerbLine) string { return "" }
|
||||
t.Cleanup(func() { opVerbProbe = prevProbe })
|
||||
var calls []string
|
||||
state := map[string]string{"LoadState": "loaded", "ActiveState": "failed", "SubState": "failed", "MainPID": "0"}
|
||||
prevCtl, prevJournal := opSystemctl, opJournalctl
|
||||
opSystemctl = func(_ context.Context, args ...string) (string, error) {
|
||||
calls = append(calls, strings.Join(args, " "))
|
||||
if args[0] == "show" {
|
||||
var b strings.Builder
|
||||
for k, v := range state {
|
||||
fmt.Fprintf(&b, "%s=%s\n", k, v)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
if args[0] == "restart" {
|
||||
state["ActiveState"], state["SubState"], state["MainPID"] = "active", "running", "42"
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
opJournalctl = func(_ context.Context, _ ...string) (string, error) { return "started\n", nil }
|
||||
t.Cleanup(func() { opSystemctl, opJournalctl = prevCtl, prevJournal })
|
||||
|
||||
dcn, priv := opTestBox(t)
|
||||
enrollFactor(t, dcn)
|
||||
files := verbsOnlyFiles(false)
|
||||
sha, _ := opDirDigest(files)
|
||||
terms := opTerm(opKindRun, sha, `{}`, "", 1, time.Hour, "root")
|
||||
terms["op"] = "restart-web"
|
||||
pair := signTermsPair(t, priv, terms)
|
||||
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(pair, bundleB64(files))}, "", dcn); code != 0 {
|
||||
t.Fatalf("held: %s", errOut)
|
||||
}
|
||||
out, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_dry_run", Args: pair}, "", dcn)
|
||||
if code != 0 {
|
||||
t.Fatalf("the dry run on a machine with no overlay: %d %s", code, errOut)
|
||||
}
|
||||
var rep opReport
|
||||
if err := json.Unmarshal([]byte(out), &rep); err != nil || len(rep.Changes) != 2 || rep.ChangesSHA256 == "" ||
|
||||
!strings.Contains(rep.Changes[0].Note, "failed/failed → restarted") {
|
||||
t.Fatalf("the dry run is the verbs' own read: %v %s", err, out)
|
||||
}
|
||||
for _, c := range calls {
|
||||
if strings.HasPrefix(c, "restart") {
|
||||
t.Fatal("a verb never runs in a dry run")
|
||||
}
|
||||
}
|
||||
// The apply, approved for exactly that change list.
|
||||
terms["changes_sha256"] = rep.ChangesSHA256
|
||||
purpose, err := purposeForOpRun(&opTerms{Server: terms["server"].(string), Op: "restart-web", SHA256: sha,
|
||||
Params: `{}`, Expires: terms["expires"].(string), Changes: rep.ChangesSHA256})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := b32decode(tSecret)
|
||||
approval, err := stepupIssueApproval(dcn, hotp(raw, time.Now().Unix()/stepupPeriod), purpose)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
terms["approval"] = approval
|
||||
out, errOut, code = driveJob(t, tAnchor, mgmtJob{Op: "op_apply", Args: signTermsPair(t, priv, terms)}, "", dcn)
|
||||
if code != 0 {
|
||||
t.Fatalf("the apply: %d %s", code, errOut)
|
||||
}
|
||||
rep = opReport{}
|
||||
_ = json.Unmarshal([]byte(out), &rep)
|
||||
if len(rep.Verbs) != 1 || !rep.Verbs[0].OK || len(rep.Checks) != 1 || !rep.Checks[0].OK || rep.VerifyError != "" {
|
||||
t.Fatalf("the unit restarted and its log checked: %s", out)
|
||||
}
|
||||
if n := countPrefix(calls, "restart web.service"); n != 1 {
|
||||
t.Fatalf("restarted once: %v", calls)
|
||||
}
|
||||
|
||||
// A machine without a verb's tool refuses the run, and that is the
|
||||
// machine's state, never the host's strike.
|
||||
opVerbProbe = func(lines []opVerbLine) string {
|
||||
return "unit.restart web.service runs systemctl, which is not installed here"
|
||||
}
|
||||
pair2 := signTermsPair(t, priv, terms)
|
||||
_, errOut, code = driveJob(t, tAnchor, mgmtJob{Op: "op_dry_run", Args: pair2}, "", dcn)
|
||||
if code != 126 || !strings.Contains(errOut, noVerbToolHere) || refusalCounts("op_dry_run", errOut) {
|
||||
t.Fatalf("a missing tool: %d %q", code, errOut)
|
||||
}
|
||||
}
|
||||
|
||||
func countPrefix(xs []string, p string) int {
|
||||
n := 0
|
||||
for _, x := range xs {
|
||||
if strings.HasPrefix(x, p) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestVerbsReportNamesEachVerbsTool(t *testing.T) {
|
||||
prev := opLookPath
|
||||
opLookPath = func(file string) (string, error) {
|
||||
if file == "systemctl" || file == "journalctl" {
|
||||
return "/usr/bin/" + file, nil
|
||||
}
|
||||
return "", fmt.Errorf("not found")
|
||||
}
|
||||
t.Cleanup(func() { opLookPath = prev })
|
||||
rep := opVerbsReport()
|
||||
for _, verb := range opVerbNames() {
|
||||
if _, ok := rep[verb]; !ok {
|
||||
t.Fatalf("every verb is on the beat: %v", rep)
|
||||
}
|
||||
}
|
||||
if !processIsRoot() {
|
||||
if v := rep["unit.restart"].(map[string]any); v["ok"] != false {
|
||||
t.Fatalf("a bastion that is not root runs no verb: %v", v)
|
||||
}
|
||||
return
|
||||
}
|
||||
if v := rep["unit.restart"].(map[string]any); v["ok"] != true {
|
||||
t.Fatalf("systemctl here: %v", v)
|
||||
}
|
||||
if v := rep["k8s.set_image"].(map[string]any); v["ok"] != false || !strings.Contains(v["why"].(string), "kubectl") {
|
||||
t.Fatalf("no kubectl here: %v", v)
|
||||
}
|
||||
if v := rep["logs.check"].(map[string]any); v["ok"] != true {
|
||||
t.Fatalf("the journal is enough for a unit's log: %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// k8s.set_image runs git as well as kubectl (its ancestry check fetches the
|
||||
// source), so a machine with kubectl and no git is told it cannot run the
|
||||
// verb, before any run, never by a failed apply.
|
||||
//
|
||||
// RESULT: red before — k8s.set_image named kubectl alone.
|
||||
func TestSetImageNeedsGitForItsAncestryCheck(t *testing.T) {
|
||||
tools := opVerbTools("k8s.set_image", nil)
|
||||
if strings.Join(tools, ",") != "kubectl,git" {
|
||||
t.Fatalf("k8s.set_image runs kubectl and git: %v", tools)
|
||||
}
|
||||
if got := opVerbTools("tree.checkout", nil); strings.Join(got, ",") != "git" {
|
||||
t.Fatalf("tree.checkout runs git: %v", got)
|
||||
}
|
||||
prev := opLookPath
|
||||
opLookPath = func(file string) (string, error) {
|
||||
if file == "kubectl" {
|
||||
return "/usr/bin/kubectl", nil
|
||||
}
|
||||
return "", fmt.Errorf("not found")
|
||||
}
|
||||
t.Cleanup(func() { opLookPath = prev })
|
||||
if !processIsRoot() {
|
||||
return // a bastion that is not root runs no verb at all
|
||||
}
|
||||
line := setImageLine("sha256:" + strings.Repeat("2", 64))
|
||||
if why := opVerbProbe([]opVerbLine{line}); !strings.Contains(why, "git") {
|
||||
t.Fatalf("a machine with kubectl and no git cannot run k8s.set_image: %q", why)
|
||||
}
|
||||
if v := opVerbsReport()["k8s.set_image"].(map[string]any); v["ok"] != false || !strings.Contains(v["why"].(string), "git") {
|
||||
t.Fatalf("the beat says so: %v", v)
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func refusalCounts(op, why string) bool {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(why, noWayToApprove) || strings.HasSuffix(why, "not permitted on this host") ||
|
||||
strings.Contains(why, noSandboxHere) {
|
||||
strings.Contains(why, noSandboxHere) || strings.Contains(why, noVerbToolHere) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ func opWrites(op string) bool {
|
||||
// approval. A lost device must not keep a revoked grant alive.
|
||||
func opTightens(op string) bool {
|
||||
return op == "dcn_grant_drop" || op == "pairing_grant_drop" || op == "service_grant_drop" ||
|
||||
op == "coder_grant_drop"
|
||||
op == "coder_grant_drop" || op == "op_grant_drop"
|
||||
}
|
||||
|
||||
// ownApproval reports whether a WRITE op already carries its own proof of the
|
||||
|
||||
Reference in New Issue
Block a user