commit 9860735f8243404da8dc6b985e9a13594609a885 Author: Bernd Worsch Date: Sat Sep 13 20:26:11 2025 +0200 feat: initial import of RailianceHosts starter diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52055b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Terraform +.terraform/ +terraform.tfstate* +*.tfvars + +# Ansible +*.retry + +# Secrets temp files +*.age +*.bak +*.tmp +*.swp + +# Python bytecode +__pycache__/ +*.pyc diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..375a531 --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,7 @@ +# SOPS encryption policy: encrypt files matching *.sops.yaml +creation_rules: + - path_regex: '.*\.sops\.ya?ml$' + encrypted_regex: '^(data|secrets|ops)$|(_secret|_password|_key)$' + # Replace with your age public key string (from keys/age.pub) + age: + - 'age1replace_with_your_public_key_here' diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cf4b7de --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +SHELL := /usr/bin/env bash + +# Decrypt Hetzner token at runtime (requires your SOPS_AGE_KEY loaded locally) +HCLOUD_TOKEN := $(shell sops -d --extract '["ops"]["hcloud_token"]' inventory/group_vars/secrets.sops.yaml 2>/dev/null) + +.PHONY: all apply tf-init tf-apply ansible destroy fmt check sops-edit sops-rotate + +all: apply + +fmt: + terraform -chdir=terraform/hetzner fmt -recursive || true + +tf-init: + terraform -chdir=terraform/hetzner init + +tf-apply: tf-init + @if [ -z "$(HCLOUD_TOKEN)" ]; then echo "HCLOUD_TOKEN empty. Did you load your SOPS key and encrypt ops.hcloud_token?"; exit 1; fi + @export HCLOUD_TOKEN=$(HCLOUD_TOKEN); \ + terraform -chdir=terraform/hetzner apply -auto-approve + +ansible: + cd ansible && ansible-playbook playbooks/bootstrap.yaml -u admin + +apply: fmt tf-apply ansible + +destroy: + @if [ -z "$(HCLOUD_TOKEN)" ]; then echo "HCLOUD_TOKEN empty. Did you load your SOPS key?"; exit 1; fi + @export HCLOUD_TOKEN=$(HCLOUD_TOKEN); \ + terraform -chdir=terraform/hetzner destroy -auto-approve + +sops-edit: + sops inventory/group_vars/secrets.sops.yaml + +check: + terraform -chdir=terraform/hetzner plan >/dev/null || true + cd ansible && ansible-inventory --list >/dev/null diff --git a/README.md b/README.md new file mode 100644 index 0000000..85dd3ce --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# RailianceHosts + +**Tagline:** Git-driven automation for secure, self-reliant servers. + +RailianceHosts is an open-source control repo that provisions and manages servers on Hetzner Cloud entirely from Git. It combines **Terraform** for lifecycle management, **cloud-init** for first-boot configuration, and **Ansible** for convergence. All secrets live in-repo encrypted with **SOPS** and are unlocked with your single **age** master key (which you keep in your password manager). The minimal server registry in `inventory/servers.yaml` is the source of truth. + +## Quickstart + +1. **Install**: terraform >= 1.7, ansible >= 2.16, age, sops. +2. **Generate master key (age)** and put the **private key** in your password manager. Save the **public key** to `keys/age.pub`. +3. **Create Hetzner Project** + API token and store it (encrypted) in `inventory/group_vars/secrets.sops.yaml` under `ops.hcloud_token`. +4. **Edit `inventory/servers.yaml`** to add your first host. +5. **Apply**: + ```bash + make apply + ``` + +See inline comments across the repo for details. Remember to **encrypt secrets** with SOPS before committing. diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000..1f0d659 --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,7 @@ +[defaults] +inventory = ./inventory_from_yaml.py +host_key_checking = False +retry_files_enabled = False +interpreter_python = auto +stdout_callback = yaml +forks = 20 diff --git a/ansible/inventory_from_yaml.py b/ansible/inventory_from_yaml.py new file mode 100644 index 0000000..1e3a2ac --- /dev/null +++ b/ansible/inventory_from_yaml.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +import json, yaml, subprocess, os, sys, pathlib + +def load_servers(): + with open(os.path.join(os.path.dirname(__file__), '..', 'inventory', 'servers.yaml')) as f: + data = yaml.safe_load(f) + servers = data.get('servers', []) + return servers + +def load_tf_outputs(): + # Try to read terraform outputs to attach IPs, if available. + try: + out = subprocess.check_output(['terraform', '-chdir=../terraform/hetzner', 'output', '-json'], stderr=subprocess.DEVNULL, text=True) + j = json.loads(out) + servers = j.get('servers', {}).get('value', {}) + return servers # {name: ip} + except Exception: + return {} + +def main(): + server_list = load_servers() + tf = load_tf_outputs() + hosts = {} + for s in server_list: + name = s['name'] + hosts[name] = { + "ansible_host": tf.get(name) or s.get('ip'), + "ansible_user": s.get('ssh_user', 'admin') + } + inv = {"all": {"hosts": hosts}} + print(json.dumps(inv)) + +if __name__ == "__main__": + main() diff --git a/ansible/playbooks/bootstrap.yaml b/ansible/playbooks/bootstrap.yaml new file mode 100644 index 0000000..983a024 --- /dev/null +++ b/ansible/playbooks/bootstrap.yaml @@ -0,0 +1,9 @@ +- hosts: all + become: true + vars_files: + - ../inventory/group_vars/all.yaml + - ../inventory/group_vars/secrets.sops.yaml + roles: + - role: base + - role: sops_agent + # - role: wireguard # enable if you configure WireGuard variables diff --git a/ansible/requirements.yaml b/ansible/requirements.yaml new file mode 100644 index 0000000..ed64a81 --- /dev/null +++ b/ansible/requirements.yaml @@ -0,0 +1,4 @@ +# ansible-galaxy collection install -r requirements.yaml +collections: + - name: community.general + - name: ansible.posix diff --git a/ansible/roles/base/tasks/main.yml b/ansible/roles/base/tasks/main.yml new file mode 100644 index 0000000..2b0ffcc --- /dev/null +++ b/ansible/roles/base/tasks/main.yml @@ -0,0 +1,45 @@ +--- +- name: Ensure base packages + ansible.builtin.package: + name: + - apt-transport-https + - ca-certificates + - curl + - git + - vim + - ufw + - python3 + - python3-venv + state: present + update_cache: true + +- name: Harden SSH + ansible.builtin.copy: + dest: /etc/ssh/sshd_config.d/10-hardening.conf + owner: root + group: root + mode: '0644' + content: | + PasswordAuthentication no + PermitRootLogin no + PubkeyAuthentication yes + +- name: Restart sshd + ansible.builtin.service: + name: ssh + state: restarted + +- name: Configure UFW + ansible.builtin.ufw: + state: enabled + policy: deny + direction: incoming + +- name: Allow SSH in UFW + ansible.builtin.ufw: + rule: allow + name: OpenSSH + +- name: Set timezone + community.general.timezone: + name: "{{ timezone | default('UTC') }}" diff --git a/ansible/roles/sops_agent/tasks/main.yml b/ansible/roles/sops_agent/tasks/main.yml new file mode 100644 index 0000000..7addb34 --- /dev/null +++ b/ansible/roles/sops_agent/tasks/main.yml @@ -0,0 +1,30 @@ +--- +- name: Install age + ansible.builtin.shell: | + set -euo pipefail + if ! command -v age >/dev/null; then + curl -fsSL https://github.com/FiloSottile/age/releases/download/v1.1.1/age-v1.1.1-linux-amd64.tar.gz | tar xz -C /usr/local/bin --strip-components=1 age/age + fi + args: + executable: /bin/bash + +- name: Install sops + ansible.builtin.get_url: + url: https://github.com/getsops/sops/releases/download/v3.9.0/sops-v3.9.0.linux.amd64 + dest: /usr/local/bin/sops + mode: '0755' + +- name: Create SOPS age dir + ansible.builtin.file: + path: /root/.config/sops/age + state: directory + mode: '0700' + +# In production, you would inject the private key at runtime; do NOT store it on hosts by default. +# This task is intentionally a placeholder (disabled by default). +# - name: (optional) Drop SOPS_AGE_KEY for automation +# ansible.builtin.copy: +# dest: /root/.config/sops/age/keys.txt +# content: "{{ sops_age_private_key }}" +# mode: '0600' +# when: sops_age_private_key is defined diff --git a/ansible/roles/wireguard/tasks/main.yml b/ansible/roles/wireguard/tasks/main.yml new file mode 100644 index 0000000..5b43c30 --- /dev/null +++ b/ansible/roles/wireguard/tasks/main.yml @@ -0,0 +1,8 @@ +--- +# Placeholder role. Define variables: +# wireguard_interface, wireguard_private_key (from SOPS), peers[], etc. +- name: Install wireguard + ansible.builtin.package: + name: wireguard + state: present + update_cache: true diff --git a/inventory/group_vars/all.yaml b/inventory/group_vars/all.yaml new file mode 100644 index 0000000..89017f6 --- /dev/null +++ b/inventory/group_vars/all.yaml @@ -0,0 +1,4 @@ +# Global, non-secret variables (safe to commit). +ansible_user: admin +ssh_port: 22 +timezone: Europe/Berlin diff --git a/inventory/group_vars/secrets.sops.yaml b/inventory/group_vars/secrets.sops.yaml new file mode 100644 index 0000000..690d512 --- /dev/null +++ b/inventory/group_vars/secrets.sops.yaml @@ -0,0 +1,3 @@ +# Encrypt this file with SOPS before committing! +ops: + hcloud_token: "hc_XXXXXXXXXXXXXXXXXXXXXXXXXXXX" # replace; then run: sops --encrypt --in-place inventory/group_vars/secrets.sops.yaml diff --git a/inventory/servers.yaml b/inventory/servers.yaml new file mode 100644 index 0000000..c13bac8 --- /dev/null +++ b/inventory/servers.yaml @@ -0,0 +1,9 @@ +# Minimal server registry: add your desired hosts here. +servers: + - name: core-01 + labels: [core, wireguard, git] + role: "core" + region: "nbg1" + type: "cpx21" + image: "ubuntu-24.04" + ssh_user: "admin" diff --git a/keys/README.md b/keys/README.md new file mode 100644 index 0000000..693d35e --- /dev/null +++ b/keys/README.md @@ -0,0 +1,7 @@ +# Keys + +- `age.pub` — your **public** age key (safe to commit). +- Your **private** age key is **NOT** stored here. Keep it in your password manager and load it locally as needed, e.g.: + ```bash + export SOPS_AGE_KEY=$(pass show age/railliance-hosts) # example + ``` diff --git a/scripts/new-server.sh b/scripts/new-server.sh new file mode 100644 index 0000000..c17bb49 --- /dev/null +++ b/scripts/new-server.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail +NAME="${1:-}" +if [[ -z "$NAME" ]]; then + echo "Usage: scripts/new-server.sh " + exit 1 +fi +yq -i '.servers += [{ "name": "'$NAME'", "labels": [], "role": "generic", "region": "nbg1", "type": "cpx21", "image": "ubuntu-24.04", "ssh_user": "admin"}]' inventory/servers.yaml +git add inventory/servers.yaml +git commit -m "Add server ${NAME}" +echo "Added ${NAME}. Run: make apply" diff --git a/scripts/sops.sh b/scripts/sops.sh new file mode 100644 index 0000000..23bbc5d --- /dev/null +++ b/scripts/sops.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +case "${1:-}" in + edit) sops inventory/group_vars/secrets.sops.yaml ;; + rotate) sops --rotate --in-place inventory/group_vars/secrets.sops.yaml ;; + *) + echo "Usage: scripts/sops.sh [edit|rotate]" + ;; +esac diff --git a/terraform/hetzner/cloud_init.yaml b/terraform/hetzner/cloud_init.yaml new file mode 100644 index 0000000..4964cac --- /dev/null +++ b/terraform/hetzner/cloud_init.yaml @@ -0,0 +1,44 @@ +#cloud-config +package_update: true +package_upgrade: true +packages: + - git + - curl + - unzip + - python3 + - python3-venv + - ufw + - vim + +users: + - name: admin + groups: [sudo] + shell: /bin/bash + sudo: "ALL=(ALL) NOPASSWD:ALL" +ssh_pwauth: false +disable_root: true + +write_files: + - path: /etc/ssh/sshd_config.d/10-hardening.conf + permissions: "0644" + content: | + PasswordAuthentication no + PermitRootLogin no + PubkeyAuthentication yes + + - path: /usr/local/bin/railliance-bootstrap.sh + permissions: "0755" + content: | + #!/usr/bin/env bash + set -euo pipefail + + # Basic firewall + ufw default deny incoming + ufw default allow outgoing + ufw allow OpenSSH + ufw --force enable + + systemctl restart ssh + +runcmd: + - [ bash, -c, "/usr/local/bin/railliance-bootstrap.sh > /var/log/railliance-bootstrap.log 2>&1" ] diff --git a/terraform/hetzner/main.tf b/terraform/hetzner/main.tf new file mode 100644 index 0000000..aaaaaca --- /dev/null +++ b/terraform/hetzner/main.tf @@ -0,0 +1,47 @@ +terraform { + required_version = ">= 1.7.0" + required_providers { + hcloud = { + source = "hetznercloud/hcloud" + version = "~> 1.49" + } + template = { + source = "hashicorp/template" + version = "~> 2.2" + } + } +} + +provider "hcloud" { + token = var.hcloud_token +} + +locals { + servers = yamldecode(file("${path.module}/../../inventory/servers.yaml")).servers + cloud_init = file("${path.module}/cloud_init.yaml") +} + +resource "hcloud_ssh_key" "admin" { + name = "railliance-admin" + public_key = file("${path.module}/../../keys/admin_ssh.pub") +} + +resource "hcloud_server" "srv" { + for_each = { for s in local.servers : s.name => s } + name = each.value.name + image = coalesce(each.value.image, "ubuntu-24.04") + server_type = each.value.type + location = each.value.region + ssh_keys = [hcloud_ssh_key.admin.id] + user_data = local.cloud_init + + labels = { + role = each.value.role + labels = join(",", try(each.value.labels, [])) + env = "default" + } +} + +output "servers" { + value = { for k, v in hcloud_server.srv : k => v.ipv4_address } +} diff --git a/terraform/hetzner/variables.tf b/terraform/hetzner/variables.tf new file mode 100644 index 0000000..f248140 --- /dev/null +++ b/terraform/hetzner/variables.tf @@ -0,0 +1,5 @@ +variable "hcloud_token" { + description = "Hetzner Cloud API token" + type = string + sensitive = true +}