feat: initial import of RailianceHosts starter
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user