at 22.05-pre 12 kB view raw
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}