--- # ============================================================================ # bootstrap.yml — prepare a fresh host for managed deployments # ============================================================================ # 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 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 # # Usage: extra vars: target=racknerd1 connect_user=root new_hostname=racknerd1 # optional: cleanup_paths='["/opt/old"]' # ============================================================================ # --------------------------------------------------------------------------- # PLAY 1 — PRECHECK (runner/localhost). No target changes. # --------------------------------------------------------------------------- - name: "PRECHECK — Infisical + connectivity (no changes)" hosts: localhost gather_facts: false tasks: - 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: - infisical_client_id | length > 0 - infisical_client_secret | length > 0 - infisical_project_id | length > 0 fail_msg: "Missing Infisical creds in the Variable Group (INFISICAL_CLIENT_ID/_SECRET/_PROJECT_ID)." - name: "[pre] Log in to Infisical" infisical.vault.login: url: "{{ infisical_url }}" auth_method: universal_auth universal_auth_client_id: "{{ infisical_client_id }}" universal_auth_client_secret: "{{ infisical_client_secret }}" register: infisical_login - name: "[pre] Read secrets" infisical.vault.read_secrets: login_data: "{{ infisical_login.login_data }}" project_id: "{{ infisical_project_id }}" env_slug: "{{ infisical_env_slug }}" path: "/" as_dict: true register: infisical_secrets no_log: true - 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.SSH_PRIVATE_KEY is defined - infisical_secrets.secrets.ANSIBLE_USER_PASS | length > 0 - infisical_secrets.secrets.ANSIBLE_BECOME_ROOT_PASS | length > 0 fail_msg: "Required secrets missing/invalid in env '{{ infisical_env_slug }}'." - 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] PASSED" ansible.builtin.debug: msg: "Precheck OK — Infisical + secrets valid, {{ target | default('racknerd1') }} reachable as root." # --------------------------------------------------------------------------- # PLAY 2 — BOOTSTRAP (target, as root) # --------------------------------------------------------------------------- - name: "BOOTSTRAP target host" hosts: "{{ target | default('racknerd1') }}" gather_facts: true become: true vars: managed_user: ansible new_hostname: "{{ new_hostname | default('racknerd1') }}" ansible_user: "{{ connect_user | default('root') }}" # Connect as root using the deploy SSH key (written to the runner temp file in precheck). # racknerd-class hosts use PermitRootLogin without-password, so password auth won't work; # the deploy key must already be in root's authorized_keys (one-time seeding). ansible_ssh_private_key_file: "/tmp/bootstrap_deploy_key" ansible_ssh_common_args: "-o StrictHostKeyChecking=accept-new -o IdentitiesOnly=yes" managed_ssh_pubkey: "{{ hostvars['localhost'].pc_ssh_pubkey }}" managed_user_password: "{{ hostvars['localhost'].pc_user_pass }}" cleanup_paths: [] tasks: - 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" ansible.builtin.file: path: "{{ item }}" state: absent loop: "{{ cleanup_paths | default([]) }}" when: cleanup_paths | length > 0 - 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') }}" shell: /bin/bash groups: "{{ 'sudo' if ansible_os_family == 'Debian' else 'wheel' }}" append: true create_home: true no_log: true - name: "[5] ~/.ssh for {{ managed_user }}" ansible.builtin.file: path: "/home/{{ managed_user }}/.ssh" state: directory owner: "{{ managed_user }}" group: "{{ managed_user }}" mode: "0700" - name: "[5] install public key" ansible.posix.authorized_key: user: "{{ managed_user }}" key: "{{ managed_ssh_pubkey }}" state: present exclusive: false - 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: "[5b] add {{ managed_user }} to docker group (if docker present)" ansible.builtin.user: name: "{{ managed_user }}" groups: docker append: true when: "'/usr/bin/docker' is exists or 'docker' in ansible_facts.packages | default({})" failed_when: false # ---------- 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: "[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: - "{{ 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