1{ config 2, lib 3, pkgs 4, ... 5}: 6 7with lib; 8{ 9 config.assertions = flatten ( 10 flip mapAttrsToList config.services.github-runners (name: cfg: map (mkIf cfg.enable) [ 11 { 12 assertion = !cfg.noDefaultLabels || (cfg.extraLabels != [ ]); 13 message = "`services.github-runners.${name}`: The `extraLabels` option is mandatory if `noDefaultLabels` is set"; 14 } 15 { 16 assertion = cfg.group == null || cfg.user != null; 17 message = ''`services.github-runners.${name}`: Setting `group` while leaving `user` unset runs the service as `root`. If this is really what you want, set `user = "root"` explicitly''; 18 } 19 ]) 20 ); 21 22 config.systemd.services = flip mapAttrs' config.services.github-runners (name: cfg: 23 let 24 svcName = "github-runner-${name}"; 25 systemdDir = "github-runner/${name}"; 26 27 # %t: Runtime directory root (usually /run); see systemd.unit(5) 28 runtimeDir = "%t/${systemdDir}"; 29 # %S: State directory root (usually /var/lib); see systemd.unit(5) 30 stateDir = "%S/${systemdDir}"; 31 # %L: Log directory root (usually /var/log); see systemd.unit(5) 32 logsDir = "%L/${systemdDir}"; 33 # Name of file stored in service state directory 34 currentConfigTokenFilename = ".current-token"; 35 36 workDir = if cfg.workDir == null then runtimeDir else cfg.workDir; 37 # Support old github-runner versions which don't have the `nodeRuntimes` arg yet. 38 package = cfg.package.override (old: optionalAttrs (hasAttr "nodeRuntimes" old) { inherit (cfg) nodeRuntimes; }); 39 in 40 nameValuePair svcName { 41 description = "GitHub Actions runner"; 42 43 wantedBy = [ "multi-user.target" ]; 44 wants = [ "network-online.target" ]; 45 after = [ "network.target" "network-online.target" ]; 46 47 environment = { 48 HOME = workDir; 49 RUNNER_ROOT = stateDir; 50 } // cfg.extraEnvironment; 51 52 path = (with pkgs; [ 53 bash 54 coreutils 55 git 56 gnutar 57 gzip 58 ]) ++ [ 59 config.nix.package 60 ] ++ cfg.extraPackages; 61 62 serviceConfig = mkMerge [ 63 { 64 ExecStart = "${package}/bin/Runner.Listener run --startuptype service"; 65 66 # Does the following, sequentially: 67 # - If the module configuration or the token has changed, purge the state directory, 68 # and create the current and the new token file with the contents of the configured 69 # token. While both files have the same content, only the later is accessible by 70 # the service user. 71 # - Configure the runner using the new token file. When finished, delete it. 72 # - Set up the directory structure by creating the necessary symlinks. 73 ExecStartPre = 74 let 75 # Wrapper script which expects the full path of the state, working and logs 76 # directory as arguments. Overrides the respective systemd variables to provide 77 # unambiguous directory names. This becomes relevant, for example, if the 78 # caller overrides any of the StateDirectory=, RuntimeDirectory= or LogDirectory= 79 # to contain more than one directory. This causes systemd to set the respective 80 # environment variables with the path of all of the given directories, separated 81 # by a colon. 82 writeScript = name: lines: pkgs.writeShellScript "${svcName}-${name}.sh" '' 83 set -euo pipefail 84 85 STATE_DIRECTORY="$1" 86 WORK_DIRECTORY="$2" 87 LOGS_DIRECTORY="$3" 88 89 ${lines} 90 ''; 91 runnerRegistrationConfig = getAttrs [ 92 "ephemeral" 93 "extraLabels" 94 "name" 95 "noDefaultLabels" 96 "runnerGroup" 97 "tokenFile" 98 "url" 99 "workDir" 100 ] 101 cfg; 102 newConfigPath = builtins.toFile "${svcName}-config.json" (builtins.toJSON runnerRegistrationConfig); 103 currentConfigPath = "$STATE_DIRECTORY/.nixos-current-config.json"; 104 newConfigTokenPath = "$STATE_DIRECTORY/.new-token"; 105 currentConfigTokenPath = "$STATE_DIRECTORY/${currentConfigTokenFilename}"; 106 107 runnerCredFiles = [ 108 ".credentials" 109 ".credentials_rsaparams" 110 ".runner" 111 ]; 112 unconfigureRunner = writeScript "unconfigure" '' 113 copy_tokens() { 114 # Copy the configured token file to the state dir and allow the service user to read the file 115 install --mode=666 ${escapeShellArg cfg.tokenFile} "${newConfigTokenPath}" 116 # Also copy current file to allow for a diff on the next start 117 install --mode=600 ${escapeShellArg cfg.tokenFile} "${currentConfigTokenPath}" 118 } 119 clean_state() { 120 find "$STATE_DIRECTORY/" -mindepth 1 -delete 121 copy_tokens 122 } 123 diff_config() { 124 changed=0 125 # Check for module config changes 126 [[ -f "${currentConfigPath}" ]] \ 127 && ${pkgs.diffutils}/bin/diff -q '${newConfigPath}' "${currentConfigPath}" >/dev/null 2>&1 \ 128 || changed=1 129 # Also check the content of the token file 130 [[ -f "${currentConfigTokenPath}" ]] \ 131 && ${pkgs.diffutils}/bin/diff -q "${currentConfigTokenPath}" ${escapeShellArg cfg.tokenFile} >/dev/null 2>&1 \ 132 || changed=1 133 # If the config has changed, remove old state and copy tokens 134 if [[ "$changed" -eq 1 ]]; then 135 echo "Config has changed, removing old runner state." 136 echo "The old runner will still appear in the GitHub Actions UI." \ 137 "You have to remove it manually." 138 clean_state 139 fi 140 } 141 if [[ "${optionalString cfg.ephemeral "1"}" ]]; then 142 # In ephemeral mode, we always want to start with a clean state 143 clean_state 144 elif [[ "$(ls -A "$STATE_DIRECTORY")" ]]; then 145 # There are state files from a previous run; diff them to decide if we need a new registration 146 diff_config 147 else 148 # The state directory is entirely empty which indicates a first start 149 copy_tokens 150 fi 151 # Always clean workDir 152 find -H "$WORK_DIRECTORY" -mindepth 1 -delete 153 ''; 154 configureRunner = writeScript "configure" '' 155 if [[ -e "${newConfigTokenPath}" ]]; then 156 echo "Configuring GitHub Actions Runner" 157 args=( 158 --unattended 159 --disableupdate 160 --work "$WORK_DIRECTORY" 161 --url ${escapeShellArg cfg.url} 162 --labels ${escapeShellArg (concatStringsSep "," cfg.extraLabels)} 163 ${optionalString (cfg.name != null ) "--name ${escapeShellArg cfg.name}"} 164 ${optionalString cfg.replace "--replace"} 165 ${optionalString (cfg.runnerGroup != null) "--runnergroup ${escapeShellArg cfg.runnerGroup}"} 166 ${optionalString cfg.ephemeral "--ephemeral"} 167 ${optionalString cfg.noDefaultLabels "--no-default-labels"} 168 ) 169 # If the token file contains a PAT (i.e., it starts with "ghp_" or "github_pat_"), we have to use the --pat option, 170 # if it is not a PAT, we assume it contains a registration token and use the --token option 171 token=$(<"${newConfigTokenPath}") 172 if [[ "$token" =~ ^ghp_* ]] || [[ "$token" =~ ^github_pat_* ]]; then 173 args+=(--pat "$token") 174 else 175 args+=(--token "$token") 176 fi 177 ${package}/bin/Runner.Listener configure "''${args[@]}" 178 # Move the automatically created _diag dir to the logs dir 179 mkdir -p "$STATE_DIRECTORY/_diag" 180 cp -r "$STATE_DIRECTORY/_diag/." "$LOGS_DIRECTORY/" 181 rm -rf "$STATE_DIRECTORY/_diag/" 182 # Cleanup token from config 183 rm "${newConfigTokenPath}" 184 # Symlink to new config 185 ln -s '${newConfigPath}' "${currentConfigPath}" 186 fi 187 ''; 188 setupWorkDir = writeScript "setup-work-dirs" '' 189 # Link _diag dir 190 ln -s "$LOGS_DIRECTORY" "$WORK_DIRECTORY/_diag" 191 192 # Link the runner credentials to the work dir 193 ln -s "$STATE_DIRECTORY"/{${lib.concatStringsSep "," runnerCredFiles}} "$WORK_DIRECTORY/" 194 ''; 195 in 196 map (x: "${x} ${escapeShellArgs [ stateDir workDir logsDir ]}") [ 197 "+${unconfigureRunner}" # runs as root 198 configureRunner 199 setupWorkDir 200 ]; 201 202 # If running in ephemeral mode, restart the service on-exit (i.e., successful de-registration of the runner) 203 # to trigger a fresh registration. 204 Restart = if cfg.ephemeral then "on-success" else "no"; 205 # If the runner exits with `ReturnCode.RetryableError = 2`, always restart the service: 206 # https://github.com/actions/runner/blob/40ed7f8/src/Runner.Common/Constants.cs#L146 207 RestartForceExitStatus = [ 2 ]; 208 209 # Contains _diag 210 LogsDirectory = [ systemdDir ]; 211 # Default RUNNER_ROOT which contains ephemeral Runner data 212 RuntimeDirectory = [ systemdDir ]; 213 # Home of persistent runner data, e.g., credentials 214 StateDirectory = [ systemdDir ]; 215 StateDirectoryMode = "0700"; 216 WorkingDirectory = workDir; 217 218 InaccessiblePaths = [ 219 # Token file path given in the configuration, if visible to the service 220 "-${cfg.tokenFile}" 221 # Token file in the state directory 222 "${stateDir}/${currentConfigTokenFilename}" 223 ]; 224 225 KillSignal = "SIGINT"; 226 227 # Hardening (may overlap with DynamicUser=) 228 # The following options are only for optimizing: 229 # systemd-analyze security github-runner 230 AmbientCapabilities = mkBefore [ "" ]; 231 CapabilityBoundingSet = mkBefore [ "" ]; 232 # ProtectClock= adds DeviceAllow=char-rtc r 233 DeviceAllow = mkBefore [ "" ]; 234 NoNewPrivileges = mkDefault true; 235 PrivateDevices = mkDefault true; 236 PrivateMounts = mkDefault true; 237 PrivateTmp = mkDefault true; 238 PrivateUsers = mkDefault true; 239 ProtectClock = mkDefault true; 240 ProtectControlGroups = mkDefault true; 241 ProtectHome = mkDefault true; 242 ProtectHostname = mkDefault true; 243 ProtectKernelLogs = mkDefault true; 244 ProtectKernelModules = mkDefault true; 245 ProtectKernelTunables = mkDefault true; 246 ProtectSystem = mkDefault "strict"; 247 RemoveIPC = mkDefault true; 248 RestrictNamespaces = mkDefault true; 249 RestrictRealtime = mkDefault true; 250 RestrictSUIDSGID = mkDefault true; 251 UMask = mkDefault "0066"; 252 ProtectProc = mkDefault "invisible"; 253 SystemCallFilter = mkBefore [ 254 "~@clock" 255 "~@cpu-emulation" 256 "~@module" 257 "~@mount" 258 "~@obsolete" 259 "~@raw-io" 260 "~@reboot" 261 "~capset" 262 "~setdomainname" 263 "~sethostname" 264 ]; 265 RestrictAddressFamilies = mkBefore [ "AF_INET" "AF_INET6" "AF_UNIX" "AF_NETLINK" ]; 266 267 BindPaths = lib.optionals (cfg.workDir != null) [ cfg.workDir ]; 268 269 # Needs network access 270 PrivateNetwork = mkDefault false; 271 # Cannot be true due to Node 272 MemoryDenyWriteExecute = mkDefault false; 273 274 # The more restrictive "pid" option makes `nix` commands in CI emit 275 # "GC Warning: Couldn't read /proc/stat" 276 # You may want to set this to "pid" if not using `nix` commands 277 ProcSubset = mkDefault "all"; 278 # Coverage programs for compiled code such as `cargo-tarpaulin` disable 279 # ASLR (address space layout randomization) which requires the 280 # `personality` syscall 281 # You may want to set this to `true` if not using coverage tooling on 282 # compiled code 283 LockPersonality = mkDefault false; 284 285 DynamicUser = mkDefault true; 286 } 287 (mkIf (cfg.user != null) { 288 DynamicUser = false; 289 User = cfg.user; 290 }) 291 (mkIf (cfg.group != null) { 292 DynamicUser = false; 293 Group = cfg.group; 294 }) 295 cfg.serviceOverrides 296 ]; 297 } 298 ); 299}