| 1 | # One isolated exec-backed instance (isolated-exec design §4.1). The |
| 2 | # activator owns it exactly as it owned the exec child this replaces: lease, |
| 3 | # restore, seed, replication, hibernation, control socket, logs. What |
| 4 | # changed is that the process is a unit, still uid carlos, inside a mount |
| 5 | # namespace in which nothing but the instance's own two directories exists, |
| 6 | # on mounts where no symlink is ever followed. The instance name is the |
| 7 | # escaped bounded identifier (%i); paths use the unescaped one (%I). |
| 8 | [Unit] |
| 9 | Description=carlos exec-backed instance %I (sidecar) |
| 10 | # Stop-only coupling: when the edge stops (including the stop half of a |
| 11 | # restart) this unit stops, and nothing starts it again but the agent. |
| 12 | # PartOf= would propagate the RESTART and race the agent's own startup |
| 13 | # (codex R1-7). |
| 14 | BindsTo=carlos-edge.service |
| 15 | Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount |
| 16 | After=carlos-edge.service data-instances.mount run-carlos-instances.mount run-carlos-logs.mount |
| 17 | |
| 18 | [Service] |
| 19 | Type=simple |
| 20 | ExecStart=/opt/carlos/carlos instance-exec sidecar %I |
| 21 | # Runs inside the same namespace, as carlos, after this invocation's |
| 22 | # processes have been signalled: leaves an ADVISORY exit record in the |
| 23 | # instance's own run directory (§6.5). Not "+": the "+" prefix drops User= |
| 24 | # and the Private*/Protect* settings but keeps the explicit |
| 25 | # TemporaryFileSystem=/BindPaths= mounts (codex R3-2), so nothing run from |
| 26 | # here can see the host's /run/carlos anyway. |
| 27 | ExecStopPost=/opt/carlos/carlos instance-exit sidecar %I |
| 28 | User=carlos |
| 29 | Group=carlos |
| 30 | # One slice per host holds the instance, its seed and its sidecar, and |
| 31 | # carries the tier (§4.3, §6.6). Slices referenced here are created on |
| 32 | # demand; no per-host unit file exists. |
| 33 | Slice=carlos-instances-%i.slice |
| 34 | # The activator owns recycling: a crash stands the entry down and the next |
| 35 | # request wakes it. systemd must not race that. |
| 36 | Restart=no |
| 37 | # SIGTERM to the app, SIGKILL to anything it left behind after |
| 38 | # TimeoutStopSec; the activator's stop budget is 20s. |
| 39 | KillMode=mixed |
| 40 | TimeoutStopSec=20 |
| 41 | # The main process is the trampoline, which stays as the namespace's init |
| 42 | # and runs the app as its child (#126): it forwards the SIGTERM and exits |
| 43 | # 128+n when the app dies by signal n. These are SIGHUP, SIGINT, SIGPIPE |
| 44 | # and SIGTERM, the deaths systemd counts as clean for a main process, so a |
| 45 | # stop still leaves the unit inactive rather than failed. |
| 46 | SuccessExitStatus=129 130 141 143 |
| 47 | # The log stream: stdout and stderr connect to the agent's per-role |
| 48 | # listener (§6.4). PID 1 opens this as root, outside the namespace, on the |
| 49 | # nosymfollow logs mount, at a path no tenant mount contains. |
| 50 | StandardOutput=file:/run/carlos/logs/%I.sidecar.sock |
| 51 | StandardError=inherit |
| 52 | # The instance's own two directories, the only writable data paths. |
| 53 | BindPaths=/data/instances/%I /run/carlos/instances/%I |
| 54 | ReadWritePaths=/data/instances/%I /run/carlos/instances/%I |
| 55 | # The working directory is the instance's own data directory, the one its |
| 56 | # database is in (discussion #256 item D). A flat child ran with cwd /, |
| 57 | # and an app writing relative paths (./data/blobs) wrote under /data; |
| 58 | # without this line a unit's cwd is / on a read-only tmpfs and that app |
| 59 | # exits at start with "read-only file system". systemd applies it after |
| 60 | # the namespace is set up, so the bind above is what it chdirs into. Same |
| 61 | # value in all three templates: the seed and the sidecar see the files |
| 62 | # the instance made from the same place. |
| 63 | WorkingDirectory=/data/instances/%I |
| 64 | # Its own build, and no other app's (#214 item 2). The namespace block |
| 65 | # empties /opt/carlos; these put back /opt/carlos/live/<host>, a link to |
| 66 | # ../app, and /opt/carlos/app, which PID 1 binds by chasing the source |
| 67 | # link to this host's live build at every start. The control service |
| 68 | # writes both sources before it starts the unit. |
| 69 | BindReadOnlyPaths=/run/carlos-control/exe/%I/app:/opt/carlos/app /run/carlos-control/exe/%I/live:/opt/carlos/live |
| 70 | |
| 71 | # --- the namespace (titogo@.service, 2026-07-31 design, on systemd 257) --- |
| 72 | ProtectSystem=strict |
| 73 | TemporaryFileSystem=/data:ro |
| 74 | TemporaryFileSystem=/run/carlos:ro |
| 75 | # Every other tree a carlos-uid unit writes on the box is hidden the same |
| 76 | # way (codex R9-2): the restore drills' cache under /var/cache, and |
| 77 | # /var/lib as a class. An instance needs nothing from either. |
| 78 | TemporaryFileSystem=/var/cache:ro |
| 79 | TemporaryFileSystem=/var/lib:ro |
| 80 | # The version store holds every app's build on the box, world-readable so |
| 81 | # a unit can exec its own (#214 item 2). An instance sees none of it: an |
| 82 | # empty /opt/carlos with the platform binary that ExecStart= and |
| 83 | # ExecStopPost= run put back, and, outside this block because it is per |
| 84 | # instance, its own build at /opt/carlos/live/<host>. |
| 85 | TemporaryFileSystem=/opt/carlos:ro |
| 86 | BindReadOnlyPaths=/opt/carlos/carlos |
| 87 | # Root-written credentials a carlos-GROUP process may read live here: |
| 88 | # /etc/carlos/relay-masters is root:carlos 0640 for carlos-relay.service, |
| 89 | # and an instance's Group=carlos would read it too (codex R10-1). An |
| 90 | # instance gets its configuration through its launch file and needs |
| 91 | # nothing from /etc/carlos. /run/carlos-control is the root control |
| 92 | # service's socket directory (design §5.2): hiding it here too means a |
| 93 | # connect from inside any instance's namespace is impossible outright, |
| 94 | # not merely refused by the peer check on the far end (final review, |
| 95 | # plan 2, item 3). |
| 96 | InaccessiblePaths=/etc/carlos /run/carlos-control |
| 97 | ProtectHome=yes |
| 98 | PrivateTmp=yes |
| 99 | PrivateDevices=yes |
| 100 | # PrivateDevices= bind-mounts the HOST's /dev/shm into the private /dev, |
| 101 | # and every instance is uid carlos, so a sibling's shared memory would be |
| 102 | # one open away. Each unit gets its own, charged to its own cgroup. |
| 103 | TemporaryFileSystem=/dev/shm:mode=1777,nosuid,nodev |
| 104 | # POSIX message queues and SysV IPC belong to an IPC namespace, and every |
| 105 | # instance is uid carlos, so a sibling's queue would be one mq_open away |
| 106 | # (#178). PrivateIPC= gives each unit its own namespace, and mq_open() |
| 107 | # goes through that namespace's own internal mount. PrivateDevices= |
| 108 | # carries the host's /dev/mqueue into the private /dev the way it carries |
| 109 | # /dev/shm, and that mount would still list and open the host namespace's |
| 110 | # queues, so it is replaced the same way. |
| 111 | PrivateIPC=yes |
| 112 | TemporaryFileSystem=/dev/mqueue:ro |
| 113 | # /sys/fs/cgroup read-only: the edge's delegated subtree is carlos-owned, |
| 114 | # and its cgroup.kill / cgroup.freeze would otherwise be one write away |
| 115 | # for a same-uid tenant (codex R9-1). |
| 116 | ProtectControlGroups=yes |
| 117 | ProtectProc=invisible |
| 118 | ProcSubset=pid |
| 119 | PrivatePIDs=yes |
| 120 | RestrictNamespaces=yes |
| 121 | NoNewPrivileges=yes |
| 122 | CapabilityBoundingSet= |
| 123 | RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX |
| 124 | # Abstract unix sockets live in the network namespace, which instances |
| 125 | # share with the box because they need outbound TCP, and the line above |
| 126 | # cannot tell them from path sockets (#178). The trampoline scopes them |
| 127 | # with Landlock before it execs the app (instanceexec.ScopeAbstractUnix): |
| 128 | # an instance can connect only to abstract sockets its own processes |
| 129 | # bound. A sibling can still bind a name first; that is a refusal of |
| 130 | # service, never a connection. |
| 131 | # |
| 132 | # Egress (#214 item 3): the internet, and nothing on the box or private. |
| 133 | # Loopback is where the console and other tenants' addr upstreams listen; |
| 134 | # RFC 1918, ULA, link-local and CGNAT are the VPC, other boxes and the |
| 135 | # cloud's internal services. IMDS stays named so the property is |
| 136 | # greppable. A cgroup filter: it drops in both directions for every |
| 137 | # socket the unit opens. The one thing an instance needs from these |
| 138 | # ranges is the box's DNS resolver, so the control service re-opens |
| 139 | # exactly the nameservers the instance sees with IPAddressAllow= on |
| 140 | # carlos-instances.slice (an allow wins, and a slice's lists reach every |
| 141 | # unit below it). The probe checks both halves. |
| 142 | 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 |
| 143 | # An allow opens an address both ways and on every port, so a loopback |
| 144 | # resolver can never be opened: a tenant could bind systemd-resolved's |
| 145 | # 127.0.0.53 itself and reach every listener bound to all addresses |
| 146 | # through it. Where resolved runs, instances resolve through its |
| 147 | # upstreams instead, and the control service opens those. The "-" makes |
| 148 | # this a no-op on a box without resolved. |
| 149 | BindReadOnlyPaths=-/run/systemd/resolve/resolv.conf:/etc/resolv.conf |
| 150 | ProtectKernelTunables=yes |
| 151 | ProtectKernelModules=yes |
| 152 | ProtectKernelLogs=yes |
| 153 | ProtectClock=yes |
| 154 | ProtectHostname=yes |
| 155 | RemoveIPC=yes |
| 156 | LockPersonality=yes |
| 157 | RestrictRealtime=yes |
| 158 | # No RestrictSUIDSGID=: systemd cannot read the mode inside openat2's |
| 159 | # open_how, so it refuses openat2 outright (ENOSYS), and every instancefs |
| 160 | # open in here is an openat2 (#126, first roll; systemd 257). What it |
| 161 | # guarded is covered: with no capabilities an instance can only make a |
| 162 | # set-id file owned by carlos, which gains nothing for another carlos |
| 163 | # process, NoNewPrivileges= voids it in here, and no other uid can reach |
| 164 | # one (the tenant trees are nosuid, PrivateTmp's sit under a root 0700 |
| 165 | # directory). |
| 166 | MemoryDenyWriteExecute=yes |
| 167 | SystemCallArchitectures=native |
| 168 | |
| 169 | # --- per-service resources; the tier lives on the host slice (§4.3) --- |
| 170 | TasksMax=256 |
| 171 | # The kernel picks a tenant before it picks anything else in carlos.slice. |
| 172 | OOMScoreAdjust=500 |
| 173 | # One OOM kill takes every process of THIS service (systemd sets |
| 174 | # memory.oom.group on the service cgroup for OOMPolicy=kill). The sidecar |
| 175 | # is a sibling service with its own family rule (§4.3). |
| 176 | OOMPolicy=kill |
| 177 | |