1{ config, pkgs, lib, ... }:
2with lib;
3let
4 cfg = config.services.github-runner;
5 svcName = "github-runner";
6 systemdDir = "${svcName}/${cfg.name}";
7 # %t: Runtime directory root (usually /run); see systemd.unit(5)
8 runtimeDir = "%t/${systemdDir}";
9 # %S: State directory root (usually /var/lib); see systemd.unit(5)
10 stateDir = "%S/${systemdDir}";
11 # %L: Log directory root (usually /var/log); see systemd.unit(5)
12 logsDir = "%L/${systemdDir}";
13in
14{
15 options.services.github-runner = {
16 enable = mkOption {
17 default = false;
18 example = true;
19 description = ''
20 Whether to enable GitHub Actions runner.
21
22 Note: GitHub recommends using self-hosted runners with private repositories only. Learn more here:
23 <link xlink:href="https://docs.github.com/en/actions/hosting-your-own-runners/about-self-hosted-runners"
24 >About self-hosted runners</link>.
25 '';
26 type = lib.types.bool;
27 };
28
29 url = mkOption {
30 type = types.str;
31 description = ''
32 Repository to add the runner to.
33
34 Changing this option triggers a new runner registration.
35 '';
36 example = "https://github.com/nixos/nixpkgs";
37 };
38
39 tokenFile = mkOption {
40 type = types.path;
41 description = ''
42 The full path to a file which contains the runner registration token.
43 The file should contain exactly one line with the token without any newline.
44 The token can be used to re-register a runner of the same name but is time-limited.
45
46 Changing this option or the file's content triggers a new runner registration.
47 '';
48 example = "/run/secrets/github-runner/nixos.token";
49 };
50
51 name = mkOption {
52 # Same pattern as for `networking.hostName`
53 type = types.strMatching "^$|^[[:alnum:]]([[:alnum:]_-]{0,61}[[:alnum:]])?$";
54 description = ''
55 Name of the runner to configure. Defaults to the hostname.
56
57 Changing this option triggers a new runner registration.
58 '';
59 example = "nixos";
60 default = config.networking.hostName;
61 };
62
63 runnerGroup = mkOption {
64 type = types.nullOr types.str;
65 description = ''
66 Name of the runner group to add this runner to (defaults to the default runner group).
67
68 Changing this option triggers a new runner registration.
69 '';
70 default = null;
71 };
72
73 extraLabels = mkOption {
74 type = types.listOf types.str;
75 description = ''
76 Extra labels in addition to the default (<literal>["self-hosted", "Linux", "X64"]</literal>).
77
78 Changing this option triggers a new runner registration.
79 '';
80 example = literalExpression ''[ "nixos" ]'';
81 default = [ ];
82 };
83
84 replace = mkOption {
85 type = types.bool;
86 description = ''
87 Replace any existing runner with the same name.
88
89 Without this flag, registering a new runner with the same name fails.
90 '';
91 default = false;
92 };
93
94 extraPackages = mkOption {
95 type = types.listOf types.package;
96 description = ''
97 Extra packages to add to <literal>PATH</literal> of the service to make them available to workflows.
98 '';
99 default = [ ];
100 };
101
102 package = mkOption {
103 type = types.package;
104 description = ''
105 Which github-runner derivation to use.
106 '';
107 default = pkgs.github-runner;
108 defaultText = literalExpression "pkgs.github-runner";
109 };
110 };
111
112 config = mkIf cfg.enable {
113 warnings = optionals (isStorePath cfg.tokenFile) [
114 ''
115 `services.github-runner.tokenFile` points to the Nix store and, therefore, is world-readable.
116 Consider using a path outside of the Nix store to keep the token private.
117 ''
118 ];
119
120 systemd.services.${svcName} = {
121 description = "GitHub Actions runner";
122
123 wantedBy = [ "multi-user.target" ];
124 wants = [ "network-online.target" ];
125 after = [ "network.target" "network-online.target" ];
126
127 environment = {
128 HOME = runtimeDir;
129 RUNNER_ROOT = runtimeDir;
130 };
131
132 path = (with pkgs; [
133 bash
134 coreutils
135 git
136 gnutar
137 gzip
138 ]) ++ [
139 config.nix.package
140 ] ++ cfg.extraPackages;
141
142 serviceConfig = rec {
143 ExecStart = "${cfg.package}/bin/runsvc.sh";
144
145 # Does the following, sequentially:
146 # - Copy the current and the previous `tokenFile` to the $RUNTIME_DIRECTORY
147 # and make it accessible to the service user to allow for a content
148 # comparison.
149 # - If the module configuration or the token has changed, clear the state directory.
150 # - Configure the runner.
151 # - Copy the configured `tokenFile` to the $STATE_DIRECTORY and make it
152 # inaccessible to the service user.
153 # - Set up the directory structure by creating the necessary symlinks.
154 ExecStartPre =
155 let
156 # Wrapper script which expects the full path of the state, runtime and logs
157 # directory as arguments. Overrides the respective systemd variables to provide
158 # unambiguous directory names. This becomes relevant, for example, if the
159 # caller overrides any of the StateDirectory=, RuntimeDirectory= or LogDirectory=
160 # to contain more than one directory. This causes systemd to set the respective
161 # environment variables with the path of all of the given directories, separated
162 # by a colon.
163 writeScript = name: lines: pkgs.writeShellScript "${svcName}-${name}.sh" ''
164 set -euo pipefail
165
166 STATE_DIRECTORY="$1"
167 RUNTIME_DIRECTORY="$2"
168 LOGS_DIRECTORY="$3"
169
170 ${lines}
171 '';
172 currentConfigPath = "$STATE_DIRECTORY/.nixos-current-config.json";
173 runnerRegistrationConfig = getAttrs [ "name" "tokenFile" "url" "runnerGroup" "extraLabels" ] cfg;
174 newConfigPath = builtins.toFile "${svcName}-config.json" (builtins.toJSON runnerRegistrationConfig);
175 currentConfigTokenFilename = ".current-token";
176 newConfigTokenFilename = ".new-token";
177 runnerCredFiles = [
178 ".credentials"
179 ".credentials_rsaparams"
180 ".runner"
181 ];
182 ownConfigTokens = writeScript "own-config-tokens" ''
183 # Copy current and new token file to runtime dir and make it accessible to the service user
184 cp ${escapeShellArg cfg.tokenFile} "$RUNTIME_DIRECTORY/${newConfigTokenFilename}"
185 chmod 600 "$RUNTIME_DIRECTORY/${newConfigTokenFilename}"
186 chown "$USER" "$RUNTIME_DIRECTORY/${newConfigTokenFilename}"
187
188 if [[ -e "$STATE_DIRECTORY/${currentConfigTokenFilename}" ]]; then
189 cp "$STATE_DIRECTORY/${currentConfigTokenFilename}" "$RUNTIME_DIRECTORY/${currentConfigTokenFilename}"
190 chmod 600 "$RUNTIME_DIRECTORY/${currentConfigTokenFilename}"
191 chown "$USER" "$RUNTIME_DIRECTORY/${currentConfigTokenFilename}"
192 fi
193 '';
194 disownConfigTokens = writeScript "disown-config-tokens" ''
195 # Make the token inaccessible to the runner service user
196 chmod 600 "$STATE_DIRECTORY/${currentConfigTokenFilename}"
197 chown root:root "$STATE_DIRECTORY/${currentConfigTokenFilename}"
198 '';
199 unconfigureRunner = writeScript "unconfigure" ''
200 differs=
201 # Set `differs = 1` if current and new runner config differ or if `currentConfigPath` does not exist
202 ${pkgs.diffutils}/bin/diff -q '${newConfigPath}' "${currentConfigPath}" >/dev/null 2>&1 || differs=1
203 # Also trigger a registration if the token content changed
204 ${pkgs.diffutils}/bin/diff -q \
205 "$RUNTIME_DIRECTORY"/{${currentConfigTokenFilename},${newConfigTokenFilename}} \
206 >/dev/null 2>&1 || differs=1
207
208 if [[ -n "$differs" ]]; then
209 echo "Config has changed, removing old runner state."
210 echo "The old runner will still appear in the GitHub Actions UI." \
211 "You have to remove it manually."
212 find "$STATE_DIRECTORY/" -mindepth 1 -delete
213 fi
214 '';
215 configureRunner = writeScript "configure" ''
216 empty=$(ls -A "$STATE_DIRECTORY")
217 if [[ -z "$empty" ]]; then
218 echo "Configuring GitHub Actions Runner"
219 token=$(< "$RUNTIME_DIRECTORY"/${newConfigTokenFilename})
220 RUNNER_ROOT="$STATE_DIRECTORY" ${cfg.package}/bin/config.sh \
221 --unattended \
222 --work "$RUNTIME_DIRECTORY" \
223 --url ${escapeShellArg cfg.url} \
224 --token "$token" \
225 --labels ${escapeShellArg (concatStringsSep "," cfg.extraLabels)} \
226 --name ${escapeShellArg cfg.name} \
227 ${optionalString cfg.replace "--replace"} \
228 ${optionalString (cfg.runnerGroup != null) "--runnergroup ${escapeShellArg cfg.runnerGroup}"}
229
230 # Move the automatically created _diag dir to the logs dir
231 mkdir -p "$STATE_DIRECTORY/_diag"
232 cp -r "$STATE_DIRECTORY/_diag/." "$LOGS_DIRECTORY/"
233 rm -rf "$STATE_DIRECTORY/_diag/"
234
235 # Cleanup token from config
236 rm -f "$RUNTIME_DIRECTORY"/${currentConfigTokenFilename}
237 mv "$RUNTIME_DIRECTORY"/${newConfigTokenFilename} "$STATE_DIRECTORY/${currentConfigTokenFilename}"
238
239 # Symlink to new config
240 ln -s '${newConfigPath}' "${currentConfigPath}"
241 fi
242 '';
243 setupRuntimeDir = writeScript "setup-runtime-dirs" ''
244 # Link _diag dir
245 ln -s "$LOGS_DIRECTORY" "$RUNTIME_DIRECTORY/_diag"
246
247 # Link the runner credentials to the runtime dir
248 ln -s "$STATE_DIRECTORY"/{${lib.concatStringsSep "," runnerCredFiles}} "$RUNTIME_DIRECTORY/"
249 '';
250 in
251 map (x: "${x} ${escapeShellArgs [ stateDir runtimeDir logsDir ]}") [
252 "+${ownConfigTokens}" # runs as root
253 unconfigureRunner
254 configureRunner
255 "+${disownConfigTokens}" # runs as root
256 setupRuntimeDir
257 ];
258
259 # Contains _diag
260 LogsDirectory = [ systemdDir ];
261 # Default RUNNER_ROOT which contains ephemeral Runner data
262 RuntimeDirectory = [ systemdDir ];
263 # Home of persistent runner data, e.g., credentials
264 StateDirectory = [ systemdDir ];
265 StateDirectoryMode = "0700";
266 WorkingDirectory = runtimeDir;
267
268 # By default, use a dynamically allocated user
269 DynamicUser = true;
270
271 KillMode = "process";
272 KillSignal = "SIGTERM";
273
274 # Hardening (may overlap with DynamicUser=)
275 # The following options are only for optimizing:
276 # systemd-analyze security github-runner
277 AmbientCapabilities = "";
278 CapabilityBoundingSet = "";
279 # ProtectClock= adds DeviceAllow=char-rtc r
280 DeviceAllow = "";
281 LockPersonality = true;
282 NoNewPrivileges = true;
283 PrivateDevices = true;
284 PrivateMounts = true;
285 PrivateTmp = true;
286 PrivateUsers = true;
287 ProtectClock = true;
288 ProtectControlGroups = true;
289 ProtectHome = true;
290 ProtectHostname = true;
291 ProtectKernelLogs = true;
292 ProtectKernelModules = true;
293 ProtectKernelTunables = true;
294 ProtectSystem = "strict";
295 RemoveIPC = true;
296 RestrictNamespaces = true;
297 RestrictRealtime = true;
298 RestrictSUIDSGID = true;
299 UMask = "0066";
300
301 # Needs network access
302 PrivateNetwork = false;
303 # Cannot be true due to Node
304 MemoryDenyWriteExecute = false;
305 };
306 };
307 };
308}