diff --git a/inventories/sign_nodes/group_vars/all.yml b/inventories/sign_nodes/group_vars/all.yml new file mode 100644 index 0000000..966f5af --- /dev/null +++ b/inventories/sign_nodes/group_vars/all.yml @@ -0,0 +1,62 @@ +--- +# ALBS master the sign nodes talk to. Set to the web-server host address. +albs_address: "localhost" + +# Multi-host deploy is remote; each host is reached over SSH via its +# per-host ansible_host (see host_vars/). +use_local_connection: false +use_already_cloned_repos: false +ansible_interpreter_path: auto + +# --- Shared identity + GPG (consumed by sign_common) ------------------------ +service_user: "albs-signer" +service_group: "albs-signer" + +# Auto-generate a dev key on hosts that declare no key. Set to false to require +# externally provisioned keys (see per-host pgp_keys / gpg_import_keys). +sign_generate_gpg_key: true +gpg_default_email: "test@albs.local" +gpg_default_password: "1234567890" + +# --- Environment: dev vs prod ----------------------------------------------- +# Dev unlocks signing keys with a static passphrase (gpg_default_password) and, +# for sign-file, seeds a test DB user. PROD MUST set this to false: the static +# dev passphrase env vars are then NOT written, and key passphrases come from +# Bitwarden instead (enable the block below). +sign_dev_mode: true + +# --- Bitwarden (prod passphrases for BOTH sign-node and sign-file) ---------- +# When enabled, each service fetches its GPG key passphrases from Bitwarden: +# create one login item per key id (item NAME == key id, PASSWORD == passphrase). +bitwarden_enabled: false +# Bitwarden server URL (vault.bitwarden.com or a self-hosted instance). The +# sign_common role installs the bw CLI and runs `bw config server` with this. +bitwarden_url: "" +bitwarden_username: "" +# Master vault password. Supply via Ansible Vault (do NOT commit plaintext); +# pass with --vault-password-file, or override with -e. The bitwarden_cli role +# writes it to bitwarden_password_file on each host (0600, owned by service_user). +bitwarden_master_password: "" +# Managed path the role writes the master password to; bw-ensure-session and +# the service configs read from here. Override only if you provision the file +# out-of-band (leave bitwarden_master_password empty in that case). +bitwarden_password_file: "/home/{{ service_user }}/.config/.bw-master" +# Inline password alternative — single-worker sign-file only, less safe. Leave +# empty when using bitwarden_master_password + bitwarden_password_file. +bitwarden_password: "" +# Optional: restrict the lookup to a single collection UUID. +bitwarden_collection_id: "" + +# --- albs-sign-node settings ------------------------------------------------ +albs_jwt_token: "" +albs_jwt_secret: "secret" + +pulp_user: "admin" +pulp_password: "password" + +# immudb (optional) — leave unset to disable notarization on the sign node. +# immudb_address: "" +# immudb_database: "" +# immudb_username: "" +# immudb_password: "" +... diff --git a/inventories/sign_nodes/host_vars/sign01.yml b/inventories/sign_nodes/host_vars/sign01.yml new file mode 100644 index 0000000..26fd359 --- /dev/null +++ b/inventories/sign_nodes/host_vars/sign01.yml @@ -0,0 +1,14 @@ +--- +ansible_host: 10.0.0.11 + +# This host's signing key(s), as 16-char key ids. Authoritative when set — +# both albs-sign-node and albs-sign-file on this host sign with these. +pgp_keys: + - "7C3955C2A345DA89" + +# Optional: private key files on the control node to import into this host's +# keyring before signing. Omit to rely on a key already present, or on +# auto-generation (sign_generate_gpg_key). +# gpg_import_keys: +# - files/keys/sign01-private.asc +... diff --git a/inventories/sign_nodes/host_vars/sign02.yml b/inventories/sign_nodes/host_vars/sign02.yml new file mode 100644 index 0000000..7347310 --- /dev/null +++ b/inventories/sign_nodes/host_vars/sign02.yml @@ -0,0 +1,10 @@ +--- +ansible_host: 10.0.0.12 + +# A DIFFERENT key from sign01 — each host has its own signing identity. +pgp_keys: + - "A1B2C3D4E5F60789" + +# gpg_import_keys: +# - files/keys/sign02-private.asc +... diff --git a/inventories/sign_nodes/hosts.yml b/inventories/sign_nodes/hosts.yml new file mode 100644 index 0000000..bc7ce0a --- /dev/null +++ b/inventories/sign_nodes/hosts.yml @@ -0,0 +1,9 @@ +all: + children: + sign_nodes: + hosts: + # Each host runs BOTH albs-sign-node and albs-sign-file, sharing that + # host's GnuPG keyring. Per-host settings (ansible_host, pgp_keys, + # gpg_import_keys) live in host_vars/.yml. + sign01: + sign02: diff --git a/playbooks/albs_sign_hosts.yml b/playbooks/albs_sign_hosts.yml new file mode 100644 index 0000000..fbc10e1 --- /dev/null +++ b/playbooks/albs_sign_hosts.yml @@ -0,0 +1,19 @@ +--- +- name: Deploy ALBS sign hosts (sign-node + sign-file, co-located) + hosts: sign_nodes + roles: + # Shared prerequisites: service user, base packages, and the host's + # GnuPG keyring resolved into the `gpg_keys` fact. Must run first — both + # service roles below consume that fact. + - sign_common + # Bitwarden CLI (prod passphrase source), configured for the service user. + - role: bitwarden_cli + when: bitwarden_enabled | default(false) | bool + vars: + bitwarden_cli_config_user: "{{ service_user }}" + # albs-sign-node: signs build artifacts pulled from the ALBS master. + - separate_sign_node + # albs-sign-file: standalone HTTP signing API (port 8000). + - separate_sign_file + connection: "{{ 'local' if use_local_connection else 'ssh' }}" +... diff --git a/playbooks/albs_with_separate_sign_node.yml b/playbooks/albs_with_separate_sign_node.yml index 50697a1..7cd3374 100644 --- a/playbooks/albs_with_separate_sign_node.yml +++ b/playbooks/albs_with_separate_sign_node.yml @@ -10,6 +10,11 @@ - name: Deploy the separate sign node of ALBS hosts: sign_node_vm roles: + - sign_common + - role: bitwarden_cli + when: bitwarden_enabled | default(false) | bool + vars: + bitwarden_cli_config_user: "{{ service_user }}" - separate_sign_node - { role: ezamriy.fail2ban, fail2ban_ignoreip: '127.0.0.1/8 192.168.0.0/24' } tags: diff --git a/roles/bitwarden_cli/defaults/main/main.yml b/roles/bitwarden_cli/defaults/main/main.yml new file mode 100644 index 0000000..9e98c73 --- /dev/null +++ b/roles/bitwarden_cli/defaults/main/main.yml @@ -0,0 +1,37 @@ +--- +# Reusable role: install the Bitwarden CLI system-wide and point it at a +# server. Include it from any service that fetches secrets from Bitwarden. +# +# Consumers typically include it conditionally and set the config user, e.g.: +# - role: bitwarden_cli +# when: bitwarden_enabled | bool +# vars: +# bitwarden_cli_config_user: "{{ service_user }}" + +bitwarden_cli_version: "2026.4.1" +bitwarden_cli_url: "https://github.com/bitwarden/clients/releases/download/cli-v{{ bitwarden_cli_version }}/bw-linux-{{ bitwarden_cli_version }}.zip" + +# Install dir must be on PATH for every user on the host. +bitwarden_cli_install_dir: "/usr/local/bin" + +# Bitwarden server URL (vault.bitwarden.com or a self-hosted instance). +bitwarden_url: "" + +# User under which `bw config server` runs (writes that user's CLI config). +# Each consuming service overrides this with its own service account. +bitwarden_cli_config_user: "root" + +# Master vault password. Supply via Ansible Vault — never commit plaintext. +# When set, the role writes it to bitwarden_password_file (0600, owned by the +# config user) so bw-ensure-session and the service configs can read it. Leave +# empty to manage the file out-of-band (the role then only reads its path). +bitwarden_master_password: "" + +# Path the role writes the master password to, and the path consumers +# (sign-node config, sign-file config, bw-ensure-session) read from. Keyed on +# service_user so it resolves IDENTICALLY in every role: bitwarden_cli_config_user +# is set only as a role-scoped var on this role, so consumers (separate_sign_*) +# would otherwise fall back to its "root" default and read a different path than +# the one written here. Falls back to bitwarden_cli_config_user for generic use. +bitwarden_password_file: "/home/{{ service_user | default(bitwarden_cli_config_user) }}/.config/.bw-master" +... diff --git a/roles/bitwarden_cli/files/bw-ensure-session.sh b/roles/bitwarden_cli/files/bw-ensure-session.sh new file mode 100644 index 0000000..9e9138d --- /dev/null +++ b/roles/bitwarden_cli/files/bw-ensure-session.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# +# Ensure a usable Bitwarden session and print the session key on stdout. +# Managed by Ansible (bitwarden_cli role). Reusable by any service that needs +# a shared BW_SESSION (e.g. multi-worker servers where each worker must reuse +# one session instead of racing `bw login`/`bw unlock`). +# +# Usage: bw-ensure-session +# +# Assumes `bw config server` has already been run for the invoking user +# (the bitwarden_cli role does this). +set -euo pipefail + +username="${1:?username required}" +pass_file="${2:?password file path required}" + +# `bw login --check` exits 0 when logged in, 1 otherwise. +if ! bw login --check >/dev/null 2>&1; then + bw login "${username}" --raw --passwordfile "${pass_file}" >/dev/null +fi + +# Print the session key so the caller can `export BW_SESSION="$(...)"`. +bw unlock --raw --passwordfile "${pass_file}" diff --git a/roles/bitwarden_cli/tasks/main.yml b/roles/bitwarden_cli/tasks/main.yml new file mode 100644 index 0000000..ee171df --- /dev/null +++ b/roles/bitwarden_cli/tasks/main.yml @@ -0,0 +1,86 @@ +--- +- name: Require a Bitwarden server URL + ansible.builtin.assert: + that: + - bitwarden_url | length > 0 + fail_msg: "bitwarden_cli role included but bitwarden_url is not set." + +- name: Install unzip (needed to unpack the CLI archive) + ansible.builtin.dnf: + name: unzip + state: present + become: yes + +- name: Download and unpack the Bitwarden CLI onto PATH + ansible.builtin.unarchive: + src: "{{ bitwarden_cli_url }}" + dest: "{{ bitwarden_cli_install_dir }}" + remote_src: yes + creates: "{{ bitwarden_cli_install_dir }}/bw" + become: yes + +- name: Ensure the bw binary is executable for all users + ansible.builtin.file: + path: "{{ bitwarden_cli_install_dir }}/bw" + owner: root + group: root + mode: "0755" + become: yes + +- name: Install the bw-ensure-session helper onto PATH + ansible.builtin.copy: + src: bw-ensure-session.sh + dest: "{{ bitwarden_cli_install_dir }}/bw-ensure-session" + owner: root + group: root + mode: "0755" + become: yes + +# `bw config server` (no url) prints the currently configured server. Read it +# first: the CLI refuses "config server" while logged in ("Logout required +# before server config update"), so on a re-run we must skip the set when the +# server is already correct. +- name: Read the currently configured Bitwarden server + become: yes + become_user: "{{ bitwarden_cli_config_user }}" + ansible.builtin.command: "{{ bitwarden_cli_install_dir }}/bw config server" + register: bw_current_server + changed_when: false + failed_when: false + +- name: Point the Bitwarden CLI at the server + become: yes + become_user: "{{ bitwarden_cli_config_user }}" + ansible.builtin.command: "{{ bitwarden_cli_install_dir }}/bw config server {{ bitwarden_url }}" + register: bw_config_server + changed_when: "'Saved' in (bw_config_server.stdout | default(''))" + when: bitwarden_url not in (bw_current_server.stdout | default('')) + +# Materialize the master password so `bw login/unlock --passwordfile` (and the +# service configs) can read it. Skipped when bitwarden_master_password is empty +# — then the file is expected to be provisioned out-of-band. +# +# This role runs before the service roles that create the config dir, and copy +# does not create parents, so ensure the directory exists first. mode matches +# the service roles' 0750 to stay idempotent across runs. +- name: Ensure the directory for the Bitwarden password file exists + become: yes + ansible.builtin.file: + path: "{{ bitwarden_password_file | dirname }}" + state: directory + owner: "{{ bitwarden_cli_config_user }}" + group: "{{ bitwarden_cli_config_user }}" + mode: "0750" + when: bitwarden_master_password | length > 0 + +- name: Write the Bitwarden master password file + become: yes + ansible.builtin.copy: + content: "{{ bitwarden_master_password }}" + dest: "{{ bitwarden_password_file }}" + owner: "{{ bitwarden_cli_config_user }}" + group: "{{ bitwarden_cli_config_user }}" + mode: "0600" + no_log: yes + when: bitwarden_master_password | length > 0 +... diff --git a/roles/separate_sign_file/defaults/main/main.yml b/roles/separate_sign_file/defaults/main/main.yml new file mode 100644 index 0000000..11ef780 --- /dev/null +++ b/roles/separate_sign_file/defaults/main/main.yml @@ -0,0 +1,58 @@ +--- +# albs-sign-file: standalone FastAPI signing API (uvicorn, port 8000). +# Runs as the shared sign service user and reads the host keyring resolved by +# the sign_common role (the `gpg_keys` fact). Its own isolated venv is built +# with the system Python 3.9 — never shared with albs-sign-node. + +sign_file_repo: "https://github.com/AlmaLinux/albs-sign-file.git" +sign_file_repo_version: "main" + +sign_file_working_directory: "/home/{{ service_user }}/albs-sign-file" +sign_file_venv_directory: "{{ sign_file_working_directory }}/venv" + +sign_file_config_dir: "/home/{{ service_user }}/.config" +sign_file_config_file: "{{ sign_file_config_dir }}/sign_file.yaml" + +# Multi-worker runs need ONE shared Bitwarden session (each worker must not +# race `bw login`/`bw unlock`). When true, ExecStart establishes the session +# via bw-ensure-session and exports BW_SESSION before exec'ing uvicorn. +sign_file_bw_session_required: "{{ (bitwarden_enabled | default(false) | bool) and (sign_file_workers | int > 1) }}" + +# Database backend: "sqlite" (default; keeps a standalone host dependency-free) +# or "postgresql" (point at an existing DB + role — this role does NOT create +# the Postgres database or user, only the schema via migrations). +sign_file_db_backend: "sqlite" + +# SQLite settings +sign_file_db_path: "/home/{{ service_user }}/sign-file.sqlite3" + +# PostgreSQL settings (used when sign_file_db_backend == "postgresql") +sign_file_pg_host: "localhost" +sign_file_pg_port: 5432 +sign_file_pg_name: "signfile" +sign_file_pg_user: "signfile" +sign_file_pg_password: "signfile" + +# Effective DB URL, derived from the backend. Override directly if you need a +# URL shape not covered here. +sign_file_db_url: >- + {{ ('sqlite:///' ~ sign_file_db_path) + if sign_file_db_backend == 'sqlite' + else ('postgresql://' ~ sign_file_pg_user ~ ':' ~ sign_file_pg_password + ~ '@' ~ sign_file_pg_host ~ ':' ~ (sign_file_pg_port | string) + ~ '/' ~ sign_file_pg_name) }} + +sign_file_bind_host: "0.0.0.0" +sign_file_port: 8000 +sign_file_workers: 4 + +sign_file_root_url: "/sign-file" +sign_file_jwt_secret: "access-secret" +sign_file_gpg_binary: "/usr/bin/gpg2" +sign_file_keyring: "/home/{{ service_user }}/.gnupg/pubring.kbx" + +# Passphrase handling is driven by the shared `sign_dev_mode` and Bitwarden +# vars (see inventory group_vars). In dev, the static dev passphrase env vars +# are written to the unit; in prod they are omitted and Bitwarden (rendered +# into config.yaml) supplies passphrases. +... diff --git a/roles/separate_sign_file/tasks/create_env.yml b/roles/separate_sign_file/tasks/create_env.yml new file mode 100644 index 0000000..f9ab9d5 --- /dev/null +++ b/roles/separate_sign_file/tasks/create_env.yml @@ -0,0 +1,65 @@ +--- +# System packages (gpg2, pinentry, python3, gcc, git) and the service user are +# provided by the sign_common role, which must run before this one. + +- name: Create the sign-file config directory + ansible.builtin.file: + path: "{{ sign_file_config_dir }}" + state: directory + owner: "{{ service_user }}" + group: "{{ service_group }}" + mode: "0750" + become: yes + +- name: Clone the albs-sign-file repository + become: yes + become_user: "{{ service_user }}" + ansible.builtin.git: + repo: "{{ sign_file_repo }}" + dest: "{{ sign_file_working_directory }}" + version: "{{ sign_file_repo_version }}" + clone: yes + update: yes + accept_hostkey: yes + force: yes + +- name: Create the sign-file venv and install the package (system python3.9) + become: yes + become_user: "{{ service_user }}" + ansible.builtin.pip: + # The Bitwarden client (bitwarden-wrapper) is an optional extra in + # setup.py; pull it in only when Bitwarden is enabled, else the service + # fails at runtime trying to fetch key passphrases. + name: "{{ '.[bitwarden]' if (bitwarden_enabled | default(false) | bool) else '.' }}" + chdir: "{{ sign_file_working_directory }}" + virtualenv: "{{ sign_file_venv_directory }}" + virtualenv_command: "python3 -m venv" + +- name: Render the sign-file config.yaml + become: yes + become_user: "{{ service_user }}" + ansible.builtin.template: + src: config.yaml.j2 + dest: "{{ sign_file_config_file }}" + owner: "{{ service_user }}" + group: "{{ service_group }}" + mode: "0640" + backup: yes + +# Schema setup via alembic (env.py reads settings.db_url from config.yaml). +# Dev: dev_init also seeds a test user; prod: migrations only. For PostgreSQL +# the database and role must already exist — this only creates the schema. +# Both commands are idempotent (alembic upgrade head; dev user creation is +# guarded), so they are safe to run on every deploy. +- name: Initialize / upgrade the sign-file database schema + become: yes + become_user: "{{ service_user }}" + environment: + SF_CONFIG_FILE: "{{ sign_file_config_file }}" + ansible.builtin.command: >- + {{ sign_file_venv_directory }}/bin/python3 db_manage.py + {{ 'dev_init' if (sign_dev_mode | bool) else 'migrate_upgrade' }} + args: + chdir: "{{ sign_file_working_directory }}" + changed_when: true +... diff --git a/roles/separate_sign_file/tasks/install_systemd_service.yml b/roles/separate_sign_file/tasks/install_systemd_service.yml new file mode 100644 index 0000000..0fef65f --- /dev/null +++ b/roles/separate_sign_file/tasks/install_systemd_service.yml @@ -0,0 +1,20 @@ +--- +- name: Install the albs-sign-file systemd unit + ansible.builtin.template: + dest: /etc/systemd/system/albs-sign-file.service + src: albs-sign-file.service.j2 + owner: root + group: root + mode: "0644" + backup: yes + become: yes + +- name: Enable and (re)start the albs-sign-file service + ansible.builtin.systemd: + name: albs-sign-file.service + state: restarted + enabled: yes + daemon_reload: yes + masked: no + become: yes +... diff --git a/roles/separate_sign_file/tasks/main.yml b/roles/separate_sign_file/tasks/main.yml new file mode 100644 index 0000000..8ebbf2b --- /dev/null +++ b/roles/separate_sign_file/tasks/main.yml @@ -0,0 +1,20 @@ +--- +- name: Deploy albs-sign-file on a sign host + block: + # Multi-worker + Bitwarden shares ONE session established by + # bw-ensure-session, which logs in/unlocks via --passwordfile. An inline + # bitwarden_password cannot satisfy that path, so require the file form. + - name: Require a Bitwarden password file for the multi-worker session + ansible.builtin.assert: + that: + - bitwarden_password_file | default('') | length > 0 + fail_msg: >- + sign_file_workers > 1 with Bitwarden enabled needs a shared session + via bw-ensure-session, which requires bitwarden_password_file. Set + bitwarden_password_file (an inline bitwarden_password will not work), + or run sign-file with a single worker. + when: sign_file_bw_session_required | bool + + - include_tasks: create_env.yml + - include_tasks: install_systemd_service.yml +... diff --git a/roles/separate_sign_file/templates/albs-sign-file.service.j2 b/roles/separate_sign_file/templates/albs-sign-file.service.j2 new file mode 100644 index 0000000..d570ad2 --- /dev/null +++ b/roles/separate_sign_file/templates/albs-sign-file.service.j2 @@ -0,0 +1,34 @@ +[Unit] +Description=ALBS Sign File service +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User={{ service_user }} +Group={{ service_group }} +WorkingDirectory={{ sign_file_working_directory }} +Environment=SF_CONFIG_FILE={{ sign_file_config_file }} +{% if sign_dev_mode | default(true) | bool %} +# Dev only: unlock signing keys with a static passphrase. Never set in prod — +# prod uses Bitwarden (see config.yaml). +Environment=SF_PASS_DB_DEV_MODE=True +Environment=SF_PASS_DB_DEV_PASS={{ gpg_default_password }} +{% endif %} +{% if sign_file_bw_session_required | bool %} +# Multi-worker + Bitwarden: establish ONE shared session and export it so every +# uvicorn worker reuses it instead of racing `bw login`/`bw unlock`. bash -c +# only sets BW_SESSION from the helper, then `exec`s uvicorn (which becomes the +# service main process). $$ escapes the $ so systemd passes it through to bash. +ExecStart=/bin/bash -c 'export BW_SESSION="$$({{ bitwarden_cli_install_dir | default("/usr/local/bin") }}/bw-ensure-session "{{ bitwarden_username }}" "{{ bitwarden_password_file }}")"; exec {{ sign_file_venv_directory }}/bin/uvicorn sign.app:app --host {{ sign_file_bind_host }} --port {{ sign_file_port }} --workers {{ sign_file_workers }}' +{% else %} +ExecStart={{ sign_file_venv_directory }}/bin/uvicorn sign.app:app \ + --host {{ sign_file_bind_host }} \ + --port {{ sign_file_port }} \ + --workers {{ sign_file_workers }} +{% endif %} +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target diff --git a/roles/separate_sign_file/templates/config.yaml.j2 b/roles/separate_sign_file/templates/config.yaml.j2 new file mode 100644 index 0000000..3c36af5 --- /dev/null +++ b/roles/separate_sign_file/templates/config.yaml.j2 @@ -0,0 +1,36 @@ +# albs-sign-file configuration — managed by Ansible (separate_sign_file role). +# Loaded via SF_CONFIG_FILE. SF_* env vars still override any value here. + +signing_backend: gpg +root_url: "{{ sign_file_root_url }}" + +gpg: + binary: "{{ sign_file_gpg_binary }}" + keyring: "{{ sign_file_keyring }}" + locks_dir: /tmp/gpg_locks + keys: +{% for key in gpg_keys %} + - "{{ key }}" +{% endfor %} + +database: + url: "{{ sign_file_db_url }}" + +jwt: + secret_key: "{{ sign_file_jwt_secret }}" + expire_minutes: 30 + algorithm: HS256 + +bitwarden: + enabled: {{ bitwarden_enabled | default(false) | bool | to_json }} +{% if bitwarden_enabled | default(false) | bool %} + username: "{{ bitwarden_username }}" +{% if bitwarden_password_file | default('') %} + password_file: "{{ bitwarden_password_file }}" +{% elif bitwarden_password | default('') %} + password: "{{ bitwarden_password }}" +{% endif %} +{% if bitwarden_collection_id | default('') %} + collection_id: "{{ bitwarden_collection_id }}" +{% endif %} +{% endif %} diff --git a/roles/separate_sign_node/defaults/main/common.yml b/roles/separate_sign_node/defaults/main/common.yml index 90989ee..661aaa4 100644 --- a/roles/separate_sign_node/defaults/main/common.yml +++ b/roles/separate_sign_node/defaults/main/common.yml @@ -7,12 +7,15 @@ final_conf_dir: "{{ base_conf_dir }}" sign_node_working_directory: "{{ home_dir }}/albs" sign_node_venv_directory: "{{ sign_node_working_directory }}/venv" sign_node_requirements_path: "{{ sign_node_working_directory }}/albs-sign-node/requirements.txt" +# NOTE: the immudb/protobuf startup breakage is handled in create_env.yml by +# removing the spurious `google-api` package after install (keeps modern +# protobuf), not by pinning the dependency stack here. working_directories: - "{{ base_work_dir }}" - "{{ sign_node_working_directory }}" - "{{ sign_node_venv_directory }}" - "{{ base_work_dir }}/sign_node" - "{{ final_conf_dir }}" -service_group: "alt" -service_user: "alt" +service_group: "albs-signer" +service_user: "albs-signer" ... diff --git a/roles/separate_sign_node/tasks/create_env.yml b/roles/separate_sign_node/tasks/create_env.yml index 7222408..2fc3dc7 100644 --- a/roles/separate_sign_node/tasks/create_env.yml +++ b/roles/separate_sign_node/tasks/create_env.yml @@ -1,13 +1,6 @@ --- -- name: Create service user - user: - name: "{{ service_user }}" - groups: wheel - append: yes - state: present - create_home: yes - generate_ssh_key: yes - become: "yes" +# The service user and the GnuPG keyring (the `gpg_keys` fact used below) are +# provisioned by the sign_common role, which must run before this one. - name: Remove venv if exists file: @@ -21,44 +14,10 @@ file: path: "{{ item }}" state: directory - recurse: yes owner: "{{ service_user }}" group: "{{ service_group }}" with_items: "{{ working_directories }}" -- name: Get already existing GPG key's fingerprint - become_user: "{{ service_user }}" - become: "yes" - shell: "gpg --list-keys test@albs.local | head -n 2 | tail -1" - register: gpg_key_fingerprint - -- name: Generate a GPG scenario - become_user: "{{ service_user }}" - become: "yes" - template: - dest: "{{ gpg_key_scenario.dest }}/{{ gpg_key_scenario.name }}" - src: "{{ gpg_key_scenario.name }}.j2" - owner: "{{ gpg_key_scenario.owner }}" - mode: "{{ gpg_key_scenario.mode }}" - force: yes - -- name: Generate GPG key - become_user: "{{ service_user }}" - become: "yes" - shell: "gpg --batch --gen-key {{ gpg_key_scenario.dest }}/{{ gpg_key_scenario.name }}" - when: gpg_key_fingerprint.stdout == "" - -- name: Get already existing GPG key's fingerprint - become_user: "{{ service_user }}" - become: "yes" - shell: "gpg --list-keys test@albs.local | head -n 2 | tail -1" - register: gpg_key_fingerprint - -- name: Set GPG key - set_fact: - gpg_keys: - - "{{ gpg_key_fingerprint.stdout[-16:] }}" - - name: Generate sign node config become_user: "{{ service_user }}" become: "yes" @@ -68,6 +27,7 @@ owner: "{{ service_user }}" group: "{{ service_group }}" mode: "0644" + backup: yes - name: Clone sign node repository become_user: "{{ service_user }}" @@ -88,6 +48,9 @@ state: latest virtualenv: "{{ sign_node_venv_directory }}" +# Isolated venv (no system site-packages): the system google.* package would +# otherwise shadow the venv's namespaced google.protobuf (pulled in by +# immudb_wrapper -> immudb-py), breaking sign-node startup. - name: Create venv become: yes become_user: "{{ service_user }}" @@ -95,7 +58,6 @@ virtualenv: "{{ sign_node_venv_directory }}" virtualenv_command: "python3 -m venv" requirements: "{{ sign_node_requirements_path }}" - virtualenv_site_packages: true - name: Install pycurl in venv become: yes @@ -106,6 +68,29 @@ - rpm virtualenv: "{{ sign_node_venv_directory }}" +# immudb-py declares a spurious dependency on the `google-api` PyPI stub, which +# installs a regular google/__init__.py and shadows the PEP420 `google` +# namespace, so `import google.protobuf` / `google.api` break at startup. +# immudb-py never actually imports it, so remove it after install — this keeps +# modern protobuf working instead of pinning the whole stack to protobuf 3. +# Runs after every venv rebuild (requirements.txt re-pulls it each time). +- name: Remove the spurious google-api package (breaks the google namespace) + become: yes + become_user: "{{ service_user }}" + ansible.builtin.pip: + name: google-api + state: absent + virtualenv: "{{ sign_node_venv_directory }}" + +# Fail the deploy here (not at service start) if the dependency tree is broken. +- name: Smoke-test the sign-node import chain + become: yes + become_user: "{{ service_user }}" + ansible.builtin.command: >- + {{ sign_node_venv_directory }}/bin/python -c + "import google.protobuf, google.api.annotations_pb2, immudb_wrapper" + changed_when: false + - name: Add pulp to /etc/hosts ansible.builtin.lineinfile: path: /etc/hosts diff --git a/roles/separate_sign_node/tasks/dnf.yml b/roles/separate_sign_node/tasks/dnf.yml index b2f4cf2..5747417 100644 --- a/roles/separate_sign_node/tasks/dnf.yml +++ b/roles/separate_sign_node/tasks/dnf.yml @@ -10,12 +10,6 @@ dest: /etc/yum.repos.d/signnode.repo become: "yes" -- name: Download codenotary.repo to /etc/yum.repos.d/ - get_url: - url: https://packages.codenotary.org/codenotary.repo - dest: /etc/yum.repos.d/codenotary.repo - become: "yes" - - name: Install epel repository dnf: name: "epel-release" @@ -30,7 +24,6 @@ - powertools - epel - signnode - - codenotary-repo update_cache: yes become: "yes" ... diff --git a/roles/separate_sign_node/tasks/install_systemd_service.yml b/roles/separate_sign_node/tasks/install_systemd_service.yml index efacf70..ad7ce42 100644 --- a/roles/separate_sign_node/tasks/install_systemd_service.yml +++ b/roles/separate_sign_node/tasks/install_systemd_service.yml @@ -6,6 +6,7 @@ group: "root" owner: "root" mode: "0644" + backup: yes become: "yes" - name: Enable and start build node systemd service diff --git a/roles/separate_sign_node/tasks/proxy.yml b/roles/separate_sign_node/tasks/proxy.yml index 196e3a3..35f84ca 100644 --- a/roles/separate_sign_node/tasks/proxy.yml +++ b/roles/separate_sign_node/tasks/proxy.yml @@ -5,6 +5,7 @@ src: pulp.conf.j2 dest: /etc/nginx/conf.d/pulp.conf mode: "0644" + backup: yes register: nginx_conf become: "yes" diff --git a/roles/separate_sign_node/templates/sign_node.yml.j2 b/roles/separate_sign_node/templates/sign_node.yml.j2 index 05af570..d24cf71 100644 --- a/roles/separate_sign_node/templates/sign_node.yml.j2 +++ b/roles/separate_sign_node/templates/sign_node.yml.j2 @@ -4,8 +4,22 @@ jwt_token: "{{ albs_jwt_token }}" pulp_host: "http://{{ albs_address }}:8081" pulp_user: "{{ pulp_user }}" pulp_password: "{{ pulp_password }}" -development_mode: true +development_mode: {{ sign_dev_mode | default(true) | bool | to_json }} +{% if sign_dev_mode | default(true) | bool %} dev_pgp_key_password: "{{ gpg_default_password }}" +{% endif %} +bitwarden_enabled: {{ bitwarden_enabled | default(false) | bool | to_json }} +{% if bitwarden_enabled | default(false) | bool %} +bitwarden_username: "{{ bitwarden_username }}" +{% if bitwarden_password_file | default('') %} +bitwarden_password_file: "{{ bitwarden_password_file }}" +{% elif bitwarden_password | default('') %} +bitwarden_password: "{{ bitwarden_password }}" +{% endif %} +{% if bitwarden_collection_id | default('') %} +bitwarden_collection_id: "{{ bitwarden_collection_id }}" +{% endif %} +{% endif %} master_url: "http://{{ albs_address }}:8080/api/v1/" ws_master_url: "ws://{{ albs_address }}:8080/api/v1/" {% if immudb_address is defined and immudb_address %} diff --git a/roles/sign_common/defaults/main/main.yml b/roles/sign_common/defaults/main/main.yml new file mode 100644 index 0000000..67280e5 --- /dev/null +++ b/roles/sign_common/defaults/main/main.yml @@ -0,0 +1,46 @@ +--- +# Identity shared by both sign services on a host. Both albs-sign-node and +# albs-sign-file run as this user and read the SAME GnuPG keyring, so the +# service user is created once here. +service_user: "albs-signer" +service_group: "albs-signer" + +# Both sign services run from the service user's home (/home//...), which +# is labeled user_home_t. Under enforcing SELinux, systemd (init_t) cannot exec +# the venv interpreter or read the config/keyring there, so the services fail to +# start. Set SELinux permissive on the sign hosts (matches production). Flip to +# "enforcing" only if the install is ever relocated off /home into a service- +# friendly context (/srv, /opt). +sign_common_selinux_state: "permissive" + +# Base OS packages needed by both services. Python is the system interpreter +# (Python 3.9 on AlmaLinux 9); each service builds its OWN venv from it, so no +# pip/venv work happens in this role. +sign_common_packages: + - gnupg2 + - pinentry + - git + - python3 + - python3-devel + - gcc + +# --- GPG key handling ------------------------------------------------------- +# Set `pgp_keys` per host (host_vars) to the 16-char key id(s) that host signs +# with. It is authoritative when non-empty. Leave empty to fall back to the +# host keyring, or to auto-generation. +pgp_keys: [] + +# Optional: private key files (on the control node) to import into the host +# keyring before resolving `gpg_keys`. Use for real, distinct per-host keys. +gpg_import_keys: [] + +# Set to false on hosts where keys are provisioned externally (imported or +# already present). When false and no key can be found, the play fails loudly +# instead of silently signing with a wrong/absent key. +sign_generate_gpg_key: true + +# Identity + passphrase for an auto-generated dev key (only used when +# sign_generate_gpg_key is true and no key is present). +gpg_default_email: "test@albs.local" +gpg_default_password: "1234567890" +... diff --git a/roles/sign_common/tasks/gpg.yml b/roles/sign_common/tasks/gpg.yml new file mode 100644 index 0000000..eed20a9 --- /dev/null +++ b/roles/sign_common/tasks/gpg.yml @@ -0,0 +1,120 @@ +--- +# Resolve the host's signing key(s) into the `gpg_keys` fact, which both +# service roles consume (sign_node.yml / albs-sign-file config.yaml). +# +# Resolution order: +# 1. Explicit `pgp_keys` (host_vars) -> authoritative +# 2. Key(s) already in the host keyring -> derive key ids +# 3. Auto-generated dev key -> only if sign_generate_gpg_key +# +# The keyring is the service user's ~/.gnupg, shared by both services. + +- name: Stage provided private keys on the host + become: yes + become_user: "{{ service_user }}" + ansible.builtin.copy: + src: "{{ item }}" + dest: "{{ ('~/' ~ 'sign-import-' ~ idx ~ '.asc') | expanduser }}" + mode: "0600" + loop: "{{ gpg_import_keys }}" + loop_control: + index_var: idx + when: gpg_import_keys | length > 0 + +- name: Import provided private keys into the keyring + become: yes + become_user: "{{ service_user }}" + ansible.builtin.command: "gpg --batch --import {{ ('~/' ~ 'sign-import-' ~ idx ~ '.asc') | expanduser }}" + loop: "{{ gpg_import_keys }}" + loop_control: + index_var: idx + when: gpg_import_keys | length > 0 + register: gpg_import_result + changed_when: "'imported' in (gpg_import_result.stderr | default(''))" + +- name: Remove staged private key files + become: yes + become_user: "{{ service_user }}" + ansible.builtin.file: + path: "{{ ('~/' ~ 'sign-import-' ~ idx ~ '.asc') | expanduser }}" + state: absent + loop: "{{ gpg_import_keys }}" + loop_control: + index_var: idx + when: gpg_import_keys | length > 0 + +- name: Check for existing secret keys in the keyring + become: yes + become_user: "{{ service_user }}" + ansible.builtin.shell: >- + set -o pipefail; + gpg --list-secret-keys --with-colons | awk -F: '/^sec:/ {print $5}' + args: + executable: /bin/bash + register: existing_secret_keys + changed_when: false + +- name: Generate a dev GPG key (no key present and generation enabled) + when: + - pgp_keys | length == 0 + - existing_secret_keys.stdout | trim == "" + - sign_generate_gpg_key | bool + block: + - name: Render the GPG key generation scenario + become: yes + become_user: "{{ service_user }}" + ansible.builtin.template: + src: gpg-key-scenario.j2 + dest: "{{ '~/gpg-key-scenario' | expanduser }}" + mode: "0600" + force: yes + + - name: Generate the GPG key + become: yes + become_user: "{{ service_user }}" + ansible.builtin.command: "gpg --batch --gen-key {{ '~/gpg-key-scenario' | expanduser }}" + changed_when: true + +- name: Re-read secret keys after optional generation + become: yes + become_user: "{{ service_user }}" + ansible.builtin.shell: >- + set -o pipefail; + gpg --list-secret-keys --with-colons | awk -F: '/^sec:/ {print $5}' + args: + executable: /bin/bash + register: secret_keys + changed_when: false + +- name: Fail when no signing key is available + ansible.builtin.fail: + msg: >- + No signing key on {{ inventory_hostname }}: `pgp_keys` is unset, the + keyring holds no secret key, and sign_generate_gpg_key is false. Provide + `pgp_keys` (and `gpg_import_keys`), or enable generation. + when: + - pgp_keys | length == 0 + - secret_keys.stdout | trim == "" + +# Declaring pgp_keys is authoritative and skips import/generation, so a key id +# that is not actually in the keyring would be written into the service configs +# and fail only at sign time (the node silently skips packages it can't sign). +# Verify each declared key is present as a secret key here instead. +- name: Verify every declared signing key is present in the keyring + vars: + keyring_ids: "{{ secret_keys.stdout_lines | map('upper') | list }}" + missing_keys: "{{ pgp_keys | map('upper') | reject('in', keyring_ids) | list }}" + ansible.builtin.fail: + msg: >- + Declared pgp_keys not found as secret keys in {{ inventory_hostname }}'s + keyring: {{ missing_keys | join(', ') }}. Provide the private key(s) via + gpg_import_keys, pre-provision them on the host, or fix pgp_keys. Ids must + be the 16-char long key id as shown by `gpg --list-secret-keys`. + when: + - pgp_keys | length > 0 + - missing_keys | length > 0 + +- name: Resolve the gpg_keys fact used by both service roles + ansible.builtin.set_fact: + gpg_keys: "{{ pgp_keys if (pgp_keys | length > 0) else secret_keys.stdout_lines }}" +... diff --git a/roles/sign_common/tasks/main.yml b/roles/sign_common/tasks/main.yml new file mode 100644 index 0000000..062f9a6 --- /dev/null +++ b/roles/sign_common/tasks/main.yml @@ -0,0 +1,34 @@ +--- +- name: Set up the shared sign host (user, packages, GPG keyring) + block: + - name: Set SELinux state (services run from /home; enforcing blocks them) + ansible.posix.selinux: + policy: targeted + state: "{{ sign_common_selinux_state }}" + become: yes + + - name: Create the sign service user + user: + name: "{{ service_user }}" + groups: wheel + append: yes + state: present + create_home: yes + generate_ssh_key: yes + become: yes + + - name: Install EPEL repository + dnf: + name: epel-release + state: present + become: yes + + - name: Install base packages shared by both sign services + dnf: + name: "{{ sign_common_packages }}" + state: present + update_cache: yes + become: yes + + - include_tasks: gpg.yml +... diff --git a/roles/sign_common/templates/gpg-key-scenario.j2 b/roles/sign_common/templates/gpg-key-scenario.j2 new file mode 100644 index 0000000..206b36b --- /dev/null +++ b/roles/sign_common/templates/gpg-key-scenario.j2 @@ -0,0 +1,8 @@ +Key-Type: 1 +Key-Length: 2048 +Subkey-Type: 1 +Subkey-Length: 2048 +Name-Real: Test GPG key for ALBS sign host ({{ inventory_hostname }}) +Name-Email: "{{ gpg_default_email }}" +Expire-Date: 0 +Passphrase: {{ gpg_default_password }} diff --git a/vars.sign_hosts.yml.tmpl b/vars.sign_hosts.yml.tmpl new file mode 100644 index 0000000..222d890 --- /dev/null +++ b/vars.sign_hosts.yml.tmpl @@ -0,0 +1,93 @@ +--- +# Variables for the sign-hosts deploy (playbooks/albs_sign_hosts.yml). +# Copy to vars.sign_hosts.yml, fill in, and run: +# ansible-playbook -i inventories/sign_nodes -u \ +# -e "@vars.sign_hosts.yml" playbooks/albs_sign_hosts.yml +# +# NOTE: per-host values (each host's address and signing key) belong in +# inventories/sign_nodes/host_vars/.yml, NOT here — this file holds the +# settings shared by every sign host. See the host_vars examples in that dir. + +# --- Connection ------------------------------------------------------------- +# ALBS master (web-server) the sign nodes talk to. +albs_address: +use_local_connection: false # false for remote multi-host deploys +ansible_interpreter_path: auto + +# --- Identity (sign_common) ------------------------------------------------- +service_user: albs-signer +service_group: albs-signer + +# --- GPG keys (sign_common) ------------------------------------------------- +# Each host's signing key is resolved in this order: +# 1. `pgp_keys` - authoritative list of 16-char long key ids the host +# signs with. The play FAILS if a declared id is not +# actually a secret key in the host keyring, so the +# key must already be present or imported (see below). +# 2. existing keyring - if `pgp_keys` is empty, every secret key already in +# the host keyring is used. +# 3. auto-generated key - only if the above find nothing AND +# `sign_generate_gpg_key` is true (dev convenience). +# +# To PROVIDE ALREADY-EXISTING / SUPPORTED KEYS (the prod path), set both of +# these PER HOST in inventories/sign_nodes/host_vars/.yml — they are +# per-host, not shared, so they do NOT belong in this file: +# pgp_keys: # the key id(s) this host signs with +# - "7C3955C2A345DA89" +# gpg_import_keys: # optional: private key file(s) on the control +# - files/keys/sign01.asc # node to import before resolution +# +# Set `pgp_keys` globally HERE only if every host genuinely shares one key. +# +# sign_generate_gpg_key: set false in prod so hosts without a provided/pre-seeded +# key fail loudly instead of silently signing with an auto-generated dev key. +sign_generate_gpg_key: true +gpg_default_email: test@albs.local +gpg_default_password: "" + +# --- Environment: dev vs prod ----------------------------------------------- +# true = dev: static passphrase unlocks keys, sign-file seeds a test DB user. +# false = prod: no static passphrase; passphrases come from Bitwarden. +sign_dev_mode: true + +# --- albs-sign-node --------------------------------------------------------- +albs_jwt_token: "" +albs_jwt_secret: "" +pulp_user: +pulp_password: +# immudb notarization (optional) — omit all four to disable. +# immudb_address: +# immudb_database: +# immudb_username: +# immudb_password: + +# --- albs-sign-file --------------------------------------------------------- +sign_file_workers: 4 # >1 triggers the shared BW_SESSION path +sign_file_jwt_secret: "" +# Database backend: sqlite (standalone, no extra deps) or postgresql. +sign_file_db_backend: sqlite +# PostgreSQL settings (used only when sign_file_db_backend == postgresql). +# The database and role must already exist — the role creates only the schema. +# sign_file_pg_host: +# sign_file_pg_port: 5432 +# sign_file_pg_name: signfile +# sign_file_pg_user: signfile +# sign_file_pg_password: + +# --- Bitwarden (bitwarden_cli + prod passphrases) --------------------------- +# Enable in prod so both services fetch key passphrases from Bitwarden. +# The bitwarden_cli role installs the CLI and runs `bw config server`. +bitwarden_enabled: false +bitwarden_url: "" +bitwarden_username: "" +# Master vault password. Do NOT commit plaintext — keep this file (or just this +# value) in Ansible Vault and pass --vault-password-file. The bitwarden_cli role +# writes it to bitwarden_password_file on each host (0600, owned by service_user). +bitwarden_master_password: "" +# Managed path the role writes the password to (default below). Override only if +# you provision the file yourself; then leave bitwarden_master_password empty. +# bitwarden_password_file: "/home/albs-signer/.config/.bw-master" +# Inline password alternative — single-worker sign-file only, less safe: +# bitwarden_password: "" +# bitwarden_collection_id: "" +# bitwarden_cli_version: "2026.4.1" # override the pinned CLI version if needed