feat: initial import of RailianceHosts starter

This commit is contained in:
2025-09-13 20:26:11 +02:00
commit 9860735f82
20 changed files with 355 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
# Terraform
.terraform/
terraform.tfstate*
*.tfvars
# Ansible
*.retry
# Secrets temp files
*.age
*.bak
*.tmp
*.swp
# Python bytecode
__pycache__/
*.pyc

7
.sops.yaml Normal file
View File

@@ -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'

36
Makefile Normal file
View File

@@ -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

18
README.md Normal file
View File

@@ -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.

7
ansible/ansible.cfg Normal file
View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -0,0 +1,4 @@
# ansible-galaxy collection install -r requirements.yaml
collections:
- name: community.general
- name: ansible.posix

View File

@@ -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') }}"

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,4 @@
# Global, non-secret variables (safe to commit).
ansible_user: admin
ssh_port: 22
timezone: Europe/Berlin

View File

@@ -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

9
inventory/servers.yaml Normal file
View File

@@ -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"

7
keys/README.md Normal file
View File

@@ -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
```

11
scripts/new-server.sh Normal file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
NAME="${1:-}"
if [[ -z "$NAME" ]]; then
echo "Usage: scripts/new-server.sh <name>"
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"

10
scripts/sops.sh Normal file
View File

@@ -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

View File

@@ -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" ]

47
terraform/hetzner/main.tf Normal file
View File

@@ -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 }
}

View File

@@ -0,0 +1,5 @@
variable "hcloud_token" {
description = "Hetzner Cloud API token"
type = string
sensitive = true
}