# The Forgejo Actions runner of this repository's CI, on halkyon.li's host at home (ADR # 0039, D6, D16; on learn.halkyon.eu's server until it retired in November 2026). # Installed as /etc/systemd/system/forgejo-runner.service; its configuration is # config.yml, next to this file. It runs in the nightly window alone: started at 02:00 by # forgejo-runner.timer, stopped three hours later (RuntimeMaxSec), while night.sh rests # the instance's heavy sidecars (halkyon-night.timer, halkyon-morning.timer). Jobs pushed # during the day wait in Forgejo's queue. # # Jobs run directly on the host as the unprivileged user `runner` ("without any # isolation" from that user, as Forgejo's security guide puts it), and they run our # dependencies' build code. On the production server, so everything below keeps them # away from production and makes sure CI can't starve the instance. Docker is out of # reach entirely: `runner` isn't in the docker group, and the socket is hidden too. # # `runner` has user and group ID 61000, and must never share a user ID with a # container's processes (PostgreSQL and the server run as 999; Docker doesn't remap # them): processes of one user ID can read each other's environment and, through # /proc, their files. `useradd --system` picks 999 when it is free, so create it with # useradd --system --uid 61000 --user-group --no-create-home \ # --home-dir /var/lib/forgejo-runner --shell /usr/sbin/nologin runner # Its home is /data/forgejo-runner on the HDD (the root disk has no room for Flutter and # the caches), seen by the runner as /var/lib/forgejo-runner (BindPaths, below): # install -d -o runner -g runner -m 700 /data/forgejo-runner /var/lib/forgejo-runner # CI's first step checks this sandbox and fails if it leaks. [Unit] Description=Forgejo Actions runner (CI of halkyon_learn; jobs on the host) Documentation=https://forgejo.org/docs/latest/admin/actions/security/ After=network-online.target Wants=network-online.target # Its folder is on the HDD, which its LUKS key file unlocks once the root disk is open. RequiresMountsFor=/data [Service] User=runner Group=runner WorkingDirectory=/var/lib/forgejo-runner ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo-runner/config.yml # The runner's token, from a root-only file. LoadCredential=token:/etc/forgejo-runner/token Restart=on-failure RestartSec=30 # The runner waits up to 10 minutes for a running job (shutdown_timeout). TimeoutStopSec=11min # The nightly window's end: three hours after forgejo-runner.timer started it at 02:00. A # job still running is stopped, and the next one's Leftovers step clears what it left. RuntimeMaxSec=3h # Away from production: /home (deploy's .env, the checkout, the database dumps) is # invisible, and so is /data, where /home really lies, with Docker's data and the media, # but for the runner's own folder; the rest of the system is read-only, other users' # processes are hidden, and Docker and containerd out of reach. ProtectHome=yes ProtectSystem=strict TemporaryFileSystem=/data:ro BindPaths=/data/forgejo-runner:/var/lib/forgejo-runner ReadWritePaths=/var/lib/forgejo-runner PrivateTmp=yes ProtectProc=invisible InaccessiblePaths=-/run/docker.sock -/run/containerd -/var/lib/docker -/var/lib/containerd # The network: the internet over IPv4 alone. No private IPv4 network (the home network # and its router, Docker's networks, where the database listens, the tunnel to ramiel, # Multipass' bridges, CGNAT and link-local addresses), and no IPv6 at all: the home # network's IPv6 addresses are public ones, under a prefix the provider changes, on the # same interface as the internet, so no address or interface could tell them apart. All # CI fetches (pub.dev, GitHub, code.halkyon.app) answers over IPv4. IPAddressDeny=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 169.254.0.0/16 ::/0 NoNewPrivileges=yes CapabilityBoundingSet= AmbientCapabilities= SystemCallArchitectures=native # No RestrictSUIDSGID=: its filter can't see the mode inside openat2's argument, so it # refuses openat2 altogether (ENOSYS), and GNU tar 1.35 enters every folder it unpacks # through openat2 without falling back: Flutter's Gradle wrapper failed to unpack (found # 12 Nov 2026). NoNewPrivileges= and the empty capability set keep a set-user-ID file # from raising anything here, and one the runner made is its own, in its 0700 folder. ProtectKernelTunables=yes ProtectKernelModules=yes ProtectKernelLogs=yes ProtectControlGroups=yes ProtectClock=yes ProtectHostname=yes LockPersonality=yes RestrictRealtime=yes RestrictNamespaces=yes RemoveIPC=yes # Netlink stays: Dart lists network interfaces through it. RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK # System calls no CI job needs fail with an error rather than killing the job. SystemCallFilter=~@clock @cpu-emulation @debug @module @mount @obsolete @raw-io @reboot @swap SystemCallErrorNumber=EPERM UMask=0077 # Never at the instance's expense, which keeps serving through the night: three of the # four cores (its servers idle then), at most 5 GB of the 7.2 (the kernel reclaims from CI # first above 4.5 GB), and swap beyond, the lowest priority for CPU and disk, and CI's # processes the first the kernel kills should the whole machine run out of memory. A # cold `dart analyze` of the workspace with its lint plugin needs about 5.4 GB (3.7 GB # warm), so it swaps a little rather than being killed: slower, within the window. CPUQuota=300% MemoryHigh=4.5G MemoryMax=5G OOMScoreAdjust=1000 TasksMax=2048 Nice=10 IOSchedulingClass=best-effort IOSchedulingPriority=7 # No [Install]: never started at boot, only by forgejo-runner.timer.