feat: initial import of RailianceHosts starter
This commit is contained in:
commit
9860735f82
20 changed files with 355 additions and 0 deletions
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal 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
7
.sops.yaml
Normal 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
36
Makefile
Normal 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
18
README.md
Normal 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
7
ansible/ansible.cfg
Normal 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
|
||||||
34
ansible/inventory_from_yaml.py
Normal file
34
ansible/inventory_from_yaml.py
Normal 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()
|
||||||
9
ansible/playbooks/bootstrap.yaml
Normal file
9
ansible/playbooks/bootstrap.yaml
Normal 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
|
||||||
4
ansible/requirements.yaml
Normal file
4
ansible/requirements.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# ansible-galaxy collection install -r requirements.yaml
|
||||||
|
collections:
|
||||||
|
- name: community.general
|
||||||
|
- name: ansible.posix
|
||||||
45
ansible/roles/base/tasks/main.yml
Normal file
45
ansible/roles/base/tasks/main.yml
Normal 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') }}"
|
||||||
30
ansible/roles/sops_agent/tasks/main.yml
Normal file
30
ansible/roles/sops_agent/tasks/main.yml
Normal 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
|
||||||
8
ansible/roles/wireguard/tasks/main.yml
Normal file
8
ansible/roles/wireguard/tasks/main.yml
Normal 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
|
||||||
4
inventory/group_vars/all.yaml
Normal file
4
inventory/group_vars/all.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Global, non-secret variables (safe to commit).
|
||||||
|
ansible_user: admin
|
||||||
|
ssh_port: 22
|
||||||
|
timezone: Europe/Berlin
|
||||||
3
inventory/group_vars/secrets.sops.yaml
Normal file
3
inventory/group_vars/secrets.sops.yaml
Normal 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
9
inventory/servers.yaml
Normal 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
7
keys/README.md
Normal 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
11
scripts/new-server.sh
Normal 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
10
scripts/sops.sh
Normal 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
|
||||||
44
terraform/hetzner/cloud_init.yaml
Normal file
44
terraform/hetzner/cloud_init.yaml
Normal 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
47
terraform/hetzner/main.tf
Normal 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 }
|
||||||
|
}
|
||||||
5
terraform/hetzner/variables.tf
Normal file
5
terraform/hetzner/variables.tf
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
variable "hcloud_token" {
|
||||||
|
description = "Hetzner Cloud API token"
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue