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 = literalExample ''[ "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
103 config = mkIf cfg.enable {
104 warnings = optionals (isStorePath cfg.tokenFile) [
105 ''
106 `services.github-runner.tokenFile` points to the Nix store and, therefore, is world-readable.
107 Consider using a path outside of the Nix store to keep the token private.
108 ''
109 ];
110
111 systemd.services.${svcName} = {
112 description = "GitHub Actions runner";
113
114 wantedBy = [ "multi-user.target" ];
115 wants = [ "network-online.target" ];
116 after = [ "network.target" "network-online.target" ];
117
118 environment = {
119 HOME = runtimeDir;
120 RUNNER_ROOT = runtimeDir;
121 };
122
123 path = (with pkgs; [
124 bash
125 coreutils
126 git
127 gnutar
128 gzip
129 ]) ++ [
130 config.nix.package
131 ] ++ cfg.extraPackages;
132
133 serviceConfig = rec {
134 ExecStart = "${pkgs.github-runner}/bin/runsvc.sh";
135
136 # Does the following, sequentially:
137 # - Copy the current and the previous `tokenFile` to the $RUNTIME_DIRECTORY
138 # and make it accessible to the service user to allow for a content
139 # comparison.
140 # - If the module configuration or the token has changed, clear the state directory.
141 # - Configure the runner.
142 # - Copy the configured `tokenFile` to the $STATE_DIRECTORY and make it
143 # inaccessible to the service user.
144 # - Set up the directory structure by creating the necessary symlinks.
145 ExecStartPre =
146 let
147 # Wrapper script which expects the full path of the state, runtime and logs
148 # directory as arguments. Overrides the respective systemd variables to provide
149 # unambiguous directory names. This becomes relevant, for example, if the
150 # caller overrides any of the StateDirectory=, RuntimeDirectory= or LogDirectory=
151 # to contain more than one directory. This causes systemd to set the respective
152 # environment variables with the path of all of the given directories, separated
153 # by a colon.
154 writeScript = name: lines: pkgs.writeShellScript "${svcName}-${name}.sh" ''
155 set -euo pipefail
156
157 STATE_DIRECTORY="$1"
158 RUNTIME_DIRECTORY="$2"
159 LOGS_DIRECTORY="$3"
160
161 ${lines}
162 '';
163 currentConfigPath = "$STATE_DIRECTORY/.nixos-current-config.json";
164 runnerRegistrationConfig = getAttrs [ "name" "tokenFile" "url" "runnerGroup" "extraLabels" ] cfg;
165 newConfigPath = builtins.toFile "${svcName}-config.json" (builtins.toJSON runnerRegistrationConfig);
166 currentConfigTokenFilename = ".current-token";
167 newConfigTokenFilename = ".new-token";
168 runnerCredFiles = [
169 ".credentials"
170 ".credentials_rsaparams"
171 ".runner"
172 ];
173 ownConfigTokens = writeScript "own-config-tokens" ''
174 # Copy current and new token file to runtime dir and make it accessible to the service user
175 cp ${escapeShellArg cfg.tokenFile} "$RUNTIME_DIRECTORY/${newConfigTokenFilename}"
176 chmod 600 "$RUNTIME_DIRECTORY/${newConfigTokenFilename}"
177 chown "$USER" "$RUNTIME_DIRECTORY/${newConfigTokenFilename}"
178
179 if [[ -e "$STATE_DIRECTORY/${currentConfigTokenFilename}" ]]; then
180 cp "$STATE_DIRECTORY/${currentConfigTokenFilename}" "$RUNTIME_DIRECTORY/${currentConfigTokenFilename}"
181 chmod 600 "$RUNTIME_DIRECTORY/${currentConfigTokenFilename}"
182 chown "$USER" "$RUNTIME_DIRECTORY/${currentConfigTokenFilename}"
183 fi
184 '';
185 disownConfigTokens = writeScript "disown-config-tokens" ''
186 # Make the token inaccessible to the runner service user
187 chmod 600 "$STATE_DIRECTORY/${currentConfigTokenFilename}"
188 chown root:root "$STATE_DIRECTORY/${currentConfigTokenFilename}"
189 '';
190 unconfigureRunner = writeScript "unconfigure" ''
191 differs=
192 # Set `differs = 1` if current and new runner config differ or if `currentConfigPath` does not exist
193 ${pkgs.diffutils}/bin/diff -q '${newConfigPath}' "${currentConfigPath}" >/dev/null 2>&1 || differs=1
194 # Also trigger a registration if the token content changed
195 ${pkgs.diffutils}/bin/diff -q \
196 "$RUNTIME_DIRECTORY"/{${currentConfigTokenFilename},${newConfigTokenFilename}} \
197 >/dev/null 2>&1 || differs=1
198
199 if [[ -n "$differs" ]]; then
200 echo "Config has changed, removing old runner state."
201 echo "The old runner will still appear in the GitHub Actions UI." \
202 "You have to remove it manually."
203 find "$STATE_DIRECTORY/" -mindepth 1 -delete
204 fi
205 '';
206 configureRunner = writeScript "configure" ''
207 empty=$(ls -A "$STATE_DIRECTORY")
208 if [[ -z "$empty" ]]; then
209 echo "Configuring GitHub Actions Runner"
210 token=$(< "$RUNTIME_DIRECTORY"/${newConfigTokenFilename})
211 RUNNER_ROOT="$STATE_DIRECTORY" ${pkgs.github-runner}/bin/config.sh \
212 --unattended \
213 --work "$RUNTIME_DIRECTORY" \
214 --url ${escapeShellArg cfg.url} \
215 --token "$token" \
216 --labels ${escapeShellArg (concatStringsSep "," cfg.extraLabels)} \
217 --name ${escapeShellArg cfg.name} \
218 ${optionalString cfg.replace "--replace"} \
219 ${optionalString (cfg.runnerGroup != null) "--runnergroup ${escapeShellArg cfg.runnerGroup}"}
220
221 # Move the automatically created _diag dir to the logs dir
222 mkdir -p "$STATE_DIRECTORY/_diag"
223 cp -r "$STATE_DIRECTORY/_diag/." "$LOGS_DIRECTORY/"
224 rm -rf "$STATE_DIRECTORY/_diag/"
225
226 # Cleanup token from config
227 rm -f "$RUNTIME_DIRECTORY"/${currentConfigTokenFilename}
228 mv "$RUNTIME_DIRECTORY"/${newConfigTokenFilename} "$STATE_DIRECTORY/${currentConfigTokenFilename}"
229
230 # Symlink to new config
231 ln -s '${newConfigPath}' "${currentConfigPath}"
232 fi
233 '';
234 setupRuntimeDir = writeScript "setup-runtime-dirs" ''
235 # Link _diag dir
236 ln -s "$LOGS_DIRECTORY" "$RUNTIME_DIRECTORY/_diag"
237
238 # Link the runner credentials to the runtime dir
239 ln -s "$STATE_DIRECTORY"/{${lib.concatStringsSep "," runnerCredFiles}} "$RUNTIME_DIRECTORY/"
240 '';
241 in
242 map (x: "${x} ${escapeShellArgs [ stateDir runtimeDir logsDir ]}") [
243 "+${ownConfigTokens}" # runs as root
244 unconfigureRunner
245 configureRunner
246 "+${disownConfigTokens}" # runs as root
247 setupRuntimeDir
248 ];
249
250 # Contains _diag
251 LogsDirectory = [ systemdDir ];
252 # Default RUNNER_ROOT which contains ephemeral Runner data
253 RuntimeDirectory = [ systemdDir ];
254 # Home of persistent runner data, e.g., credentials
255 StateDirectory = [ systemdDir ];
256 StateDirectoryMode = "0700";
257 WorkingDirectory = runtimeDir;
258
259 # By default, use a dynamically allocated user
260 DynamicUser = true;
261
262 KillMode = "process";
263 KillSignal = "SIGTERM";
264
265 # Hardening (may overlap with DynamicUser=)
266 # The following options are only for optimizing:
267 # systemd-analyze security github-runner
268 AmbientCapabilities = "";
269 CapabilityBoundingSet = "";
270 # ProtectClock= adds DeviceAllow=char-rtc r
271 DeviceAllow = "";
272 LockPersonality = true;
273 NoNewPrivileges = true;
274 PrivateDevices = true;
275 PrivateMounts = true;
276 PrivateTmp = true;
277 PrivateUsers = true;
278 ProtectClock = true;
279 ProtectControlGroups = true;
280 ProtectHome = true;
281 ProtectHostname = true;
282 ProtectKernelLogs = true;
283 ProtectKernelModules = true;
284 ProtectKernelTunables = true;
285 ProtectSystem = "strict";
286 RemoveIPC = true;
287 RestrictNamespaces = true;
288 RestrictRealtime = true;
289 RestrictSUIDSGID = true;
290 UMask = "0066";
291
292 # Needs network access
293 PrivateNetwork = false;
294 # Cannot be true due to Node
295 MemoryDenyWriteExecute = false;
296 };
297 };
298 };
299}