diff --git a/inventory/hosts.yml b/inventory/hosts.yml index 9664a2a..f0214ff 100644 --- a/inventory/hosts.yml +++ b/inventory/hosts.yml @@ -1,18 +1,26 @@ # Semaphore deployment inventory -# Hosts reached over the NetBird mesh (Semaphore LXC .99 is on the mesh). +# Reached over the NetBird mesh (Semaphore LXC .99 is on the mesh). +# +# Two connection styles: +# - bootstrap.yml connects as root (ansible_user=root) using ANSIBLE_BECOME_ROOT_PASS +# - post-bootstrap playbooks connect as the `ansible` user via the deploy SSH key +# +# Semaphore injects the SSH private key (SSH_PRIVATE_KEY) and passwords from Infisical. all: + vars: + ansible_python_interpreter: /usr/bin/python3 children: vps: hosts: racknerd1: - ansible_host: 100.96.198.46 # NetBird mesh IP (public: 172.245.72.108) - ansible_user: root + ansible_host: 100.96.198.46 # mesh IP (public: 172.245.72.108) racknerd2: ansible_host: 100.96.217.250 - ansible_user: root racknerd3: ansible_host: 67.215.255.203 - ansible_user: root + vars: + # default connection user AFTER bootstrap; override to root for bootstrap.yml + ansible_user: "{{ connect_user | default('ansible') }}" dashboards: hosts: racknerd1: diff --git a/playbooks/bootstrap.yml b/playbooks/bootstrap.yml new file mode 100644 index 0000000..bbcbe82 --- /dev/null +++ b/playbooks/bootstrap.yml @@ -0,0 +1,126 @@ +--- +# ============================================================================ +# bootstrap.yml — prepare a fresh VPS/host for managed deployments +# ============================================================================ +# Run this FIRST against any new host. Sequence: +# 1. Connection test (as root) +# 2. Clean up old-deployment clutter (explicit allowlist only — safe by default) +# 3. Create the `ansible` user with SSH key + password +# 4. Install the public key to /home/ansible/.ssh/authorized_keys +# 5. Verify the ansible user can log in and sudo +# +# Secrets come from Infisical (injected by Semaphore as env vars): +# SSH_PUBLIC_KEY - OpenSSH public key for the ansible user +# ANSIBLE_USER_PASS - password for the ansible user +# ANSIBLE_BECOME_ROOT_PASS - current root/sudo password on the target +# +# Usage (Semaphore Task Template): +# playbook: playbooks/bootstrap.yml +# inventory: inventory/hosts.yml +# limit: racknerd1 +# Extra vars (optional): cleanup_paths='["/opt/old-thing","/opt/nuclio"]' +# ============================================================================ + +- name: Bootstrap managed host + hosts: "{{ target | default('racknerd1') }}" + become: true + gather_facts: true + vars: + managed_user: ansible + # Secrets pulled from Infisical env injection: + ansible_user_password: "{{ lookup('env', 'ANSIBLE_USER_PASS') }}" + managed_ssh_pubkey: "{{ lookup('env', 'SSH_PUBLIC_KEY') }}" + # SAFETY: cleanup removes ONLY paths explicitly listed here. Empty = deletes nothing. + # Override per-host from Semaphore (survey/extra-vars). NEVER a blind sweep. + cleanup_paths: [] + + tasks: + # ---------- STEP 1: connection test ---------- + - name: "[1] Connection test — confirm we can reach the host and run as root" + ansible.builtin.command: id + register: idcheck + changed_when: false + + - name: "[1] Report connection" + ansible.builtin.debug: + msg: "Connected to {{ inventory_hostname }} ({{ ansible_host | default('n/a') }}) as {{ idcheck.stdout }}" + + - name: "[1] Assert we are root (become worked)" + ansible.builtin.assert: + that: "'uid=0' in idcheck.stdout" + fail_msg: "Not running as root — check become password (ANSIBLE_BECOME_ROOT_PASS)." + + # ---------- STEP 2: cleanup (explicit allowlist only) ---------- + - name: "[2] Show cleanup plan" + ansible.builtin.debug: + msg: >- + Cleanup paths to remove: {{ cleanup_paths | default([]) }} + {{ '(none — skipping)' if (cleanup_paths | length == 0) else '' }} + + - name: "[2] Remove ONLY explicitly-listed old deployment paths" + ansible.builtin.file: + path: "{{ item }}" + state: absent + loop: "{{ cleanup_paths | default([]) }}" + when: cleanup_paths | length > 0 + + # ---------- STEP 3 + 4: create ansible user + install key ---------- + - name: "[3] Validate required secrets are present" + ansible.builtin.assert: + that: + - managed_ssh_pubkey | length > 0 + - managed_ssh_pubkey.startswith('ssh-') + - ansible_user_password | length > 0 + fail_msg: >- + Missing/invalid secrets. SSH_PUBLIC_KEY must be OpenSSH format (ssh-...) + and ANSIBLE_USER_PASS must be set (from Infisical). + + - name: "[3] Create the '{{ managed_user }}' user" + ansible.builtin.user: + name: "{{ managed_user }}" + password: "{{ ansible_user_password | password_hash('sha512') }}" + shell: /bin/bash + groups: "{{ 'sudo' if ansible_os_family == 'Debian' else 'wheel' }}" + append: true + create_home: true + state: present + + - name: "[4] Ensure ~/.ssh exists for {{ managed_user }}" + ansible.builtin.file: + path: "/home/{{ managed_user }}/.ssh" + state: directory + owner: "{{ managed_user }}" + group: "{{ managed_user }}" + mode: "0700" + + - name: "[4] Install public key to {{ managed_user }} authorized_keys" + ansible.posix.authorized_key: + user: "{{ managed_user }}" + key: "{{ managed_ssh_pubkey }}" + state: present + exclusive: false + + - name: "[3] Allow passwordless-or-password sudo for {{ managed_user }}" + ansible.builtin.copy: + dest: "/etc/sudoers.d/{{ managed_user }}" + content: "{{ managed_user }} ALL=(ALL) ALL\n" + mode: "0440" + validate: "visudo -cf %s" + + # ---------- STEP 5: verify ---------- + - name: "[5] Verify {{ managed_user }} exists and is in sudo group" + ansible.builtin.command: "id {{ managed_user }}" + register: userverify + changed_when: false + + - name: "[5] Verify authorized_keys installed" + ansible.builtin.stat: + path: "/home/{{ managed_user }}/.ssh/authorized_keys" + register: akfile + + - name: "[5] Bootstrap result" + ansible.builtin.debug: + msg: + - "User: {{ userverify.stdout }}" + - "authorized_keys present: {{ akfile.stat.exists }}" + - "Host {{ inventory_hostname }} is ready. Deploys can now run as '{{ managed_user }}'." diff --git a/playbooks/deploy-homarr.yml b/playbooks/deploy-homarr.yml index d1e2738..6275777 100644 --- a/playbooks/deploy-homarr.yml +++ b/playbooks/deploy-homarr.yml @@ -1,45 +1,61 @@ --- -# Deploy Homarr dashboard via Docker Compose. -# Usage (Semaphore Task Template): playbook = playbooks/deploy-homarr.yml, inventory = inventory/hosts.yml, limit = racknerd1 +# ============================================================================ +# deploy-homarr.yml — deploy Homarr dashboard as the `ansible` user +# ============================================================================ +# Prereq: host has been bootstrapped (see bootstrap.yml) so the `ansible` +# user exists with the SSH key installed. This playbook connects AS ansible, +# creates ~/docker/homarr/, drops the compose file, and brings it up. # -# REQUIRED extra var (set in Semaphore -> Variable Groups / Survey, NOT committed): -# homarr_secret_encryption_key : 64-char hex (openssl rand -hex 32). Homarr v1 refuses to start without it. -- name: Deploy Homarr - hosts: racknerd1 - become: true +# Secret from Infisical (Semaphore env injection): +# HOMARR_SECRET_ENCRYPTION_KEY - 64-char hex (openssl rand -hex 32) +# +# Usage (Semaphore Task Template): +# playbook: playbooks/deploy-homarr.yml +# inventory: inventory/hosts.yml +# limit: racknerd1 +# ============================================================================ + +- name: Deploy Homarr (as ansible user) + hosts: "{{ target | default('racknerd1') }}" + become: true # sudo for docker + gather_facts: true vars: - homarr_dir: /opt/homarr + deploy_user: ansible + homarr_dir: "/home/{{ deploy_user }}/docker/homarr" homarr_port: 7575 homarr_image: ghcr.io/homarr-labs/homarr:latest - # homarr_secret_encryption_key: "" - tasks: - - name: Ensure Docker is present - ansible.builtin.command: docker version - register: docker_check - changed_when: false - failed_when: docker_check.rc != 0 + homarr_encryption_key: "{{ lookup('env', 'HOMARR_SECRET_ENCRYPTION_KEY') }}" - - name: Fail early if encryption key not provided + tasks: + - name: Validate encryption key present (64-hex) ansible.builtin.assert: that: - - homarr_secret_encryption_key is defined - - homarr_secret_encryption_key | length == 64 - fail_msg: >- - homarr_secret_encryption_key must be a 64-char hex string. - Generate with `openssl rand -hex 32` and set it as a Semaphore variable. + - homarr_encryption_key | length == 64 + fail_msg: "HOMARR_SECRET_ENCRYPTION_KEY must be 64 hex chars (from Infisical)." - - name: Create Homarr directories + - name: Confirm Docker is available + ansible.builtin.command: docker version + register: dv + changed_when: false + failed_when: dv.rc != 0 + + - name: Create ~/docker/homarr directory tree (owned by {{ deploy_user }}) ansible.builtin.file: path: "{{ item }}" state: directory + owner: "{{ deploy_user }}" + group: "{{ deploy_user }}" mode: "0755" loop: + - "/home/{{ deploy_user }}/docker" - "{{ homarr_dir }}" - "{{ homarr_dir }}/appdata" - name: Write docker-compose.yml ansible.builtin.copy: dest: "{{ homarr_dir }}/docker-compose.yml" + owner: "{{ deploy_user }}" + group: "{{ deploy_user }}" mode: "0644" content: | services: @@ -51,7 +67,7 @@ - /var/run/docker.sock:/var/run/docker.sock - {{ homarr_dir }}/appdata:/appdata environment: - - SECRET_ENCRYPTION_KEY={{ homarr_secret_encryption_key }} + - SECRET_ENCRYPTION_KEY={{ homarr_encryption_key }} ports: - "{{ homarr_port }}:7575" @@ -60,17 +76,17 @@ project_src: "{{ homarr_dir }}" pull: always state: present - register: compose_result + register: compose_up - - name: Wait for Homarr to answer + - name: Wait for Homarr to respond ansible.builtin.uri: url: "http://127.0.0.1:{{ homarr_port }}/" status_code: [200, 302, 307] register: health - retries: 12 + retries: 15 delay: 5 - until: health.status is defined + until: health.status is defined and health.status in [200, 302, 307] - - name: Report + - name: Result ansible.builtin.debug: - msg: "Homarr is up on {{ inventory_hostname }}:{{ homarr_port }} (status {{ health.status | default('n/a') }})" + msg: "Homarr up on {{ inventory_hostname }}:{{ homarr_port }} (HTTP {{ health.status }})"