carlos / platform Public

zdd plan 2 task 7: carlos-instance-gen@ template, same namespace, keyed by drop-in

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 591cbc251f3c10e9821c4b5d922748e252b242d4 parent 6992ac0
4 files changed, +168 −3
  • internal/host/host_test.go +1 −0
  • internal/host/units/carlos-instance-gen@.service +136 −0
  • internal/host/units_hardening_test.go +1 −1
  • internal/host/units_isolation_test.go +30 −2
diff --git a/internal/host/host_test.go b/internal/host/host_test.go
index 4bd257c09..2313b98e3 100644
--- a/internal/host/host_test.go
+++ b/internal/host/host_test.go
@@ -41,6 +41,7 @@ var expectedUnits = []string{
"carlos-hibernate-verify.timer",
"carlos-instance-control.service",
"carlos-instance-control.socket",
+ "carlos-instance-gen@.service",
"carlos-instance-probe.service",
"carlos-instance-seed@.service",
"carlos-instance-sidecar@.service",
diff --git a/internal/host/units/carlos-instance-gen@.service b/internal/host/units/carlos-instance-gen@.service
new file mode 100644
index 000000000..4c98493a3
--- /dev/null
+++ b/internal/host/units/carlos-instance-gen@.service
@@ -0,0 +1,136 @@
+# One generation of an isolated exec-backed instance (zero-downtime
+# deploys design §7.6). The instance string is "<instance-id>:<generation>"
+# (%I), and the trampoline and the exit writer split it. systemd has no
+# specifier for part of an instance string, and ':' is BindPaths='s field
+# separator, so everything keyed by the host ID alone (the slice, the data
+# mounts) or by the generation (the executable mount, the log socket)
+# comes from a runtime drop-in the control service writes before each
+# start. Without that drop-in this unit has an empty /data and its
+# trampoline refuses, and the control service will not start it unless
+# systemd reports the drop-in's slice. carlos-instance@.service stays for
+# every box and binary that still runs it.
+[Unit]
+Description=carlos exec-backed instance generation %I
+# Stop-only coupling, as carlos-instance@.service (codex R1-7).
+BindsTo=carlos-edge.service
+Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
+After=carlos-edge.service data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
+
+[Service]
+Type=simple
+ExecStart=/opt/carlos/carlos instance-exec run %I
+ExecStopPost=/opt/carlos/carlos instance-exit run %I
+User=carlos
+Group=carlos
+# The activator owns recycling; systemd must not race it.
+Restart=no
+KillMode=mixed
+TimeoutStopSec=20
+StandardError=inherit
+
+# --- the namespace (titogo@.service, 2026-07-31 design, on systemd 257) ---
+ProtectSystem=strict
+TemporaryFileSystem=/data:ro
+TemporaryFileSystem=/run/carlos:ro
+# Every other tree a carlos-uid unit writes on the box is hidden the same
+# way (codex R9-2): the restore drills' cache under /var/cache, and
+# /var/lib as a class. An instance needs nothing from either.
+TemporaryFileSystem=/var/cache:ro
+TemporaryFileSystem=/var/lib:ro
+# The version store holds every app's build on the box, world-readable so
+# a unit can exec its own (#214 item 2). An instance sees none of it: an
+# empty /opt/carlos with the platform binary that ExecStart= and
+# ExecStopPost= run put back, and, outside this block because it is per
+# instance, its own build at /opt/carlos/live/<host>.
+TemporaryFileSystem=/opt/carlos:ro
+BindReadOnlyPaths=/opt/carlos/carlos
+# Root-written credentials a carlos-GROUP process may read live here:
+# /etc/carlos/relay-masters is root:carlos 0640 for carlos-relay.service,
+# and an instance's Group=carlos would read it too (codex R10-1). An
+# instance gets its configuration through its launch file and needs
+# nothing from /etc/carlos. /run/carlos-control is the root control
+# service's socket directory (design §5.2): hiding it here too means a
+# connect from inside any instance's namespace is impossible outright,
+# not merely refused by the peer check on the far end (final review,
+# plan 2, item 3).
+InaccessiblePaths=/etc/carlos /run/carlos-control
+ProtectHome=yes
+PrivateTmp=yes
+PrivateDevices=yes
+# PrivateDevices= bind-mounts the HOST's /dev/shm into the private /dev,
+# and every instance is uid carlos, so a sibling's shared memory would be
+# one open away. Each unit gets its own, charged to its own cgroup.
+TemporaryFileSystem=/dev/shm:mode=1777,nosuid,nodev
+# POSIX message queues and SysV IPC belong to an IPC namespace, and every
+# instance is uid carlos, so a sibling's queue would be one mq_open away
+# (#178). PrivateIPC= gives each unit its own namespace, and mq_open()
+# goes through that namespace's own internal mount. PrivateDevices=
+# carries the host's /dev/mqueue into the private /dev the way it carries
+# /dev/shm, and that mount would still list and open the host namespace's
+# queues, so it is replaced the same way.
+PrivateIPC=yes
+TemporaryFileSystem=/dev/mqueue:ro
+# /sys/fs/cgroup read-only: the edge's delegated subtree is carlos-owned,
+# and its cgroup.kill / cgroup.freeze would otherwise be one write away
+# for a same-uid tenant (codex R9-1).
+ProtectControlGroups=yes
+ProtectProc=invisible
+ProcSubset=pid
+PrivatePIDs=yes
+RestrictNamespaces=yes
+NoNewPrivileges=yes
+CapabilityBoundingSet=
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+# Abstract unix sockets live in the network namespace, which instances
+# share with the box because they need outbound TCP, and the line above
+# cannot tell them from path sockets (#178). The trampoline scopes them
+# with Landlock before it execs the app (instanceexec.ScopeAbstractUnix):
+# an instance can connect only to abstract sockets its own processes
+# bound. A sibling can still bind a name first; that is a refusal of
+# service, never a connection.
+#
+# Egress (#214 item 3): the internet, and nothing on the box or private.
+# Loopback is where the console and other tenants' addr upstreams listen;
+# RFC 1918, ULA, link-local and CGNAT are the VPC, other boxes and the
+# cloud's internal services. IMDS stays named so the property is
+# greppable. A cgroup filter: it drops in both directions for every
+# socket the unit opens. The one thing an instance needs from these
+# ranges is the box's DNS resolver, so the control service re-opens
+# exactly the nameservers the instance sees with IPAddressAllow= on
+# carlos-instances.slice (an allow wins, and a slice's lists reach every
+# unit below it). The probe checks both halves.
+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
+# An allow opens an address both ways and on every port, so a loopback
+# resolver can never be opened: a tenant could bind systemd-resolved's
+# 127.0.0.53 itself and reach every listener bound to all addresses
+# through it. Where resolved runs, instances resolve through its
+# upstreams instead, and the control service opens those. The "-" makes
+# this a no-op on a box without resolved.
+BindReadOnlyPaths=-/run/systemd/resolve/resolv.conf:/etc/resolv.conf
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+ProtectClock=yes
+ProtectHostname=yes
+RemoveIPC=yes
+LockPersonality=yes
+RestrictRealtime=yes
+# No RestrictSUIDSGID=: systemd cannot read the mode inside openat2's
+# open_how, so it refuses openat2 outright (ENOSYS), and every instancefs
+# open in here is an openat2 (#126, first roll; systemd 257). What it
+# guarded is covered: with no capabilities an instance can only make a
+# set-id file owned by carlos, which gains nothing for another carlos
+# process, NoNewPrivileges= voids it in here, and no other uid can reach
+# one (the tenant trees are nosuid, PrivateTmp's sit under a root 0700
+# directory).
+MemoryDenyWriteExecute=yes
+SystemCallArchitectures=native
+
+# --- per-service resources; the tier lives on the host slice (§4.3) ---
+TasksMax=256
+# The kernel picks a tenant before it picks anything else in carlos.slice.
+OOMScoreAdjust=500
+# One OOM kill takes every process of THIS service (systemd sets
+# memory.oom.group on the service cgroup for OOMPolicy=kill). The sidecar
+# is a sibling service with its own family rule (§4.3).
+OOMPolicy=kill
diff --git a/internal/host/units_hardening_test.go b/internal/host/units_hardening_test.go
index 0cd6fc864..fecb8ee6b 100644
--- a/internal/host/units_hardening_test.go
+++ b/internal/host/units_hardening_test.go
@@ -42,7 +42,7 @@ const instanceDeny = "IPAddressDeny=localhost multicast " +
// the three templates and the probe (TestInstanceTemplatesShareOneNamespace),
// so pinning it on carlos-instance@ pins it on all four.
func TestNamespaceBlockCarriesTheHardening(t *testing.T) {
- for _, name := range []string{"carlos-instance@.service", "carlos-instance-seed@.service", "carlos-instance-sidecar@.service", "carlos-instance-probe.service"} {
+ for _, name := range []string{"carlos-instance@.service", "carlos-instance-gen@.service", "carlos-instance-seed@.service", "carlos-instance-sidecar@.service", "carlos-instance-probe.service"} {
u := readUnit(t, name)
for _, want := range []string{
// #214 item 2: no other app's build is visible.
diff --git a/internal/host/units_isolation_test.go b/internal/host/units_isolation_test.go
index f7a939f57..0ddb2c801 100644
--- a/internal/host/units_isolation_test.go
+++ b/internal/host/units_isolation_test.go
@@ -239,7 +239,7 @@ func TestInstanceTemplatesShareOneNamespace(t *testing.T) {
return u[start:end]
}
ref := block("carlos-instance@.service")
- for _, name := range []string{"carlos-instance-seed@.service", "carlos-instance-sidecar@.service", "carlos-instance-probe.service"} {
+ for _, name := range []string{"carlos-instance-gen@.service", "carlos-instance-seed@.service", "carlos-instance-sidecar@.service", "carlos-instance-probe.service"} {
if block(name) != ref {
t.Errorf("%s namespace block differs from carlos-instance@.service", name)
}
@@ -275,6 +275,9 @@ func TestInstanceTemplatesShareOneNamespace(t *testing.T) {
"Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount", "OOMScoreAdjust=500", "OOMPolicy=kill", "TasksMax=256", "Type=simple",
// The trampoline's init exits 128+n for a death by signal n (#126).
"SuccessExitStatus=129 130 141 143"},
+ "carlos-instance-gen@.service": {"ExecStart=/opt/carlos/carlos instance-exec run %I", "ExecStopPost=/opt/carlos/carlos instance-exit run %I",
+ "Restart=no", "KillMode=mixed", "TimeoutStopSec=20", "BindsTo=carlos-edge.service",
+ "Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount", "OOMScoreAdjust=500", "OOMPolicy=kill", "TasksMax=256", "Type=simple"},
"carlos-instance-seed@.service": {"Type=oneshot", "RemainAfterExit=yes", "TimeoutStartSec=120", "ExecStart=/opt/carlos/carlos instance-exec seed %I",
"ExecStopPost=/opt/carlos/carlos instance-exit seed %I", "StandardOutput=file:/run/carlos/logs/%I.seed.sock",
"KillMode=mixed", "TimeoutStopSec=20", "WorkingDirectory=/data/instances/%I"},
@@ -335,7 +338,7 @@ func TestControlUnitsShape(t *testing.T) {
if !have["carlos-instance-control.socket"] {
t.Error("enabledUnits lacks the control socket")
}
- if have["carlos-instance-control.service"] || have["carlos-instance-probe.service"] || have["carlos-instance@.service"] {
+ if have["carlos-instance-control.service"] || have["carlos-instance-probe.service"] || have["carlos-instance@.service"] || have["carlos-instance-gen@.service"] {
t.Error("a template, the probe or the socket-activated service must not be enabled")
}
for _, want := range []string{"d /data/.instances/_probe 0700 carlos carlos -", "d /run/carlos/.instances/_probe 2750 carlos carlos-sockets -"} {
@@ -405,3 +408,28 @@ func TestSameUIDAuditIsCoveredByTheTemplates(t *testing.T) {
}
}
}
+
+// The generation template's instance string is "<iid>:<n>" (zero-downtime
+// spec §7.6, plan 2 D1). No specifier can take the instance ID out of it,
+// so the template keys NOTHING by %i or %I but the trampoline's argument.
+// The slice, the data and executable mounts and the log socket come from
+// the control service's per-generation drop-in (instancectl.GenDropIn).
+func TestGenerationTemplateKeysNothingByItsInstanceString(t *testing.T) {
+ u := readUnit(t, "carlos-instance-gen@.service")
+ for _, d := range directives(u) {
+ for _, key := range []string{"Slice=", "BindPaths=", "ReadWritePaths=", "StandardOutput="} {
+ if strings.HasPrefix(d, key) {
+ t.Errorf("generation template carries %q; it belongs in the drop-in", d)
+ }
+ }
+ if strings.HasPrefix(d, "BindReadOnlyPaths=") && strings.Contains(d, "/run/carlos-control/exe") {
+ t.Errorf("generation template binds an executable itself: %q", d)
+ }
+ if strings.Contains(d, "/opt/carlos/versions") {
+ t.Errorf("generation template names the version store: %q", d)
+ }
+ if strings.Contains(d, "%i") || (strings.Contains(d, "%I") && !strings.HasPrefix(d, "ExecStart=") && !strings.HasPrefix(d, "ExecStopPost=") && !strings.HasPrefix(d, "Description=")) {
+ t.Errorf("generation template keys %q by its instance string", d)
+ }
+ }
+}