| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | ) |
| 7 | |
| 8 | // directives returns every "Key=value" line of a unit, comments and |
| 9 | // section headers dropped, in file order. |
| 10 | func directives(u string) []string { |
| 11 | var out []string |
| 12 | for _, line := range strings.Split(u, "\n") { |
| 13 | line = strings.TrimSpace(line) |
| 14 | if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "[") { |
| 15 | continue |
| 16 | } |
| 17 | out = append(out, line) |
| 18 | } |
| 19 | return out |
| 20 | } |
| 21 | |
| 22 | func hasDirective(u, want string) bool { |
| 23 | for _, d := range directives(u) { |
| 24 | if d == want { |
| 25 | return true |
| 26 | } |
| 27 | } |
| 28 | return false |
| 29 | } |
| 30 | |
| 31 | // instanceDeny is the namespace block's egress fence (#214 item 3): every |
| 32 | // address a tenant has no business dialling, loopback first. A tenant |
| 33 | // reaches the internet; it does not reach the box, the VPC, or the cloud's |
| 34 | // internal ranges. The box's resolver is re-opened per box by the control |
| 35 | // service on carlos-instances.slice (IPAddressAllow=, which wins). |
| 36 | const instanceDeny = "IPAddressDeny=localhost multicast " + |
| 37 | "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 " + |
| 38 | "64:ff9b::/96 fc00::/7 fd00:ec2::254/128 fe80::/10 fec0::/10" |
| 39 | |
| 40 | // Every directive the hardening adds to the shared namespace block (#214 |
| 41 | // items 2 and 3, #178), pinned exactly: the block is byte-identical across |
| 42 | // the four templates and the probe (TestInstanceTemplatesShareOneNamespace), |
| 43 | // so pinning it on carlos-instance@ pins it on all five. |
| 44 | func TestNamespaceBlockCarriesTheHardening(t *testing.T) { |
| 45 | 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"} { |
| 46 | u := readUnit(t, name) |
| 47 | for _, want := range []string{ |
| 48 | // #214 item 2: no other app's build is visible. |
| 49 | "TemporaryFileSystem=/opt/carlos:ro", |
| 50 | "BindReadOnlyPaths=/opt/carlos/carlos", |
| 51 | // #214 item 3: loopback, private and cloud-internal ranges. |
| 52 | instanceDeny, |
| 53 | // #178: SysV and POSIX mqueue namespaces of its own, and the |
| 54 | // host's mqueue mount out of sight. |
| 55 | // #214 item 3: resolved's upstreams, never its loopback stub. |
| 56 | "BindReadOnlyPaths=-/run/systemd/resolve/resolv.conf:/etc/resolv.conf", |
| 57 | "PrivateIPC=yes", |
| 58 | "TemporaryFileSystem=/dev/mqueue:ro", |
| 59 | } { |
| 60 | if !hasDirective(u, want) { |
| 61 | t.Errorf("%s lacks %q", name, want) |
| 62 | } |
| 63 | } |
| 64 | // The old IMDS-only line is gone, not left beside the new one: |
| 65 | // two IPAddressDeny= lines would still union, but a reader would |
| 66 | // take the first for the whole fence. |
| 67 | if hasDirective(u, "IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128") { |
| 68 | t.Errorf("%s still carries the IMDS-only IPAddressDeny line", name) |
| 69 | } |
| 70 | // Nothing in the namespace re-opens what the fence closes: the |
| 71 | // resolver allow lives on the slice, set per box at run time. |
| 72 | for _, d := range directives(u) { |
| 73 | if strings.HasPrefix(d, "IPAddressAllow=") { |
| 74 | t.Errorf("%s carries %q; the resolver allow belongs on carlos-instances.slice", name, d) |
| 75 | } |
| 76 | } |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | // The doc-engine fence (caps/render.go) is the platform's other private- |
| 81 | // range list. The instance fence must cover every range it does, plus |
| 82 | // loopback, which the doc engine deliberately leaves open. |
| 83 | func TestInstanceDenyCoversTheDocEngineFence(t *testing.T) { |
| 84 | have := map[string]bool{} |
| 85 | for _, f := range strings.Fields(strings.TrimPrefix(instanceDeny, "IPAddressDeny=")) { |
| 86 | have[f] = true |
| 87 | } |
| 88 | // localhost and multicast are systemd's names for 127.0.0.0/8 ::1/128 |
| 89 | // and 224.0.0.0/4 ff00::/8. Link-local is spelled out: systemd's |
| 90 | // "link-local" is fe80::/64, narrower than the fe80::/10 here. |
| 91 | named := map[string]string{"224.0.0.0/4": "multicast", "ff00::/8": "multicast"} |
| 92 | 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", |
| 93 | "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", |
| 94 | "64:ff9b::/96", "fc00::/7", "fd00:ec2::254/128", "fe80::/10", "fec0::/10", "ff00::/8"} { |
| 95 | if !have[r] && !have[named[r]] { |
| 96 | t.Errorf("instance fence lacks %s, which the doc-engine fence denies", r) |
| 97 | } |
| 98 | } |
| 99 | if !have["localhost"] { |
| 100 | t.Error("instance fence leaves loopback open") |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | // Each template binds back exactly its own build (#214 item 2): the control |
| 105 | // service writes the two sources before every start (instancectl ensureExe). |
| 106 | // The cmd template binds sources of its own, cmd-app and cmd-live, which no |
| 107 | // other template's start writes (merge ruling M4). |
| 108 | func TestInstanceTemplatesBindOnlyTheirOwnBuild(t *testing.T) { |
| 109 | for name, src := range map[string][2]string{ |
| 110 | "carlos-instance@.service": {"%I", ""}, |
| 111 | "carlos-instance-seed@.service": {"%I", ""}, |
| 112 | "carlos-instance-sidecar@.service": {"%I", ""}, |
| 113 | "carlos-instance-cmd@.service": {"%I", "cmd-"}, |
| 114 | "carlos-instance-probe.service": {"_probe", ""}, |
| 115 | } { |
| 116 | u := readUnit(t, name) |
| 117 | iid, pre := src[0], src[1] |
| 118 | want := "BindReadOnlyPaths=/run/carlos-control/exe/" + iid + "/" + pre + "app:/opt/carlos/app /run/carlos-control/exe/" + iid + "/" + pre + "live:/opt/carlos/live" |
| 119 | if !hasDirective(u, want) { |
| 120 | t.Errorf("%s lacks %q", name, want) |
| 121 | } |
| 122 | for _, d := range directives(u) { |
| 123 | if strings.Contains(d, "/opt/carlos/versions") { |
| 124 | t.Errorf("%s names the version store: %q", name, d) |
| 125 | } |
| 126 | } |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | // The probe's own resource section carries a CPU and IO setting it can read |
| 131 | // back from its own cgroup (#214 item 1): proof that systemd applies them |
| 132 | // under carlos-instances.slice on this box, which is where the per-host |
| 133 | // slices get the tier's. |
| 134 | func TestProbeCarriesReadableCPUAndIOSettings(t *testing.T) { |
| 135 | u := readUnit(t, "carlos-instance-probe.service") |
| 136 | for _, want := range []string{"CPUQuota=50%", "CPUWeight=50", "IOWeight=50"} { |
| 137 | if !hasDirective(u, want) { |
| 138 | t.Errorf("probe lacks %q", want) |
| 139 | } |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | // carlos-app@ gets the same /opt view (#214 item 2): its ExecStart is |
| 144 | // /opt/carlos/live/%i, which PID 1 chases to the build when it binds it. |
| 145 | func TestAppTemplateSeesOnlyItsOwnBuild(t *testing.T) { |
| 146 | u := readUnit(t, "carlos-app@.service") |
| 147 | for _, want := range []string{"TemporaryFileSystem=/opt/carlos:ro", "BindReadOnlyPaths=/opt/carlos/live/%i", "ExecStart=/opt/carlos/live/%i serve"} { |
| 148 | if !hasDirective(u, want) { |
| 149 | t.Errorf("carlos-app@.service lacks %q", want) |
| 150 | } |
| 151 | } |
| 152 | } |
| 153 | |