diff --git a/hosts/bekkalokk/services/gitea/default.nix b/hosts/bekkalokk/services/gitea/default.nix index c54ce81..12dc889 100644 --- a/hosts/bekkalokk/services/gitea/default.nix +++ b/hosts/bekkalokk/services/gitea/default.nix @@ -6,7 +6,8 @@ let in { imports = [ ./ci.nix - ./import-users.nix + ./import-users + ./web-secret-provider ]; sops.secrets = { diff --git a/hosts/bekkalokk/services/gitea/import-users.nix b/hosts/bekkalokk/services/gitea/import-users/default.nix similarity index 100% rename from hosts/bekkalokk/services/gitea/import-users.nix rename to hosts/bekkalokk/services/gitea/import-users/default.nix diff --git a/hosts/bekkalokk/services/gitea/gitea-import-users.py b/hosts/bekkalokk/services/gitea/import-users/gitea-import-users.py similarity index 100% rename from hosts/bekkalokk/services/gitea/gitea-import-users.py rename to hosts/bekkalokk/services/gitea/import-users/gitea-import-users.py diff --git a/hosts/bekkalokk/services/gitea/web-secret-provider/default.nix b/hosts/bekkalokk/services/gitea/web-secret-provider/default.nix new file mode 100644 index 0000000..e929045 --- /dev/null +++ b/hosts/bekkalokk/services/gitea/web-secret-provider/default.nix @@ -0,0 +1,119 @@ +{ config, pkgs, lib, ... }: +let + organizations = [ + "Drift" + "Projects" + "Kurs" + ]; + + giteaCfg = config.services.gitea; + + giteaWebSecretProviderScript = pkgs.writers.writePython3 "gitea-web-secret-provider" { + libraries = with pkgs.python3Packages; [ requests ]; + flakeIgnore = [ + "E501" # Line over 80 chars lol + "E201" # "whitespace after {" + "E202" # "whitespace after }" + "E251" # unexpected spaces around keyword / parameter equals + "W391" # Newline at end of file + ]; + makeWrapperArgs = [ + "--prefix PATH : ${(lib.makeBinPath [ pkgs.openssh ])}" + ]; + } (builtins.fileContents ./gitea-web-secret-provider.py); +in +{ + sops.secrets."gitea/web-secret-provider/token" = { + owner = "gitea"; + group = "gitea"; + restartUnits = [ + "gitea-web-secret-provider@" + ] ++ (map (org: "gitea-web-secret-provider@${org}") organizations); + }; + + systemd.tmpfiles.settings."10-gitea-web-secret-provider" = { + "/var/lib/gitea-web/authorized_keys.d".d = { + user = "gitea"; + group = "gitea"; + mode = "700"; + }; + "/var/lib/gitea-web/web".d = { + user = "gitea"; + group = "nginx"; + mode = "750"; + }; + } // + (builtins.listToAttrs (map (org: { + name = "/var/lib/gitea-web/web/${org}"; + value = { + d = { + user = "gitea"; + group = "nginx"; + mode = "750"; + }; + }; + }) organizations)); + + systemd.slices.system-giteaweb = { + description = "Gitea web directories"; + }; + + # https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#Specifiers + # %i - instance name (after the @) + # %d - secrets directory + systemd.services."gitea-web-secret-provider@" = { + description = "Ensure all repos in %i has an SSH key to push web content"; + requires = [ "gitea.service" "network.target" ]; + serviceConfig = { + Slice = "system-giteaweb.slice"; + Type = "oneshot"; + ExecStart = let + args = lib.cli.toGNUCommandLineShell { } { + org = "%i"; + token-path = "%d/token"; + api-url = "${giteaCfg.settings.server.ROOT_URL}api/v1"; + key-dir = "/var/lib/gitea-web/keys/%i"; + authorized-keys-path = "/var/lib/gitea-web/authorized_keys.d/%i"; + rrsync-script = pkgs.writeShellScript "rrsync-chown" '' + ${lib.getExe pkgs.rrsync} -wo "$1" + ${pkgs.coreutils}/bin/chown -R gitea:nginx "$1" + ''; + web-dir = "/var/lib/gitea-web/web"; + }; + in "${giteaWebSecretProviderScript} ${args}"; + User = "gitea"; + Group = "gitea"; + StateDirectory = "%i"; + LoadCredential = [ + "token:${config.sops.secrets."gitea/web-secret-provider/token".path}" + ]; + NoNewPrivileges = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectSystem = true; + ProtectHome = true; + ProtectControlGroups = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; + RestrictRealtime = true; + RestrictSUIDSGID = true; + MemoryDenyWriteExecute = true; + LockPersonality = true; + }; + }; + + systemd.timers."gitea-web-secret-provider@" = { + description = "Ensure all repos in %i has an SSH key to push web content"; + timerConfig = { + RandomizedDelaySec = "1h"; + Persistent = true; + Unit = "gitea-web-secret-provider@%i.service"; + OnCalendar = "daily"; + }; + }; + + systemd.targets.timers.wants = map (org: "gitea-web-secret-provider@${org}.timer") organizations; + + services.openssh.authorizedKeysFiles = map (org: "/var/lib/gitea-web/authorized_keys.d/${org}") organizations; +} diff --git a/hosts/bekkalokk/services/gitea/web-secret-provider/gitea-web-secret-provider.py b/hosts/bekkalokk/services/gitea/web-secret-provider/gitea-web-secret-provider.py new file mode 100644 index 0000000..beee4c6 --- /dev/null +++ b/hosts/bekkalokk/services/gitea/web-secret-provider/gitea-web-secret-provider.py @@ -0,0 +1,112 @@ +import argparse +import hashlib +import os +import requests +import subprocess +from pathlib import Path + + +def parse_args(): + parser = argparse.ArgumentParser(description="Generate SSH keys for Gitea repositories and add them as secrets") + parser.add_argument("--org", required=True, type=str, help="The organization to generate keys for") + parser.add_argument("--token-path", metavar='PATH', required=True, type=Path, help="Path to a file containing the Gitea API token") + parser.add_argument("--api-url", metavar='URL', type=str, help="The URL of the Gitea API", default="https://git.pvv.ntnu.no/api/v1") + parser.add_argument("--key-dir", metavar='PATH', type=Path, help="The directory to store the generated keys in", default="/run/gitea-web-secret-provider") + parser.add_argument("--authorized-keys-path", metavar='PATH', type=Path, help="The path to the resulting authorized_keys file", default="/etc/ssh/authorized_keys.d/gitea-web-secret-provider") + parser.add_argument("--rrsync-script", metavar='PATH', type=Path, help="The path to a rrsync script, taking the destination path as its single argument") + parser.add_argument("--web-dir", metavar='PATH', type=Path, help="The directory to sync the repositories to", default="/var/www") + parser.add_argument("--force", action="store_true", help="Overwrite existing keys") + return parser.parse_args() + + +def add_secret(args: argparse.Namespace, token: str, repo: str, name: str, secret: str): + result = requests.put( + f"{args.api_url}/repos/{args.org}/{repo}/actions/secrets/{name}", + json = { 'data': secret }, + headers = { 'Authorization': 'token ' + token }, + ) + if result.status_code not in (201, 204): + raise Exception(f"Failed to add secret: {result.json()}") + + +def get_org_repo_list(args: argparse.Namespace, token: str): + result = requests.get( + f"{args.api_url}/orgs/{args.org}/repos", + headers = { 'Authorization': 'token ' + token }, + ) + return [repo["name"] for repo in result.json()] + + +def generate_ssh_key(args: argparse.Namespace, repository: str): + keyname = hashlib.sha256(args.org.encode() + repository.encode()).hexdigest() + key_path = args.key_dir / keyname + if not key_path.is_file() or args.force: + subprocess.run( + [ + "ssh-keygen", + *("-t", "ed25519"), + *("-f", key_path), + *("-N", ""), + *("-C", f"{args.org}/{repository}"), + ], + check=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + print(f"Generated SSH key for `{args.org}/{repository}`") + + with open(key_path, "r") as f: + private_key = f.read() + + pub_key_path = args.key_dir / (keyname + '.pub') + with open(pub_key_path, "r") as f: + public_key = f.read() + + return private_key, public_key + + +SSH_OPTS = ",".join([ + "restrict", + "no-agent-forwarding", + "no-port-forwarding", + "no-pty", + "no-X11-forwarding", +]) + + +def generate_authorized_keys(args: argparse.Namespace, repo_public_keys: list[tuple[str, str]]): + lines = [] + for repo, public_key in repo_public_keys: + command = f"{args.rrsync_script} {args.web_dir}/{args.org}/{repo}" + lines.append(f'command="{command}",{SSH_OPTS} {public_key}') + + with open(args.authorized_keys_path, "w") as f: + f.writelines(lines) + + +def main(): + args = parse_args() + + with open(args.token_path, "r") as f: + token = f.read().strip() + + os.makedirs(args.key_dir, 0o700, exist_ok=True) + os.makedirs(args.authorized_keys_path.parent, 0o700, exist_ok=True) + + repos = get_org_repo_list(args, token) + print(f'Found {len(repos)} repositories in `{args.org}`') + + repo_public_keys = [] + for repo in repos: + print(f"Locating key for `{args.org}/{repo}`") + private_key, public_key = generate_ssh_key(args, repo) + add_secret(args, token, repo, "WEB_SYNC_SSH_KEY", private_key) + repo_public_keys.append((repo, public_key)) + + generate_authorized_keys(args, repo_public_keys) + print(f"Wrote authorized_keys file to `{args.authorized_keys_path}`") + + +if __name__ == "__main__": + main() diff --git a/secrets/bekkalokk/bekkalokk.yaml b/secrets/bekkalokk/bekkalokk.yaml index 0798d90..5b2680f 100644 --- a/secrets/bekkalokk/bekkalokk.yaml +++ b/secrets/bekkalokk/bekkalokk.yaml @@ -1,4 +1,6 @@ gitea: + web-secret-provider: + token: ENC[AES256_GCM,data:pHmBKxrNcLifl4sjR44AGEElfdachja35Tl/InsqvBWturaeTv4R0w==,iv:emBWfXQs2VNqtpDp5iA5swNC+24AWDYYXo6nvN+Fwx4=,tag:lkhSVSs6IqhHpfDPOX0wQA==,type:str] password: ENC[AES256_GCM,data:hlNzdU1ope0t50/3aztyLeXjMHd2vFPpwURX+Iu8f49DOqgSnEMtV+KtLA==,iv:qljRnSnchL5cFmaUAfCH9GQYQxcy5cyWejgk1x6bFgI=,tag:tIhboFU5kZsj5oAQR3hLbw==,type:str] database: ENC[AES256_GCM,data:UlS33IdCEyeSvT6ngpmnkBWHuSEqsB//DT+3b7C+UwbD8UXWJlsLf1X8/w==,iv:mPRW5ldyZaHP+y/0vC2JGSLZmlkhgmkvXPk4LazkSDs=,tag:gGk6Z/nbPvzE1zG+tJC8Sw==,type:str] email-password: ENC[AES256_GCM,data:KRwC+aL1aPvJuXt91Oq1ttATMnFTnuUy,iv:ats8TygB/2pORkaTZzPOLufZ9UmvVAKoRcWNvYF1z6w=,tag:Do0fA+4cZ3+l7JJyu8hjBg==,type:str] @@ -90,8 +92,8 @@ sops: UHpLRkdQTnhkeGlWVG9VS1hkWktyckEKAdwnA9URLYZ50lMtXrU9Q09d0L3Zfsyr 4UsvjjdnFtsXwEZ9ZzOQrpiN0Oz24s3csw5KckDni6kslaloJZsLGg== -----END AGE ENCRYPTED FILE----- - lastmodified: "2024-05-26T02:07:41Z" - mac: ENC[AES256_GCM,data:CRaJefV1zcJc6eyzyjTLgd0+Wv46VT8o4iz2YAGU+c2b/Cr97Tj290LoEO6UXTI3uFwVfzii2yZ2l+4FK3nVVriD4Cx1O/9qWcnLa5gfK30U0zof6AsJx8qtGu1t6oiPlGUCF7sT0BW9Wp8cPumrY6cZp9QbhmIDV0o0aJNUNN4=,iv:8OSYV1eG6kYlJD4ovZZhcD1GaYnmy7vHPa/+7egM1nE=,tag:OPI13rpDh2l1ViFj8TBFWg==,type:str] + lastmodified: "2024-08-13T19:49:24Z" + mac: ENC[AES256_GCM,data:AeJ53D+8A8mHYRmVHdqhcS1ZTbqVe5gQqJsJjMk4T/ZlNX8/V4M9mqAW2FB9m/JSdj234gDu+PBHcW70ZrCqeVsoUW/ETVgUX3W2gBmBgYJiRETp8I7/eks/5YEV6vIIxQsZNP/9dZTNX4T2wD74ELl23NSTXA/6k2tyzBlTMYo=,iv:DABafHvw+5w0PHCKqLgpwmQnv0uHOTyj+s8gdnHFTZ4=,tag:SNZ7W+6zdyuuv2AB9ir8eg==,type:str] pgp: - created_at: "2024-08-04T00:03:28Z" enc: |- @@ -114,4 +116,4 @@ sops: -----END PGP MESSAGE----- fp: F7D37890228A907440E1FD4846B9228E814A2AAC unencrypted_suffix: _unencrypted - version: 3.8.1 + version: 3.9.0