mirror of
				https://git.pvv.ntnu.no/Drift/pvv-nixos-config.git
				synced 2025-11-03 18:48:04 +01:00 
			
		
		
		
	Compare commits
	
		
			2 Commits
		
	
	
		
			74a2b1970e
			...
			ce2f6a4546
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					ce2f6a4546 | ||
| 
						 | 
					ed13e49ba7 | 
@ -6,7 +6,8 @@ let
 | 
			
		||||
in {
 | 
			
		||||
  imports = [
 | 
			
		||||
    ./ci.nix
 | 
			
		||||
    ./import-users.nix
 | 
			
		||||
    ./import-users
 | 
			
		||||
    ./web-secret-provider
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  sops.secrets = {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										153
									
								
								hosts/bekkalokk/services/gitea/web-secret-provider/default.nix
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										153
									
								
								hosts/bekkalokk/services/gitea/web-secret-provider/default.nix
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,153 @@
 | 
			
		||||
{ 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 {"
 | 
			
		||||
      "E202" # "whitespace after }"
 | 
			
		||||
      "E251" # unexpected spaces around keyword / parameter equals
 | 
			
		||||
      "W391" # Newline at end of file
 | 
			
		||||
    ];
 | 
			
		||||
    makeWrapperArgs = [
 | 
			
		||||
      "--prefix PATH : ${(lib.makeBinPath [ pkgs.openssh ])}"
 | 
			
		||||
    ];
 | 
			
		||||
  } (lib.pipe ./gitea-web-secret-provider.py [
 | 
			
		||||
    builtins.readFile
 | 
			
		||||
    (lib.splitString "\n")
 | 
			
		||||
    (lib.drop 2)
 | 
			
		||||
    lib.concatLines
 | 
			
		||||
  ]);
 | 
			
		||||
 | 
			
		||||
  commonHardening = {
 | 
			
		||||
    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;
 | 
			
		||||
  };
 | 
			
		||||
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";
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  systemd.slices.system-giteaweb = {
 | 
			
		||||
    description = "Gitea web directories";
 | 
			
		||||
    wantedBy = [ "multi-user.target" ];
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  # 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 = "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 = "${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}"
 | 
			
		||||
        ];
 | 
			
		||||
      } // commonHardening;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    "gitea-web-chown@" = {
 | 
			
		||||
      description = "Ensure all gitea-web content is owned by the gitea user";
 | 
			
		||||
      serviceConfig = {
 | 
			
		||||
        Slice = "system-giteaweb.slice";
 | 
			
		||||
        Type = "oneshot";
 | 
			
		||||
        ExecStart = "${pkgs.coreutils}/bin/chown -R gitea:gitea '%S/gitea-web'";
 | 
			
		||||
 | 
			
		||||
        StateDirectory = "%i";
 | 
			
		||||
 | 
			
		||||
        LoadCredential = [
 | 
			
		||||
          "token:${config.sops.secrets."gitea/web-secret-provider/token".path}"
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
        PrivateNetwork = true;
 | 
			
		||||
      } // commonHardening;
 | 
			
		||||
    };
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  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";
 | 
			
		||||
      };
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    "gitea-web-chown@" = {
 | 
			
		||||
      description = "Ensure all gitea-web content is owned by the gitea user";
 | 
			
		||||
      timerConfig = {
 | 
			
		||||
        RandomizedDelaySec = "10m";
 | 
			
		||||
        Persistent = true;
 | 
			
		||||
        Unit = "gitea-web-chown@%i.service";
 | 
			
		||||
        OnCalendar = "hourly";
 | 
			
		||||
      };
 | 
			
		||||
    };
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  systemd.targets.timers.wants = lib.mapCartesianProduct ({ timer, org }: "${timer}@${org}.timer") {
 | 
			
		||||
    timer = [
 | 
			
		||||
      "gitea-web-secret-provider"
 | 
			
		||||
      "gitea-web-chown"
 | 
			
		||||
    ];
 | 
			
		||||
    org = organizations;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  services.openssh.authorizedKeysFiles = map (org: "/var/lib/gitea-web/authorized_keys.d/${org}") organizations;
 | 
			
		||||
 | 
			
		||||
  services.nginx.virtualHosts."pages.pvv.ntnu.no" = {
 | 
			
		||||
    kTLS = true;
 | 
			
		||||
    forceSSL = true;
 | 
			
		||||
    enableACME = true;
 | 
			
		||||
    root = "/var/lib/gitea-web/web";
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,107 @@
 | 
			
		||||
#!/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
 | 
			
		||||
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, 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)
 | 
			
		||||
    os.makedirs(Path(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()
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user