package host import ( "strings" "testing" ) // directives returns every "Key=value" line of a unit, comments and // section headers dropped, in file order. func directives(u string) []string { var out []string for _, line := range strings.Split(u, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "[") { continue } out = append(out, line) } return out } func hasDirective(u, want string) bool { for _, d := range directives(u) { if d == want { return true } } return false } // instanceDeny is the namespace block's egress fence (#214 item 3): every // address a tenant has no business dialling, loopback first. A tenant // reaches the internet; it does not reach the box, the VPC, or the cloud's // internal ranges. The box's resolver is re-opened per box by the control // service on carlos-instances.slice (IPAddressAllow=, which wins). const instanceDeny = "IPAddressDeny=localhost multicast " + "0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 169.254.0.0/16 169.254.169.254/32 172.16.0.0/12 192.0.0.0/24 192.168.0.0/16 198.18.0.0/15 240.0.0.0/4 " + "64:ff9b::/96 fc00::/7 fd00:ec2::254/128 fe80::/10 fec0::/10" // Every directive the hardening adds to the shared namespace block (#214 // items 2 and 3, #178), pinned exactly: the block is byte-identical across // the four templates and the probe (TestInstanceTemplatesShareOneNamespace), // so pinning it on carlos-instance@ pins it on all five. func TestNamespaceBlockCarriesTheHardening(t *testing.T) { for _, name := range []string{"carlos-instance@.service", "carlos-instance-gen@.service", "carlos-instance-seed@.service", "carlos-instance-sidecar@.service", "carlos-instance-cmd@.service", "carlos-instance-probe.service"} { u := readUnit(t, name) for _, want := range []string{ // #214 item 2: no other app's build is visible. "TemporaryFileSystem=/opt/carlos:ro", "BindReadOnlyPaths=/opt/carlos/carlos", // #214 item 3: loopback, private and cloud-internal ranges. instanceDeny, // #178: SysV and POSIX mqueue namespaces of its own, and the // host's mqueue mount out of sight. // #214 item 3: resolved's upstreams, never its loopback stub. "BindReadOnlyPaths=-/run/systemd/resolve/resolv.conf:/etc/resolv.conf", "PrivateIPC=yes", "TemporaryFileSystem=/dev/mqueue:ro", } { if !hasDirective(u, want) { t.Errorf("%s lacks %q", name, want) } } // The old IMDS-only line is gone, not left beside the new one: // two IPAddressDeny= lines would still union, but a reader would // take the first for the whole fence. if hasDirective(u, "IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128") { t.Errorf("%s still carries the IMDS-only IPAddressDeny line", name) } // Nothing in the namespace re-opens what the fence closes: the // resolver allow lives on the slice, set per box at run time. for _, d := range directives(u) { if strings.HasPrefix(d, "IPAddressAllow=") { t.Errorf("%s carries %q; the resolver allow belongs on carlos-instances.slice", name, d) } } } } // The doc-engine fence (caps/render.go) is the platform's other private- // range list. The instance fence must cover every range it does, plus // loopback, which the doc engine deliberately leaves open. func TestInstanceDenyCoversTheDocEngineFence(t *testing.T) { have := map[string]bool{} for _, f := range strings.Fields(strings.TrimPrefix(instanceDeny, "IPAddressDeny=")) { have[f] = true } // localhost and multicast are systemd's names for 127.0.0.0/8 ::1/128 // and 224.0.0.0/4 ff00::/8. Link-local is spelled out: systemd's // "link-local" is fe80::/64, narrower than the fe80::/10 here. named := map[string]string{"224.0.0.0/4": "multicast", "ff00::/8": "multicast"} for _, r := range []string{"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "169.254.0.0/16", "169.254.169.254/32", "172.16.0.0/12", "192.0.0.0/24", "192.168.0.0/16", "198.18.0.0/15", "224.0.0.0/4", "240.0.0.0/4", "64:ff9b::/96", "fc00::/7", "fd00:ec2::254/128", "fe80::/10", "fec0::/10", "ff00::/8"} { if !have[r] && !have[named[r]] { t.Errorf("instance fence lacks %s, which the doc-engine fence denies", r) } } if !have["localhost"] { t.Error("instance fence leaves loopback open") } } // Each template binds back exactly its own build (#214 item 2): the control // service writes the two sources before every start (instancectl ensureExe). // The cmd template binds sources of its own, cmd-app and cmd-live, which no // other template's start writes (merge ruling M4). func TestInstanceTemplatesBindOnlyTheirOwnBuild(t *testing.T) { for name, src := range map[string][2]string{ "carlos-instance@.service": {"%I", ""}, "carlos-instance-seed@.service": {"%I", ""}, "carlos-instance-sidecar@.service": {"%I", ""}, "carlos-instance-cmd@.service": {"%I", "cmd-"}, "carlos-instance-probe.service": {"_probe", ""}, } { u := readUnit(t, name) iid, pre := src[0], src[1] want := "BindReadOnlyPaths=/run/carlos-control/exe/" + iid + "/" + pre + "app:/opt/carlos/app /run/carlos-control/exe/" + iid + "/" + pre + "live:/opt/carlos/live" if !hasDirective(u, want) { t.Errorf("%s lacks %q", name, want) } for _, d := range directives(u) { if strings.Contains(d, "/opt/carlos/versions") { t.Errorf("%s names the version store: %q", name, d) } } } } // The probe's own resource section carries a CPU and IO setting it can read // back from its own cgroup (#214 item 1): proof that systemd applies them // under carlos-instances.slice on this box, which is where the per-host // slices get the tier's. func TestProbeCarriesReadableCPUAndIOSettings(t *testing.T) { u := readUnit(t, "carlos-instance-probe.service") for _, want := range []string{"CPUQuota=50%", "CPUWeight=50", "IOWeight=50"} { if !hasDirective(u, want) { t.Errorf("probe lacks %q", want) } } } // carlos-app@ gets the same /opt view (#214 item 2): its ExecStart is // /opt/carlos/live/%i, which PID 1 chases to the build when it binds it. func TestAppTemplateSeesOnlyItsOwnBuild(t *testing.T) { u := readUnit(t, "carlos-app@.service") for _, want := range []string{"TemporaryFileSystem=/opt/carlos:ro", "BindReadOnlyPaths=/opt/carlos/live/%i", "ExecStart=/opt/carlos/live/%i serve"} { if !hasDirective(u, want) { t.Errorf("carlos-app@.service lacks %q", want) } } }