From ed661af1dbcfde50d25ae2720dd3ed99f739765a Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 26 Aug 2026 14:28:27 -0400 Subject: [PATCH] bootstrap: set hostname racknerd1, ansible-key safety check, harden root login (prohibit-password), /var/log/bootstrap log+sha256 --- playbooks/bootstrap.yml | 262 +++++++++++++++++++++++++++++----------- 1 file changed, 191 insertions(+), 71 deletions(-) diff --git a/playbooks/bootstrap.yml b/playbooks/bootstrap.yml index e56997f..b4b7c57 100644 --- a/playbooks/bootstrap.yml +++ b/playbooks/bootstrap.yml @@ -2,63 +2,61 @@ # ============================================================================ # bootstrap.yml — prepare a fresh host for managed deployments # ============================================================================ -# PLAY 1 (precheck, on the Semaphore runner): authenticate to Infisical, pull -# secrets, validate them, and test SSH reachability to the target. -# If ANYTHING fails, the run stops here with a clear reason — BEFORE -# any change is made to the target host. -# PLAY 2 (bootstrap, on the target as root): cleanup (allowlist) -> create the -# `ansible` user + install SSH key -> verify. +# PLAY 1 (precheck, Semaphore runner): Infisical login + pull/validate secrets + +# test root reachability. Aborts with a reason if anything fails — +# BEFORE any change to the target. +# PLAY 2 (bootstrap, target as root): +# 1. confirm root +# 2. cleanup (explicit allowlist only; empty = nothing removed) +# 3. set hostname -> racknerd1 (+ /etc/hosts) +# 4. create `ansible` user + password (from Infisical) +# 5. install SSH public key -> /home/ansible/.ssh/authorized_keys + sudoers +# 6. SAFETY CHECK: verify ansible-user key login works +# 7. ONLY IF key login passed: PermitRootLogin prohibit-password +# (sshd -t validate -> restart -> verify up). If key test failed: ABORT, +# leave root auth untouched (no lockout). +# 8. write timestamped run log + SHA256 to /var/log/bootstrap/ # -# Infisical auth (Universal Auth) — provide these to the runner as env vars -# (Semaphore Environment / Variable Group), NOT committed: -# INFISICAL_CLIENT_ID -# INFISICAL_CLIENT_SECRET -# INFISICAL_PROJECT_ID -# INFISICAL_URL (default http://192.168.122.41:8080) -# INFISICAL_ENV_SLUG (default dev) +# Infisical auth env vars (Semaphore Variable Group 'infisical-dev', Secrets): +# INFISICAL_CLIENT_ID / _CLIENT_SECRET / _PROJECT_ID (URL+ENV_SLUG optional) +# Secrets read: SSH_PUBLIC_KEY, SSH_PRIVATE_KEY, ANSIBLE_USER_PASS, ANSIBLE_BECOME_ROOT_PASS # -# Secrets READ from Infisical (must exist in the project/env): -# SSH_PUBLIC_KEY, ANSIBLE_USER_PASS, ANSIBLE_BECOME_ROOT_PASS -# -# Usage (Semaphore Task Template): -# playbook: playbooks/bootstrap.yml -# inventory: inventory/hosts.yml -# extra vars: target=racknerd1 connect_user=root -# (optional) cleanup_paths='["/opt/old-thing"]' +# Usage: extra vars: target=racknerd1 connect_user=root new_hostname=racknerd1 +# optional: cleanup_paths='["/opt/old"]' # ============================================================================ # --------------------------------------------------------------------------- -# PLAY 1 — PRECHECK (runs on the Semaphore runner / localhost). No target changes. +# PLAY 1 — PRECHECK (runner/localhost). No target changes. # --------------------------------------------------------------------------- -- name: "PRECHECK — Infisical + connectivity (no changes made)" +- name: "PRECHECK — Infisical + connectivity (no changes)" hosts: localhost gather_facts: false - vars: - infisical_url: "{{ lookup('env','INFISICAL_URL') | default('http://192.168.122.41:8080', true) }}" - infisical_project_id: "{{ lookup('env','INFISICAL_PROJECT_ID') }}" - infisical_env_slug: "{{ lookup('env','INFISICAL_ENV_SLUG') | default('dev', true) }}" - target_host: "{{ target | default('racknerd1') }}" tasks: - - name: "[precheck] Infisical auth vars present?" + - name: "[pre] resolve Infisical auth (env, else extra-var)" + ansible.builtin.set_fact: + infisical_client_id: "{{ lookup('env','INFISICAL_CLIENT_ID') | default(lookup('vars','INFISICAL_CLIENT_ID', default=''), true) }}" + infisical_client_secret: "{{ lookup('env','INFISICAL_CLIENT_SECRET') | default(lookup('vars','INFISICAL_CLIENT_SECRET', default=''), true) }}" + infisical_project_id: "{{ lookup('env','INFISICAL_PROJECT_ID') | default(lookup('vars','INFISICAL_PROJECT_ID', default=''), true) }}" + infisical_url: "{{ lookup('env','INFISICAL_URL') | default(lookup('vars','INFISICAL_URL', default='http://192.168.122.41:8080'), true) }}" + infisical_env_slug: "{{ lookup('env','INFISICAL_ENV_SLUG') | default(lookup('vars','INFISICAL_ENV_SLUG', default='dev'), true) }}" + + - name: "[pre] Infisical auth present?" ansible.builtin.assert: that: - - lookup('env','INFISICAL_CLIENT_ID') | length > 0 - - lookup('env','INFISICAL_CLIENT_SECRET') | length > 0 + - infisical_client_id | length > 0 + - infisical_client_secret | length > 0 - infisical_project_id | length > 0 - fail_msg: >- - Missing Infisical auth. Set INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET, - and INFISICAL_PROJECT_ID in the Semaphore Environment for this template. + fail_msg: "Missing Infisical creds in the Variable Group (INFISICAL_CLIENT_ID/_SECRET/_PROJECT_ID)." - - name: "[precheck] Log in to Infisical" + - name: "[pre] Log in to Infisical" infisical.vault.login: url: "{{ infisical_url }}" auth_method: universal_auth - universal_auth_client_id: "{{ lookup('env','INFISICAL_CLIENT_ID') }}" - universal_auth_client_secret: "{{ lookup('env','INFISICAL_CLIENT_SECRET') }}" + universal_auth_client_id: "{{ infisical_client_id }}" + universal_auth_client_secret: "{{ infisical_client_secret }}" register: infisical_login - # if this fails, the play stops here with the SDK error (bad creds / unreachable Infisical) - - name: "[precheck] Read required secrets from Infisical" + - name: "[pre] Read secrets" infisical.vault.read_secrets: login_data: "{{ infisical_login.login_data }}" project_id: "{{ infisical_project_id }}" @@ -66,45 +64,51 @@ path: "/" as_dict: true register: infisical_secrets + no_log: true - - name: "[precheck] Validate the secrets we need are present + well-formed" + - name: "[pre] Validate required secrets" ansible.builtin.assert: that: - infisical_secrets.secrets.SSH_PUBLIC_KEY is defined - infisical_secrets.secrets.SSH_PUBLIC_KEY.startswith('ssh-') - - infisical_secrets.secrets.ANSIBLE_USER_PASS is defined + - infisical_secrets.secrets.SSH_PRIVATE_KEY is defined - infisical_secrets.secrets.ANSIBLE_USER_PASS | length > 0 - - infisical_secrets.secrets.ANSIBLE_BECOME_ROOT_PASS is defined - infisical_secrets.secrets.ANSIBLE_BECOME_ROOT_PASS | length > 0 - fail_msg: >- - Infisical is reachable but required secrets are missing/invalid in env - '{{ infisical_env_slug }}'. Need: SSH_PUBLIC_KEY (OpenSSH format), - ANSIBLE_USER_PASS, ANSIBLE_BECOME_ROOT_PASS. + fail_msg: "Required secrets missing/invalid in env '{{ infisical_env_slug }}'." - - name: "[precheck] Test SSH reachability to target ({{ target_host }})" + - name: "[pre] stash secrets + write private key to a temp file for the key-login safety test" + ansible.builtin.set_fact: + pc_ssh_pubkey: "{{ infisical_secrets.secrets.SSH_PUBLIC_KEY }}" + pc_ssh_privkey: "{{ infisical_secrets.secrets.SSH_PRIVATE_KEY }}" + pc_user_pass: "{{ infisical_secrets.secrets.ANSIBLE_USER_PASS }}" + pc_root_pass: "{{ infisical_secrets.secrets.ANSIBLE_BECOME_ROOT_PASS }}" + no_log: true + + - name: "[pre] write deploy private key to runner temp (0600) for safety test" + ansible.builtin.copy: + dest: "/tmp/bootstrap_deploy_key" + content: "{{ pc_ssh_privkey }}\n" + mode: "0600" + no_log: true + + - name: "[pre] test SSH reachability to target as root" ansible.builtin.command: >- - ansible {{ target_host }} -i {{ inventory_file }} + ansible {{ target | default('racknerd1') }} -i {{ inventory_file }} -m ansible.builtin.ping -e "ansible_user={{ connect_user | default('root') }}" - -e "ansible_password={{ infisical_secrets.secrets.ANSIBLE_BECOME_ROOT_PASS }}" + -e "ansible_password={{ pc_root_pass }}" -e "ansible_ssh_common_args=-o StrictHostKeyChecking=accept-new" register: reach changed_when: false failed_when: reach.rc != 0 - no_log: true # avoid leaking the password in output + no_log: true - - name: "[precheck] PASSED — stash secrets for the bootstrap play" - ansible.builtin.set_fact: - pc_ssh_pubkey: "{{ infisical_secrets.secrets.SSH_PUBLIC_KEY }}" - pc_user_pass: "{{ infisical_secrets.secrets.ANSIBLE_USER_PASS }}" - pc_root_pass: "{{ infisical_secrets.secrets.ANSIBLE_BECOME_ROOT_PASS }}" - - - name: "[precheck] Result" + - name: "[pre] PASSED" ansible.builtin.debug: - msg: "Precheck OK — Infisical reachable, secrets valid, {{ target_host }} reachable. Proceeding." + msg: "Precheck OK — Infisical + secrets valid, {{ target | default('racknerd1') }} reachable as root." # --------------------------------------------------------------------------- -# PLAY 2 — BOOTSTRAP the target (only runs if PLAY 1 succeeded) +# PLAY 2 — BOOTSTRAP (target, as root) # --------------------------------------------------------------------------- - name: "BOOTSTRAP target host" hosts: "{{ target | default('racknerd1') }}" @@ -112,28 +116,44 @@ become: true vars: managed_user: ansible + new_hostname: "{{ new_hostname | default('racknerd1') }}" ansible_user: "{{ connect_user | default('root') }}" ansible_password: "{{ hostvars['localhost'].pc_root_pass }}" ansible_become_password: "{{ hostvars['localhost'].pc_root_pass }}" ansible_ssh_common_args: "-o StrictHostKeyChecking=accept-new" managed_ssh_pubkey: "{{ hostvars['localhost'].pc_ssh_pubkey }}" managed_user_password: "{{ hostvars['localhost'].pc_user_pass }}" - cleanup_paths: [] # SAFETY: only removes paths explicitly listed. Empty = nothing. + cleanup_paths: [] tasks: - - name: "[1] Connection confirmed as root" + - name: "[1] confirm root" ansible.builtin.command: id register: idc changed_when: false failed_when: "'uid=0' not in idc.stdout" - - name: "[2] Cleanup — remove ONLY explicitly-listed paths" + - name: "[2] cleanup — remove ONLY explicitly listed paths" ansible.builtin.file: path: "{{ item }}" state: absent loop: "{{ cleanup_paths | default([]) }}" when: cleanup_paths | length > 0 - - name: "[3] Create '{{ managed_user }}' user" + - name: "[3] record old hostname" + ansible.builtin.set_fact: + old_hostname: "{{ ansible_hostname }}" + + - name: "[3] set hostname -> {{ new_hostname }}" + ansible.builtin.hostname: + name: "{{ new_hostname }}" + + - name: "[3] ensure /etc/hosts maps 127.0.1.1 to new hostname" + ansible.builtin.lineinfile: + path: /etc/hosts + regexp: '^127\.0\.1\.1' + line: "127.0.1.1 {{ new_hostname }}" + state: present + + - name: "[4] create '{{ managed_user }}' user" ansible.builtin.user: name: "{{ managed_user }}" password: "{{ managed_user_password | password_hash('sha512') }}" @@ -143,7 +163,7 @@ create_home: true no_log: true - - name: "[4] Ensure ~/.ssh for {{ managed_user }}" + - name: "[5] ~/.ssh for {{ managed_user }}" ansible.builtin.file: path: "/home/{{ managed_user }}/.ssh" state: directory @@ -151,25 +171,125 @@ group: "{{ managed_user }}" mode: "0700" - - name: "[4] Install public key to {{ managed_user }} authorized_keys" + - name: "[5] install public key" ansible.posix.authorized_key: user: "{{ managed_user }}" key: "{{ managed_ssh_pubkey }}" state: present exclusive: false - - name: "[3] sudoers entry for {{ managed_user }}" + - name: "[5] sudoers entry" ansible.builtin.copy: dest: "/etc/sudoers.d/{{ managed_user }}" content: "{{ managed_user }} ALL=(ALL) ALL\n" mode: "0440" validate: "visudo -cf %s" - - name: "[5] Verify user + key" - ansible.builtin.command: "id {{ managed_user }}" - register: uv + # ---------- SAFETY CHECK: ansible key login must work ---------- + - name: "[6] SAFETY — verify {{ managed_user }} can log in via SSH key (from the runner)" + ansible.builtin.command: >- + ssh -i /tmp/bootstrap_deploy_key + -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 + {{ managed_user }}@{{ ansible_host | default(inventory_hostname) }} id + delegate_to: localhost + become: false + register: keytest changed_when: false + failed_when: false - - name: "[5] Bootstrap complete" + - name: "[6] ABORT if key login failed (root auth left UNTOUCHED)" + ansible.builtin.assert: + that: "keytest.rc == 0 and 'uid=' in keytest.stdout" + fail_msg: >- + ansible-user SSH key login FAILED (rc={{ keytest.rc }}). Aborting BEFORE + touching root login so we can't lock ourselves out. Fix the key/user, re-run. + success_msg: "ansible-user key login verified — safe to harden root login." + + # ---------- HARDEN: disable root password auth (key still works) ---------- + - name: "[7] set PermitRootLogin prohibit-password" + ansible.builtin.lineinfile: + path: /etc/ssh/sshd_config + regexp: '^\s*#?\s*PermitRootLogin' + line: "PermitRootLogin prohibit-password" + state: present + validate: "sshd -t -f %s" + register: sshd_change + + - name: "[7] restart sshd" + ansible.builtin.service: + name: "{{ 'ssh' if ansible_os_family == 'Debian' else 'sshd' }}" + state: restarted + when: sshd_change is changed + + - name: "[7] verify sshd is up" + ansible.builtin.service_facts: + + - name: "[7] assert sshd running" + ansible.builtin.assert: + that: >- + (ansible_facts.services['ssh.service'] is defined and ansible_facts.services['ssh.service'].state == 'running') + or (ansible_facts.services['sshd.service'] is defined and ansible_facts.services['sshd.service'].state == 'running') + fail_msg: "sshd did not come back up after restart — investigate immediately." + success_msg: "sshd is running after hardening." + + # ---------- LOG: write run record + SHA256 to /var/log/bootstrap/ ---------- + - name: "[8] ensure /var/log/bootstrap exists" + ansible.builtin.file: + path: /var/log/bootstrap + state: directory + owner: root + group: root + mode: "0755" + + - name: "[8] timestamp for the log file" + ansible.builtin.set_fact: + ts: "{{ lookup('pipe','date -u +%Y-%m-%d-%H%M%S') }}" + ts_iso: "{{ lookup('pipe','date -u +%Y-%m-%dT%H:%M:%SZ') }}" + + - name: "[8] write bootstrap log" + ansible.builtin.copy: + dest: "/var/log/bootstrap/bootstrap-{{ ts }}.log" + owner: root + group: root + mode: "0644" + content: | + Bootstrap record + ================= + Host (new): {{ new_hostname }} + Host (previous): {{ old_hostname }} + Completed (UTC): {{ ts_iso }} + Managed user: {{ managed_user }} (groups: {{ 'sudo' if ansible_os_family == 'Debian' else 'wheel' }}) + SSH key install: yes + Key login test: PASS (rc={{ keytest.rc }}) + Root login: PermitRootLogin prohibit-password (changed: {{ sshd_change is changed }}) + sshd validated: yes (sshd -t) | sshd restarted: {{ sshd_change is changed }} + Cleanup paths: {{ cleanup_paths | default([]) }} + ---------------------------------------------------------------------- + Run by Semaphore. This file is root-owned; integrity hash in the .sha256 sibling. + register: logwrite + + - name: "[8] write SHA256 of the log" + ansible.builtin.shell: >- + sha256sum "/var/log/bootstrap/bootstrap-{{ ts }}.log" + | awk '{print $1}' > "/var/log/bootstrap/bootstrap-{{ ts }}.log.sha256" + args: + creates: "/var/log/bootstrap/bootstrap-{{ ts }}.log.sha256" + + - name: "[done] Bootstrap complete" ansible.builtin.debug: - msg: "{{ inventory_hostname }} ready — {{ uv.stdout }}. Deploys can now run as '{{ managed_user }}'." + msg: + - "{{ old_hostname }} -> {{ new_hostname }} bootstrapped." + - "ansible user ready (key login PASS), root password auth disabled." + - "Log: /var/log/bootstrap/bootstrap-{{ ts }}.log (+ .sha256)" + +# --------------------------------------------------------------------------- +# CLEANUP — remove the temp deploy key from the runner +# --------------------------------------------------------------------------- +- name: "Cleanup runner temp key" + hosts: localhost + gather_facts: false + tasks: + - name: "remove temp deploy key from runner" + ansible.builtin.file: + path: /tmp/bootstrap_deploy_key + state: absent