diff --git a/.gitignore b/.gitignore index 354fe3f..ebe56ce 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,5 @@ keys/* !keys/*.* # Ansible Vault secrets — never commit plain-text secrets -ansible/group_vars/vault.yml +ansible/group_vars/all/vault.yml diff --git a/ansible.cfg b/ansible.cfg new file mode 100644 index 0000000..44ef0e0 --- /dev/null +++ b/ansible.cfg @@ -0,0 +1,2 @@ +[defaults] +interpreter_python = auto_silent diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all/vars.yml similarity index 83% rename from ansible/group_vars/all.yml rename to ansible/group_vars/all/vars.yml index caa793a..c9ce7b8 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all/vars.yml @@ -26,7 +26,7 @@ traefik_version: v3.3 traefik_data_dir: /opt/traefik # htpasswd-formatted user list for the dashboard. # Generate with: echo $(htpasswd -nB admin) | sed -e 's/\$/\$\$/g' -# Store the actual value in ansible/group_vars/vault.yml (Ansible Vault). +# Store the actual value in ansible/group_vars/all/vault.yml (Ansible Vault). traefik_dashboard_users: "" # Gitea @@ -36,7 +36,7 @@ gitea_data_dir: /opt/gitea # Set to false for the very first deploy so the admin account can be created, # then flip to true and redeploy to close public registration. gitea_disable_registration: true -# Secrets — store values in ansible/group_vars/vault.yml (Ansible Vault) +# Secrets — store values in ansible/group_vars/all/vault.yml (Ansible Vault) # gitea_db_password: "" # gitea_secret_key: "" # generate: openssl rand -hex 32 # gitea_internal_token: "" # generate: openssl rand -hex 32 @@ -46,7 +46,7 @@ keycloak_version: "26.2" keycloak_db_version: "16" sso_data_dir: /opt/sso keycloak_admin_user: admin -# Secrets — store values in ansible/group_vars/vault.yml (Ansible Vault) +# Secrets — store values in ansible/group_vars/all/vault.yml (Ansible Vault) # keycloak_db_password: "" # keycloak_admin_password: "" @@ -55,12 +55,12 @@ nextcloud_version: "31-apache" # apache variant includes a working web server nextcloud_db_version: "16" nextcloud_data_dir: /opt/nextcloud nextcloud_admin_user: admin -# Secrets — store values in ansible/group_vars/vault.yml (Ansible Vault) +# Secrets — store values in ansible/group_vars/all/vault.yml (Ansible Vault) # nextcloud_db_password: "" # nextcloud_admin_password: "" # Mail (Roundcube webmail client) -roundcube_version: "1.6-apache" +roundcube_version: "1.6.x-apache" roundcube_db_version: "16" mail_data_dir: /opt/mail # IMAP/SMTP — set to the mail server Roundcube should connect to. @@ -70,7 +70,7 @@ roundcube_imap_host: "" roundcube_imap_port: "993" roundcube_smtp_host: "" roundcube_smtp_port: "587" -# Secrets — store values in ansible/group_vars/vault.yml (Ansible Vault) +# Secrets — store values in ansible/group_vars/all/vault.yml (Ansible Vault) # roundcube_db_password: "" # roundcube_des_key: "" # generate: openssl rand -hex 12 (must be exactly 24 chars) @@ -79,7 +79,7 @@ registry_data_dir: /opt/registry # registry_htpasswd — full htpasswd file content, store in vault.yml # Generate with: docker run --entrypoint htpasswd httpd:2 -Bbn # Multiple users: run the command once per user and concatenate the lines. -# Secrets — store values in ansible/group_vars/vault.yml (Ansible Vault) +# Secrets — store values in ansible/group_vars/all/vault.yml (Ansible Vault) # registry_htpasswd: "" # k8s (placeholder) diff --git a/ansible/inventory.ini b/ansible/inventory.ini index d4e10f8..a71644e 100644 --- a/ansible/inventory.ini +++ b/ansible/inventory.ini @@ -1,2 +1,2 @@ [cloud] -cloud.ladkau.de ansible_user=deploy ansible_ssh_private_key_file=../keys/notroot_cloud_ladkau_de +cloud.ladkau.de ansible_host=217.154.207.148 ansible_user=deploy ansible_ssh_private_key_file=keys/notroot_cloud_ladkau_de diff --git a/ansible/roles/nextcloud/templates/docker-compose.yml.j2 b/ansible/roles/nextcloud/templates/docker-compose.yml.j2 index c3cc257..5be79e6 100644 --- a/ansible/roles/nextcloud/templates/docker-compose.yml.j2 +++ b/ansible/roles/nextcloud/templates/docker-compose.yml.j2 @@ -28,10 +28,11 @@ services: networks: - nextcloud_internal healthcheck: - test: ["CMD-SHELL", "redis-cli ping | grep PONG"] + test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 5 + start_period: 10s nextcloud: image: nextcloud:{{ nextcloud_version }} diff --git a/docs/runbook.md b/docs/runbook.md index 51f4ba4..7224c92 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -32,12 +32,24 @@ ssh-keygen -t ed25519 -f keys/notroot_cloud_ladkau_de -C "deploy@cloud.ladkau.de ``` Upload the root public key to the server via the Strato control panel (or paste -it during the initial OS install) so that `root@217.154.207.148` is accessible -before running anything. +it during the initial OS install) so that root SSH access is available before +running anything. The server IP is defined in `ansible/inventory.ini` +(`ansible_host`). ## Steps -### 1. Install Ansible collections +### 1. Set the server IP + +Open `ansible/inventory.ini` and set `ansible_host` to the server's public IP: + +```ini +cloud.ladkau.de ansible_host= ansible_user=deploy ansible_ssh_private_key_file=keys/notroot_cloud_ladkau_de +``` + +This is the only place the IP needs to be set — the bootstrap script and all +Ansible roles read it from here. + +### 2. Install Ansible collections From the repo root: @@ -50,28 +62,28 @@ Required collections: - `ansible.posix` — authorized_key module - `community.docker` — docker_network, docker_compose_v2 modules -### 2. Configure DNS +### 3. Configure DNS -Ensure the following DNS A records point to `217.154.207.148` before running -the playbook. Traefik requests Let's Encrypt certificates on first start and -DNS must resolve at that point. +Ensure the following DNS A records all point to the server IP (`ansible_host` +in `ansible/inventory.ini`) before running the playbook. Traefik requests +Let's Encrypt certificates on first start and DNS must resolve at that point. | Domain | Record | |---------------------|--------| -| cloud.ladkau.de | A → 217.154.207.148 | -| gitea.ladkau.de | A → 217.154.207.148 | -| nextcloud.ladkau.de | A → 217.154.207.148 | -| sso.ladkau.de | A → 217.154.207.148 | -| mail.ladkau.de | A → 217.154.207.148 | -| cr.ladkau.de | A → 217.154.207.148 | -| k8s.ladkau.de | A → 217.154.207.148 | +| cloud.ladkau.de | A → server IP | +| gitea.ladkau.de | A → server IP | +| nextcloud.ladkau.de | A → server IP | +| sso.ladkau.de | A → server IP | +| mail.ladkau.de | A → server IP | +| cr.ladkau.de | A → server IP | +| k8s.ladkau.de | A → server IP | -### 3. Create the vault and populate secrets +### 4. Create the vault and populate secrets -Create `ansible/group_vars/vault.yml` (gitignored) and encrypt it with Ansible Vault: +Create `ansible/group_vars/all/vault.yml` (gitignored) and encrypt it with Ansible Vault: ```bash -ansible-vault create ansible/group_vars/vault.yml +ansible-vault create ansible/group_vars/all/vault.yml ``` Populate all required secrets: @@ -114,19 +126,34 @@ bash scripts/check-vault.sh To edit the vault later: ```bash -ansible-vault edit ansible/group_vars/vault.yml +ansible-vault edit ansible/group_vars/all/vault.yml ``` -### 4. Bootstrap the deploy user - -Copies the bootstrap script to the server, runs it as root, and verifies the -deploy user can log in. Also runs `check-vault.sh` as a preflight check. +### 5. Bootstrap the deploy user ```bash bash scripts/run-bootstrap.sh ``` -### 5. Run the Ansible master playbook +The script runs the following steps in order: + +**Preflight checks (local)** +1. Verifies all four key files exist under `keys/` (both root and deploy key pairs) +2. Verifies `scripts/bootstrap-deploy-user.sh` exists +3. Sets `chmod 600` on the private key files (SSH refuses keys with open permissions) +4. Runs `scripts/check-vault.sh` — decrypts the vault and confirms all 11 required + secrets are present and non-empty + +**Remote actions** +5. Opens a test SSH connection as `root` to confirm the root key works +6. Copies `bootstrap-deploy-user.sh` to `/root/` on the server via `scp` +7. Executes it as root — creates the `deploy` user, grants passwordless sudo, + and installs `keys/notroot_cloud_ladkau_de.pub` as the only authorized key +8. Opens a test SSH connection as `deploy` to confirm the new user can log in + +If any step fails the script exits immediately with a descriptive error message. + +### 6. Run the Ansible master playbook ```bash ansible-playbook -i ansible/inventory.ini ansible/site.yml --ask-vault-pass @@ -160,7 +187,7 @@ ansible-playbook -i ansible/inventory.ini ansible/site.yml --tags --ask-v it to `false` in `vault.yml` so the setup wizard can create the admin account: ```bash -ansible-vault edit ansible/group_vars/vault.yml +ansible-vault edit ansible/group_vars/all/vault.yml # add: gitea_disable_registration: false ansible-playbook -i ansible/inventory.ini ansible/site.yml --tags gitea --ask-vault-pass ``` diff --git a/scripts/bootstrap-deploy-user.sh b/scripts/bootstrap-deploy-user.sh index 20ff0b2..14dd1fb 100755 --- a/scripts/bootstrap-deploy-user.sh +++ b/scripts/bootstrap-deploy-user.sh @@ -22,6 +22,10 @@ fi echo "==> Adding '${DEPLOY_USER}' to sudo group" usermod -aG sudo "${DEPLOY_USER}" +echo "==> Granting '${DEPLOY_USER}' passwordless sudo" +echo "${DEPLOY_USER} ALL=(ALL) NOPASSWD: ALL" > "/etc/sudoers.d/${DEPLOY_USER}" +chmod 440 "/etc/sudoers.d/${DEPLOY_USER}" + echo "==> Installing SSH authorized key" SSH_DIR="/home/${DEPLOY_USER}/.ssh" AUTH_KEYS="${SSH_DIR}/authorized_keys" diff --git a/scripts/check-vault.sh b/scripts/check-vault.sh index 411f5e2..e8b24d6 100755 --- a/scripts/check-vault.sh +++ b/scripts/check-vault.sh @@ -7,7 +7,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -VAULT_FILE="${REPO_ROOT}/ansible/group_vars/vault.yml" +VAULT_FILE="${REPO_ROOT}/ansible/group_vars/all/vault.yml" REQUIRED_KEYS=( traefik_dashboard_users diff --git a/scripts/run-bootstrap.sh b/scripts/run-bootstrap.sh index d556fce..eeff8a9 100755 --- a/scripts/run-bootstrap.sh +++ b/scripts/run-bootstrap.sh @@ -9,7 +9,12 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -SERVER="217.154.207.148" +SERVER=$(grep -oP '(?<=ansible_host=)\S+' "${REPO_ROOT}/ansible/inventory.ini" | head -1) +if [ -z "${SERVER}" ]; then + echo "ERROR: could not find ansible_host in ansible/inventory.ini" >&2 + exit 1 +fi + ROOT_KEY="${REPO_ROOT}/keys/root_cloud_ladkau_de" DEPLOY_KEY="${REPO_ROOT}/keys/notroot_cloud_ladkau_de" DEPLOY_PUBKEY="${REPO_ROOT}/keys/notroot_cloud_ladkau_de.pub" @@ -37,6 +42,9 @@ if [ ! -f "${BOOTSTRAP_SCRIPT}" ]; then exit 1 fi +# SSH private keys must not be group/world readable +chmod 600 "${ROOT_KEY}" "${DEPLOY_KEY}" + # --- Vault check --- bash "${SCRIPT_DIR}/check-vault.sh" @@ -46,10 +54,18 @@ SSH_OPTS="-o StrictHostKeyChecking=accept-new -o ConnectTimeout=10" # --- Verify root access --- echo "==> Verifying root SSH access to ${SERVER}" -if ! ssh ${SSH_OPTS} -i "${ROOT_KEY}" "root@${SERVER}" "echo ok" &>/dev/null; then +if ! ssh ${SSH_OPTS} -i "${ROOT_KEY}" "root@${SERVER}" "echo ok" 2>/tmp/ssh_root_err; then echo "ERROR: cannot connect as root to ${SERVER}" >&2 + echo " SSH error: $(cat /tmp/ssh_root_err)" >&2 + echo "" >&2 + echo "Possible causes:" >&2 + echo " - Root public key not uploaded to the server (Strato control panel)" >&2 + echo " - Wrong key file: ${ROOT_KEY}" >&2 + echo " - Server not yet reachable (still booting?)" >&2 + rm -f /tmp/ssh_root_err exit 1 fi +rm -f /tmp/ssh_root_err # --- Copy and run the bootstrap script ---