diff --git a/hosts/bekkalokk/services/gitea/default.nix b/hosts/bekkalokk/services/gitea/default.nix index 4410568..ccbcb1a 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..fc4515e --- /dev/null +++ b/hosts/bekkalokk/services/gitea/web-secret-provider/default.nix @@ -0,0 +1,110 @@ +{ config, pkgs, lib, ... }: +let + organizations = [ + "Drift" + "Projects" + "Kurs" + ]; + + cfg = config.services.gitea; + + program = pkgs.writers.writePython3 "gitea-web-secret-provider" { + libraries = with pkgs.python3Packages; [ requests ]; + flakeIgnore = [ + "E501" # Line over 80 chars lol + "E201" # "whitespace after {" < this looks better bruh + "E202" # "whitespace after }" < brot + "E251" # unexpected spaces around keyword / parameter equals < megabrot + "W391" # Newline at end of file < nei vil ikke + ]; + makeWrapperArgs = [ + "--prefix PATH : ${(lib.makeBinPath [ pkgs.openssh ])}" + ]; + } (lib.pipe ./gitea-web-secret-provider.py [ + builtins.readFile + (lib.splitString "\n") + (lib.drop 2) + lib.concatLines + ]); +in +{ + sops.secrets."gitea/web-secret-provider/token" = { + owner = "gitea"; + group = "gitea"; + restartUnits = [ + "gitea-web-secret-provider@.service" + "gitea-web-secret-provider@.timer" + ] + ++ (map (org: "gitea-web-secret-provider@${org}.service") organizations) + ++ (map (org: "gitea-web-secret-provider@${org}.timer") organizations); + }; + + # https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#Specifiers + # %i - instance name (after the @) + # %d - secrets directory + # %S - /var/lib + systemd.services = { + "gitea-web-secret-provider@" = { + description = "Gitea web secret provider"; + requires = [ "gitea.service" "network.target" ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = let + args = lib.cli.toGNUCommandLineShell { } { + org = "%i"; + token-path = "%d/token"; + api-url = "${cfg.settings.server.ROOT_URL}api/v1"; + key-dir = "%S/%i/keys"; + authorized-keys-path = "%S/gitea-web/authorized_keys.d/%i"; + rrsync-path = "${pkgs.rrsync}/bin/rrsync"; + web-dir = "%S/gitea-web/web"; + }; + in "${program} ${args}"; + User = "gitea"; + Group = "gitea"; + StateDirectory = "%i"; + LoadCredential = [ + "token:${config.sops.secrets."gitea/web-secret-provider/token".path}" + ]; + + # Hardening + 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 = "Run the Gitea web secret provider"; + wantedBy = [ "timers.target" ]; + 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; + + systemd.tmpfiles.settings."10-gitea-web-secret-provider"."/var/lib/gitea-web/authorized_keys.d".d = { + user = "gitea"; + group = "gitea"; + mode = "700"; + }; + + 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..2342b0f --- /dev/null +++ b/hosts/bekkalokk/services/gitea/web-secret-provider/gitea-web-secret-provider.py @@ -0,0 +1,105 @@ +#!/usr/bin/env nix-shell +#!nix-shell -i python3 -p "python3.withPackages(ps: with ps; [ requests ])" openssh + +import argparse +import hashlib +import os +import requests +import subprocess + + +def parse_args(): + parser = argparse.ArgumentParser(description="Generate SSH keys for Gitea repositories and add them as secrets") + parser.add_argument("--org", required=True, help="The organization to generate keys for") + parser.add_argument("--token-path", metavar='PATH', required=True, help="Path to a file containing the Gitea API token") + parser.add_argument("--api-url", metavar='URL', help="The URL of the Gitea API", default="https://git.pvv.ntnu.no/api/v1") + parser.add_argument("--key-dir", metavar='PATH', help="The directory to store the generated keys in", default="/run/gitea-web-secret-provider") + parser.add_argument("--authorized-keys-path", metavar='PATH', help="The path to the resulting authorized_keys file", default="/etc/ssh/authorized_keys.d/gitea-web-secret-provider") + parser.add_argument("--rrsync-path", metavar='PATH', help="The path to the rrsync binary", default="/run/current-system/sw/bin/rrsync") + parser.add_argument("--web-dir", metavar='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, token, repo, name, secret): + 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, token): + 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, repository: str): + keyname = hashlib.sha256(args.org.encode() + repository.encode()).hexdigest() + + if not os.path.exists(os.path.join(args.key_dir, keyname)) or args.force: + subprocess.run( + [ + "ssh-keygen", + *("-t", "ed25519"), + *("-b", "4096"), + *("-f", os.path.join(args.key_dir, keyname)), + *("-N", ""), + *("-C", f"{args.org}/{repository}"), + ], + check=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + print(f"Generated SSH key for `{args.org}/{repository}`") + + with open(os.path.join(args.key_dir, keyname), "r") as f: + private_key = f.read() + + with open(os.path.join(args.key_dir, keyname + ".pub"), "r") as f: + public_key = f.read() + + return private_key, public_key + + +def generate_authorized_keys(args, repo_public_keys: list[tuple[str, str]]): + result = "" + for repo, public_key in repo_public_keys: + result += f""" + command="{args.rrsync_path} -wo {args.web_dir}/{args.org}/{repo}",restrict,no-agent-forwarding,no-port-forwarding,no-pty,no-X11-forwarding {public_key} + """.strip() + "\n" + + with open(args.authorized_keys_path, "w") as f: + f.write(result) + + +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) + + 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