From a3da8deb0fb1e61211d4d9ee7ce6ede8243a99ee Mon Sep 17 00:00:00 2001 From: Jeremie Fraeys Date: Tue, 20 Jan 2026 17:10:02 -0500 Subject: [PATCH] feat(actions-ssh): use register/deregister keys for services access - Add app_ssh_access role to install forced-command keys for infra-register-stdin and infra-deregister\n- Ensure required infra-controller runtime directories exist on services host\n- Add helper script to generate/register both Actions SSH secrets and update vault public keys --- playbooks/services.yml | 12 + requirements.txt | 3 + roles/app_ssh_access/defaults/main.yml | 6 + roles/app_ssh_access/tasks/main.yml | 92 ++++++++ scripts/forgejo_set_actions_secret.py | 314 +++++++++++++++++++++++++ 5 files changed, 427 insertions(+) create mode 100644 requirements.txt create mode 100644 roles/app_ssh_access/defaults/main.yml create mode 100644 roles/app_ssh_access/tasks/main.yml create mode 100644 scripts/forgejo_set_actions_secret.py diff --git a/playbooks/services.yml b/playbooks/services.yml index d00502e..4b3252f 100644 --- a/playbooks/services.yml +++ b/playbooks/services.yml @@ -7,11 +7,23 @@ file: "{{ playbook_dir }}/../secrets/vault.yml" when: (lookup('ansible.builtin.fileglob', playbook_dir ~ '/../secrets/vault.yml', wantlist=True) | length) > 0 tags: always + + - name: Ensure minimal required directories exist + file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: "{{ ['/var/run/active-apps', '/var/lib/infra-controller'] }}" + tags: always roles: - role: docker tags: [docker] - role: traefik tags: [traefik] + - role: app_ssh_access + vars: + app_ssh_user: deployer + tags: [app_ssh_access] - role: lldap tags: [lldap] - role: authelia diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..88a1afd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pynacl +requests +pyyaml \ No newline at end of file diff --git a/roles/app_ssh_access/defaults/main.yml b/roles/app_ssh_access/defaults/main.yml new file mode 100644 index 0000000..0fb7f24 --- /dev/null +++ b/roles/app_ssh_access/defaults/main.yml @@ -0,0 +1,6 @@ +--- +app_ssh_user: app +app_ssh_allowed_host: web +app_ssh_allowed_ip: "" +app_ssh_register_key: "" +app_ssh_deregister_key: "" diff --git a/roles/app_ssh_access/tasks/main.yml b/roles/app_ssh_access/tasks/main.yml new file mode 100644 index 0000000..c66cc98 --- /dev/null +++ b/roles/app_ssh_access/tasks/main.yml @@ -0,0 +1,92 @@ +--- +- name: Compute app SSH allowed IP + set_fact: + app_ssh_allowed_ip_effective: >- + {{ + (app_ssh_allowed_ip | default('', true)) + if (app_ssh_allowed_ip | default('', true) | length) > 0 + else (hostvars[app_ssh_allowed_host].public_ipv4 + | default(hostvars[app_ssh_allowed_host].ansible_host, true)) + }} + +- name: Compute register SSH public key + set_fact: + app_ssh_register_key_effective: >- + {{ + (app_ssh_register_key | default('', true)) + if (app_ssh_register_key | default('', true) | length) > 0 + else ( + SERVICE_SSH_REGISTER_PUBLIC_KEY + | default(lookup('env', 'SERVICE_SSH_REGISTER_PUBLIC_KEY'), true) + ) + }} + no_log: true + +- name: Compute deregister SSH public key + set_fact: + app_ssh_deregister_key_effective: >- + {{ + (app_ssh_deregister_key | default('', true)) + if (app_ssh_deregister_key | default('', true) | length) > 0 + else ( + SERVICE_SSH_DEREGISTER_PUBLIC_KEY + | default(lookup('env', 'SERVICE_SSH_DEREGISTER_PUBLIC_KEY'), true) + ) + }} + no_log: true + +- name: Fail if register SSH public key is missing + fail: + msg: "SERVICE_SSH_REGISTER_PUBLIC_KEY is required (must be an SSH public key like 'ssh-ed25519 AAAA...')" + when: app_ssh_register_key_effective | length == 0 + +- name: Fail if deregister SSH public key is missing + fail: + msg: "SERVICE_SSH_DEREGISTER_PUBLIC_KEY is required (must be an SSH public key like 'ssh-ed25519 AAAA...')" + when: app_ssh_deregister_key_effective | length == 0 + +- name: Fail if register SSH public key does not look like an SSH key + fail: + msg: "SERVICE_SSH_REGISTER_PUBLIC_KEY does not look like an SSH public key" + when: not (app_ssh_register_key_effective is match('^ssh-')) + +- name: Fail if deregister SSH public key does not look like an SSH key + fail: + msg: "SERVICE_SSH_DEREGISTER_PUBLIC_KEY does not look like an SSH public key" + when: not (app_ssh_deregister_key_effective is match('^ssh-')) + +- name: Fail if app SSH allowed host/IP cannot be determined + fail: + msg: "Unable to determine app SSH allowed IP" + when: app_ssh_allowed_ip_effective | length == 0 + +- name: Ensure app SSH user exists + user: + name: "{{ app_ssh_user }}" + state: present + create_home: true + shell: /bin/bash + +- name: Ensure .ssh directory exists + file: + path: "/home/{{ app_ssh_user }}/.ssh" + state: directory + owner: "{{ app_ssh_user }}" + group: "{{ app_ssh_user }}" + mode: "0700" + +- name: Install restricted authorized key for register + authorized_key: + user: "{{ app_ssh_user }}" + state: present + key: "{{ app_ssh_register_key_effective }}" + key_options: >- + command="/usr/local/sbin/infra-register-stdin",from="{{ app_ssh_allowed_ip_effective }}",no-agent-forwarding,no-port-forwarding,no-pty,no-user-rc,no-X11-forwarding + +- name: Install restricted authorized key for deregister + authorized_key: + user: "{{ app_ssh_user }}" + state: present + key: "{{ app_ssh_deregister_key_effective }}" + key_options: >- + command="/usr/local/sbin/infra-deregister",from="{{ app_ssh_allowed_ip_effective }}",no-agent-forwarding,no-port-forwarding,no-pty,no-user-rc,no-X11-forwarding \ No newline at end of file diff --git a/scripts/forgejo_set_actions_secret.py b/scripts/forgejo_set_actions_secret.py new file mode 100644 index 0000000..01a834f --- /dev/null +++ b/scripts/forgejo_set_actions_secret.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 + +import argparse +import base64 +import json +import os +import re +import subprocess +import sys +import tempfile +from typing import Optional, Tuple +from urllib.parse import urljoin + +import requests + +try: + import yaml +except Exception: # pragma: no cover + yaml = None + + +def _require_pynacl(): + try: + from nacl.public import PublicKey, SealedBox + except Exception as e: + raise RuntimeError( + "PyNaCl is required for secrets encryption. Install with: pip install pynacl" + ) from e + return PublicKey, SealedBox + + +def _api_headers(token: str) -> dict: + # Gitea/Forgejo uses "token " in Authorization. + return { + "Authorization": f"token {token}", + "Accept": "application/json", + "Content-Type": "application/json", + } + + +def get_repo_public_key(base_url: str, token: str, owner: str, repo: str) -> dict: + url = urljoin(base_url.rstrip("/") + "/", f"api/v1/repos/{owner}/{repo}/actions/secrets/public-key") + r = requests.get(url, headers=_api_headers(token), timeout=30) + + # Some Forgejo/Gitea versions do not expose a public-key endpoint. + # In that case, secrets are submitted in plaintext (over TLS) and encrypted server-side. + if r.status_code in (404, 405): + raise NotImplementedError("public-key endpoint not available") + + r.raise_for_status() + + data = r.json() + # Expected: {"key_id": "...", "key": "base64..."} + if "key" not in data or "key_id" not in data: + raise RuntimeError(f"Unexpected response from {url}: {data}") + return data + + +def _put_repo_secret_auto(base_url: str, token: str, owner: str, repo: str, name: str, value: str) -> None: + try: + public = get_repo_public_key(base_url, token, owner, repo) + encrypted = encrypt_secret(public["key"], value) + put_repo_secret(base_url, token, owner, repo, name, encrypted, public["key_id"]) + except NotImplementedError: + put_repo_secret_plain(base_url, token, owner, repo, name, value) + + +def encrypt_secret(public_key_b64: str, secret_value: str) -> str: + PublicKey, SealedBox = _require_pynacl() + + pk_bytes = base64.b64decode(public_key_b64) + pk = PublicKey(pk_bytes) + box = SealedBox(pk) + encrypted = box.encrypt(secret_value.encode("utf-8")) + return base64.b64encode(encrypted).decode("ascii") + + +def _ensure_ssh_keypair(private_key_path: str, comment: str, force: bool) -> None: + pub_path = private_key_path + ".pub" + if not force and (os.path.exists(private_key_path) or os.path.exists(pub_path)): + raise RuntimeError( + f"Refusing to overwrite existing key material: {private_key_path} / {pub_path} (use --force-generate-ssh-key)" + ) + + if force: + for p in (private_key_path, pub_path): + try: + if os.path.exists(p): + os.unlink(p) + except Exception as e: + raise RuntimeError(f"Failed to remove existing key material: {p}") from e + + cmd = [ + "ssh-keygen", + "-t", + "ed25519", + "-N", + "", + "-f", + private_key_path, + "-C", + comment, + ] + + try: + subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + except FileNotFoundError: + raise RuntimeError("ssh-keygen not found in PATH") + except subprocess.CalledProcessError as e: + raise RuntimeError("ssh-keygen failed to generate keypair") from e + + +def put_repo_secret(base_url: str, token: str, owner: str, repo: str, name: str, encrypted_value_b64: str, key_id: str) -> None: + url = urljoin(base_url.rstrip("/") + "/", f"api/v1/repos/{owner}/{repo}/actions/secrets/{name}") + payload = { + "encrypted_value": encrypted_value_b64, + "key_id": key_id, + } + r = requests.put(url, headers=_api_headers(token), json=payload, timeout=30) + r.raise_for_status() + + +def put_repo_secret_plain(base_url: str, token: str, owner: str, repo: str, name: str, value: str) -> None: + url = urljoin(base_url.rstrip("/") + "/", f"api/v1/repos/{owner}/{repo}/actions/secrets/{name}") + # Some Forgejo/Gitea instances accept a plaintext secret value and encrypt server-side. + payload = {"data": value} + r = requests.put(url, headers=_api_headers(token), json=payload, timeout=30) + r.raise_for_status() + + +def _read_ansible_vault_yaml(vault_file: str, vault_password_file: Optional[str]) -> dict: + if yaml is None: + raise RuntimeError("PyYAML is required to read tokens from ansible-vault. Install with: pip install pyyaml") + + cmd = ["ansible-vault", "view", vault_file] + if vault_password_file: + cmd.extend(["--vault-password-file", vault_password_file]) + + try: + p = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + except FileNotFoundError: + raise RuntimeError("ansible-vault not found in PATH") + except subprocess.CalledProcessError as e: + # Don't echo stderr; it may contain hints about vault location. + raise RuntimeError("Failed to read vault file via ansible-vault") from e + + data = yaml.safe_load(p.stdout) or {} + if not isinstance(data, dict): + raise RuntimeError(f"Vault file {vault_file} did not parse to a YAML mapping") + return data + + +def _read_ansible_vault_text(vault_file: str, vault_password_file: Optional[str]) -> str: + cmd = ["ansible-vault", "view", vault_file] + if vault_password_file: + cmd.extend(["--vault-password-file", vault_password_file]) + + try: + p = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + except FileNotFoundError: + raise RuntimeError("ansible-vault not found in PATH") + except subprocess.CalledProcessError as e: + raise RuntimeError("Failed to read vault file via ansible-vault") from e + return p.stdout + + +def _write_ansible_vault_text(vault_file: str, plaintext: str, vault_password_file: Optional[str]) -> None: + with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8", prefix="vault_", suffix=".yml") as tf: + tmp_path = tf.name + tf.write(plaintext) + + try: + cmd = ["ansible-vault", "encrypt", tmp_path, "--output", vault_file] + if vault_password_file: + cmd.extend(["--vault-password-file", vault_password_file]) + subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + except FileNotFoundError: + raise RuntimeError("ansible-vault not found in PATH") + except subprocess.CalledProcessError as e: + raise RuntimeError("Failed to write vault file via ansible-vault") from e + finally: + try: + os.unlink(tmp_path) + except Exception: + pass + + +def _upsert_ansible_vault_scalar_line(vault_file: str, key: str, value: str, vault_password_file: Optional[str]) -> None: + plaintext = _read_ansible_vault_text(vault_file, vault_password_file) + if plaintext and not plaintext.endswith("\n"): + plaintext += "\n" + + # Quote the value safely (SSH public keys contain spaces). + rendered_value = json.dumps(value) + new_line = f"{key}: {rendered_value}" + + pattern = re.compile(rf"^(?P{re.escape(key)})\s*:\s*.*$", re.MULTILINE) + if pattern.search(plaintext): + plaintext = pattern.sub(new_line, plaintext, count=1) + else: + if plaintext and not plaintext.endswith("\n"): + plaintext += "\n" + plaintext += new_line + "\n" + + _write_ansible_vault_text(vault_file, plaintext, vault_password_file) + + +def _load_token_from_ansible_vault(vault_file: str, token_var: str, vault_password_file: Optional[str]) -> Tuple[Optional[str], list[str]]: + data = _read_ansible_vault_yaml(vault_file, vault_password_file) + keys = sorted([str(k) for k in data.keys()]) + val = data.get(token_var) + if isinstance(val, str) and val.strip(): + return val.strip(), keys + return None, keys + + +def args_parse(): + p = argparse.ArgumentParser(description="Create/update a Forgejo Actions secret in a repository.") + p.add_argument("--base-url", default=os.environ.get("FORGEJO_BASE_URL", "https://git.jfraeys.com")) + p.add_argument("--token", default=os.environ.get("FORGEJO_API_TOKEN")) + p.add_argument("--vault-file", default="./secrets/vault.yml") + p.add_argument("--vault-password-file", default="./secrets/.vault_pass") + p.add_argument("--token-var", default="FORGEJO_API_TOKEN") + p.add_argument("--repo", required=True, help="owner/repo") + p.add_argument("--force-generate-ssh-key", action="store_true") + p.add_argument( + "--generate-ssh-keys", + action="store_true", + help="Generate both register and deregister SSH key pairs.", + ) + p.add_argument("--register-key-path", default="./secrets/service_ssh_key_register") + p.add_argument("--deregister-key-path", default="./secrets/service_ssh_key_deregister") + p.add_argument( + "--update-vault-both-public-keys", + action="store_true", + help="Set both SERVICE_SSH_REGISTER_PUBLIC_KEY and SERVICE_SSH_DEREGISTER_PUBLIC_KEY in vault", + ) + + args = p.parse_args() + return args + +def main() -> int: + args = args_parse() + + token = args.token + vault_keys: list[str] = [] + if not token: + token, vault_keys = _load_token_from_ansible_vault(args.vault_file, args.token_var, args.vault_password_file) + + if not token: + print( + "FORGEJO_API_TOKEN is required (pass --token, set env FORGEJO_API_TOKEN, or store it in ansible-vault secrets/vault.yml)", + file=sys.stderr, + ) + if vault_keys: + print( + f"Vault parsed OK but key '{args.token_var}' not found. Available top-level keys: {', '.join(vault_keys)}", + file=sys.stderr, + ) + return 2 + + if "/" not in args.repo: + print("--repo must be in the form owner/repo", file=sys.stderr) + return 2 + + owner, repo = args.repo.split("/", 1) + + if args.generate_ssh_keys: + _ensure_ssh_keypair(args.register_key_path, "actions-register", args.force_generate_ssh_key) + _ensure_ssh_keypair(args.deregister_key_path, "actions-deregister", args.force_generate_ssh_key) + else: + missing: list[str] = [] + if not os.path.exists(args.register_key_path): + missing.append(args.register_key_path) + if not os.path.exists(args.deregister_key_path): + missing.append(args.deregister_key_path) + if missing: + print( + "Missing SSH private key(s): " + ", ".join(missing) + ". Use --generate-ssh-keys to create them.", + file=sys.stderr, + ) + return 2 + + with open(args.register_key_path, "r", encoding="utf-8") as f: + register_secret = f.read() + with open(args.deregister_key_path, "r", encoding="utf-8") as f: + deregister_secret = f.read() + + _put_repo_secret_auto(args.base_url, token, owner, repo, "SERVICE_SSH_KEY_REGISTER", register_secret) + _put_repo_secret_auto(args.base_url, token, owner, repo, "SERVICE_SSH_KEY_DEREGISTER", deregister_secret) + + if args.update_vault_both_public_keys: + with open(args.register_key_path + ".pub", "r", encoding="utf-8") as f: + _upsert_ansible_vault_scalar_line( + args.vault_file, + "SERVICE_SSH_REGISTER_PUBLIC_KEY", + f.read().strip(), + args.vault_password_file, + ) + + with open(args.deregister_key_path + ".pub", "r", encoding="utf-8") as f: + _upsert_ansible_vault_scalar_line( + args.vault_file, + "SERVICE_SSH_DEREGISTER_PUBLIC_KEY", + f.read().strip(), + args.vault_password_file, + ) + + print(f"Updated Actions secrets SERVICE_SSH_KEY_REGISTER and SERVICE_SSH_KEY_DEREGISTER for {owner}/{repo}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())