swithcing from CoreOS to Fedora Server

This commit is contained in:
Dominik Moritz Roth 2025-05-13 17:18:36 +02:00
parent 1180a3d8ac
commit 4d5880df27
4 changed files with 263 additions and 219 deletions

View File

@ -4,36 +4,48 @@
<br> <br>
</div> </div>
Secure Fedora CoreOS server setup with LUKS encryption, TPM, and BTRFS RAID1 with focus on Hetzner Infra. Secure Fedora Server setup with LUKS encryption, TPM, and BTRFS RAID1 with focus on Hetzner Infra.
## Features ## Features
- Fedora CoreOS base - Fedora Server base
- Full disk encryption with LUKS - Full disk encryption with LUKS
- Remote unlock via Tang server - Remote unlock via Tang server
- TPM-based boot verification - TPM-based boot verification
- BTRFS RAID1 storage - BTRFS RAID1 storage with optimized subvolumes
- Automated deployment to Hetzner - Automated deployment to Hetzner
- Cloud-init based configuration
## Security Model ## Security Model
### Tang Server Operation ### Unlock Methods
The Tang server provides secure remote unlocking of LUKS volumes: The system uses multiple methods to unlock the LUKS volumes:
1. First connection: Client verifies Tang's public key advertisement 1. **Primary Method**: TPM2 + Tang server
2. Boot time: Client sends encrypted challenge to Tang - TPM2 verifies boot integrity
3. Tang proves identity by decrypting challenge - Tang server provides remote unlock capability
4. Client receives key to unlock LUKS volume - Both must succeed for automatic unlock
2. **Fallback Method**: Manual passphrase
- Available via SSH if primary method fails
- Can be used for recovery or maintenance
### TPM Integration ### TPM Integration
- TPM2 chip verifies boot integrity - TPM2 chip verifies boot integrity
- PCR measurements ensure system hasn't been tampered with - PCR measurements ensure system hasn't been tampered with
- Combined with Tang for defense in depth - Combined with Tang for defense in depth
- Monitors all critical boot components
### Storage Security
- BTRFS RAID1 for data redundancy
- Dedicated database subvolume with `nodatacow` and `noatime`
- LUKS2 encryption with multiple unlock methods
- Secure boot enabled by default
- Redundant boot partition using BTRFS RAID1
## Repository Structure ## Repository Structure
``` ```
. .
├── build.sh # Build and upload image from build-config ├── build.py # Build and upload image from build-config
├── deploy.sh # Deployment script for Hetzner from deploy-config ├── deploy.py # Deployment script for Hetzner from deploy-config
├── MASTER_README.md # Tang server setup documentation ├── MASTER_README.md # Tang server setup documentation
├── README.md # Main project documentation ├── README.md # Main project documentation
└── requirements.txt # Python dependencies └── requirements.txt # Python dependencies
@ -45,11 +57,11 @@ The Tang server provides secure remote unlocking of LUKS volumes:
# Install tools # Install tools
curl -fsSL https://raw.githubusercontent.com/hetznercloud/cli/master/install.sh | bash curl -fsSL https://raw.githubusercontent.com/hetznercloud/cli/master/install.sh | bash
go install github.com/hetznercloud/hcloud-upload-image@latest go install github.com/hetznercloud/hcloud-upload-image@latest
dnf install jq coreos-installer python3-pyyaml sudo dnf install -y jq python3-pyyaml libguestfs-tools cloud-image-utils curl
# Configure Hetzner # Configure Hetzner
export HCLOUD_TOKEN="your-token-here" export HCLOUD_TOKEN="your-token-here"
hcloud ssh-key create --name "fedora-coreos-hetzner" --public-key "$(cat ~/.ssh/id_ed25519.pub)" hcloud ssh-key create --name "fedora-server-hetzner" --public-key "$(cat ~/.ssh/id_ed25519.pub)"
``` ```
## Setup ## Setup
@ -62,7 +74,7 @@ hcloud ssh-key create --name "fedora-coreos-hetzner" --public-key "$(cat ~/.ssh/
2. **Build Base Image** (one-time setup) 2. **Build Base Image** (one-time setup)
```bash ```bash
python3 build.py # Creates and uploads FCOS image to Hetzner python3 build.py # Creates and uploads Fedora Server image to Hetzner
``` ```
3. **Configure Deployment Settings** 3. **Configure Deployment Settings**
@ -78,7 +90,7 @@ hcloud ssh-key create --name "fedora-coreos-hetzner" --public-key "$(cat ~/.ssh/
5. **Verify** 5. **Verify**
```bash ```bash
ssh core@your-server ssh admin@your-server
systemctl status clevis-luks-askpass systemctl status clevis-luks-askpass
lsblk lsblk
clevis-luks-list -d /dev/sda2 clevis-luks-list -d /dev/sda2

View File

@ -1,7 +1,7 @@
# Build Configuration # Build Configuration
image: image:
name: fedora-coreos-nullpoint name: nullpoint
stream: stable version: 39
arch: x86_64 arch: x86_64
hetzner_arch: x86 hetzner_arch: x86
@ -12,14 +12,45 @@ system:
tang_url: https://tang.example.com tang_url: https://tang.example.com
tang_thumbprint: your-tang-thumbprint tang_thumbprint: your-tang-thumbprint
# Storage Configuration # TPM Configuration
storage: tpm:
boot_size_mib: 512 pcr_bank: sha256 # PCR bank to use (sha1 or sha256)
compression: zstd pcr_ids: [0,4,7,8,9] # PCRs to measure
subvolumes: # PCR descriptions:
- name: "@" # 0: Core System Firmware executable code (BIOS/UEFI) (RECOMMENDED)
path: "/" # 1: Core System Firmware data (BIOS/UEFI settings)
- name: "@home" # 2: Extended or pluggable executable code
path: "/home" # 3: Extended or pluggable firmware data
- name: "@var" # 4: Boot Manager Code (bootloader) (RECOMMENDED)
path: "/var" # 5: Boot Manager Configuration and Data
# 6: Platform-specific code
# 7: Platform-specific configuration (RECOMMENDED)
# 8: UEFI driver and application code (RECOMMENDED)
# 9: UEFI driver and application configuration (RECOMMENDED)
# 10: UEFI Handoff Tables
# 11: UEFI Boot Services Code
# 12: UEFI Boot Services Data
# 13: UEFI Runtime Services Code
# 14: UEFI Runtime Services Data
# 15: UEFI Secure Boot State
# Cloud-init Configuration
cloud_init:
timezone: UTC
users:
- name: admin
groups: wheel
sudo: ALL=(ALL) NOPASSWD:ALL
ssh_authorized_keys:
- "your-ssh-key-here"
packages:
- btrfs-progs
- clevis
- clevis-luks
- clevis-tang
- clevis-tpm2
- tpm2-tools
- tpm2-tss
- cryptsetup
- systemd
- curl

271
build.py
View File

@ -5,7 +5,9 @@ import sys
import yaml import yaml
import subprocess import subprocess
import json import json
import shutil
from pathlib import Path from pathlib import Path
import tempfile
def load_config(config_file): def load_config(config_file):
"""Load and parse YAML config file.""" """Load and parse YAML config file."""
@ -14,7 +16,7 @@ def load_config(config_file):
def check_prerequisites(): def check_prerequisites():
"""Check if required tools are installed.""" """Check if required tools are installed."""
required_tools = ['hcloud', 'hcloud-upload-image', 'jq', 'coreos-installer'] required_tools = ['hcloud', 'hcloud-upload-image', 'jq', 'virt-customize', 'cloud-localds']
for tool in required_tools: for tool in required_tools:
if not shutil.which(tool): if not shutil.which(tool):
print(f"Error: {tool} not found. Please install it first.") print(f"Error: {tool} not found. Please install it first.")
@ -26,177 +28,113 @@ def check_hetzner_token():
print("Error: HCLOUD_TOKEN environment variable not set") print("Error: HCLOUD_TOKEN environment variable not set")
sys.exit(1) sys.exit(1)
def generate_ignition_config(config): def generate_cloud_init(config):
"""Generate Ignition configuration.""" """Generate cloud-init configuration."""
system = config['system'] system = config['system']
luks = system['luks'] cloud_init = system['cloud_init']
storage = system['storage'] tpm_config = system['luks']['tpm']
# Generate the Ignition config # Generate TPM config
ignition_config = { tpm_json = {
"ignition": { 'pcr_bank': tpm_config['pcr_bank'],
"version": "3.4.0", 'pcr_ids': ','.join(map(str, tpm_config['pcr_ids']))
"config": {
"merge": []
}
},
"storage": {
"disks": [
{
"device": "/dev/sda",
"partitions": [
{
"label": "boot",
"number": 1,
"sizeMiB": storage['boot_size_mib'],
"typeGuid": "C12A7328-F81F-11D2-BA4B-00A0C93EC93B"
},
{
"label": "root",
"number": 2,
"sizeMiB": 0,
"typeGuid": "21686148-6449-6E6F-744E-656564454649"
}
]
},
{
"device": "/dev/sdb",
"partitions": [
{
"label": "boot2",
"number": 1,
"sizeMiB": storage['boot_size_mib'],
"typeGuid": "C12A7328-F81F-11D2-BA4B-00A0C93EC93B"
},
{
"label": "root2",
"number": 2,
"sizeMiB": 0,
"typeGuid": "21686148-6449-6E6F-744E-656564454649"
}
]
}
],
"luks": [
{
"name": "root",
"device": "/dev/disk/by-partlabel/root",
"clevis": {
"tpm2": True,
"tang": [
{
"url": luks['tang_url'],
"thumbprint": luks['tang_thumbprint']
}
],
"threshold": 1
}
},
{
"name": "root2",
"device": "/dev/disk/by-partlabel/root2",
"clevis": {
"tpm2": True,
"tang": [
{
"url": luks['tang_url'],
"thumbprint": luks['tang_thumbprint']
}
],
"threshold": 1
}
}
],
"filesystems": [
{
"path": "/boot",
"device": "/dev/disk/by-partlabel/boot",
"format": "vfat",
"label": "boot",
"wipeFilesystem": True
},
{
"path": "/boot2",
"device": "/dev/disk/by-partlabel/boot2",
"format": "vfat",
"label": "boot2",
"wipeFilesystem": True
}
]
},
"systemd": {
"units": [
{
"name": "kernel-sign.service",
"enabled": True,
"contents": "[Unit]\nDescription=Sign new kernel images\nAfter=kernel-install.service\n\n[Service]\nType=oneshot\nExecStart=/usr/local/bin/sign-kernel.sh\nRemainAfterExit=yes\n\n[Install]\nWantedBy=multi-user.target"
}
]
}
} }
# Add BTRFS filesystems # Base cloud-init config
for subvol in storage['subvolumes']: cloud_config = {
ignition_config['storage']['filesystems'].append({ 'timezone': cloud_init['timezone'],
"path": subvol['path'], 'users': cloud_init['users'],
"device": "/dev/disk/by-id/dm-name-root", 'package_update': True,
"format": "btrfs", 'package_upgrade': True,
"label": "root", 'packages': cloud_init['packages'],
"wipeFilesystem": subvol['name'] == "@", 'write_files': [
"options": [ {
f"subvol={subvol['name']}", 'path': '/etc/clevis/tang.conf',
f"compress={storage['compression']}" 'content': f"URL={system['luks']['tang_url']}\nThumbprint={system['luks']['tang_thumbprint']}\n",
'permissions': '0644'
},
{
'path': '/etc/clevis/tpm2.conf',
'content': json.dumps(tpm_json),
'permissions': '0644'
}
],
'runcmd': [
# Verify Tang server is accessible
f'curl -s -f {system["luks"]["tang_url"]}/adv > /dev/null || {{ echo "Tang server not accessible"; exit 1; }}',
# Verify TPM is available
'tpm2_getcap properties-fixed > /dev/null || { echo "TPM not available"; exit 1; }',
# Create filesystem
'mkfs.btrfs -f -d raid1 -m raid1 /dev/sda2 /dev/sdb2',
'mount /dev/sda2 /mnt',
# Create subvolumes
'btrfs subvolume create /mnt/@boot',
'btrfs subvolume create /mnt/@home',
'btrfs subvolume create /mnt/@db',
'chattr +C /mnt/@db',
# Setup LUKS with escaped passphrase
'echo -n "${LUKS_PASSPHRASE}" | tr -d "\n" | cryptsetup luksFormat /dev/sda2 --type luks2 --key-file -',
'echo -n "${LUKS_PASSPHRASE}" | tr -d "\n" | cryptsetup luksFormat /dev/sdb2 --type luks2 --key-file -',
# Setup Clevis with error handling
'clevis luks bind -d /dev/sda2 tpm2 -c /etc/clevis/tpm2.conf || { echo "TPM bind failed"; exit 1; }',
'clevis luks bind -d /dev/sda2 tang -c /etc/clevis/tang.conf || { echo "Tang bind failed"; exit 1; }',
'clevis luks bind -d /dev/sdb2 tpm2 -c /etc/clevis/tpm2.conf || { echo "TPM bind failed"; exit 1; }',
'clevis luks bind -d /dev/sdb2 tang -c /etc/clevis/tang.conf || { echo "Tang bind failed"; exit 1; }',
# Setup fstab
'echo "/dev/mapper/root / btrfs compress=zstd 0 0" >> /etc/fstab',
'echo "/dev/mapper/root /boot btrfs subvol=@boot,compress=zstd 0 0" >> /etc/fstab',
'echo "/dev/mapper/root /home btrfs subvol=@home,compress=zstd 0 0" >> /etc/fstab',
'echo "/dev/mapper/root /db btrfs subvol=@db,nodatacow,noatime,compress=zstd 0 0" >> /etc/fstab',
# Enable services
'systemctl enable clevis-luks-askpass.service',
'systemctl enable clevis-luks-askpass.path'
] ]
}) }
return json.dumps(ignition_config, indent=2) return yaml.dump(cloud_config)
def download_fcos_image(config): def download_fedora_image(config):
"""Download FCOS image.""" """Download Fedora Server cloud image."""
image = config['image'] image = config['image']
url = f"https://download.fedoraproject.org/pub/fedora/linux/releases/{image['version']}/Cloud/x86_64/images/Fedora-Cloud-Base-{image['version']}-1.6.x86_64.qcow2"
cmd = ['curl', '-L', '-o', 'fedora-server.qcow2', url]
subprocess.run(cmd, check=True)
def customize_image(config):
"""Customize the Fedora Server image."""
# Generate cloud-init config
cloud_init = generate_cloud_init(config)
# Create cloud-init ISO
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml') as f:
f.write(cloud_init)
f.flush()
subprocess.run(['cloud-localds', 'cloud-init.iso', f.name], check=True)
# Customize image
cmd = [ cmd = [
'coreos-installer', 'download', 'virt-customize',
'-s', image['stream'], '-a', 'fedora-server.qcow2',
'-p', 'hetzner', '--selinux-relabel',
'-a', image['arch'], '--run-command', 'dnf clean all',
'-f', 'raw.xz' '--run-command', 'dnf -y update',
'--run-command', 'dnf -y install cloud-init',
'--copy-in', 'cloud-init.iso:/var/lib/cloud/seed/nocloud/'
] ]
subprocess.run(cmd, check=True) subprocess.run(cmd, check=True)
def get_image_file(): def create_snapshot(config):
"""Get the downloaded image file name."""
files = sorted(Path('.').glob('fedora-coreos-*-hetzner.x86_64.raw.xz'))
if not files:
print("Error: No FCOS image found")
sys.exit(1)
return files[-1]
def delete_existing_image(image_name):
"""Delete existing image if it exists."""
cmd = [
'hcloud', 'image', 'list',
'--type=snapshot',
f'--selector=name={image_name}',
'--output', 'json'
]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
images = json.loads(result.stdout)
if images:
print("Deleting existing image...")
subprocess.run(['hcloud', 'image', 'delete', str(images[0]['id'])], check=True)
def create_snapshot(config, image_file):
"""Create new snapshot from image.""" """Create new snapshot from image."""
image = config['image'] image = config['image']
cmd = [ cmd = [
'hcloud-upload-image', 'upload', 'hcloud-upload-image', 'upload',
'--architecture', image['hetzner_arch'], '--architecture', image['hetzner_arch'],
'--compression', 'xz', '--compression', 'xz',
'--image-path', str(image_file), '--image-path', 'fedora-server.qcow2',
'--name', image['name'], '--name', image['name'],
'--labels', f'os=fedora-coreos,channel={image["stream"]}', '--labels', f'os=fedora-server,version={image["version"]}',
'--description', f'Fedora CoreOS ({image["stream"]}, {image["arch"]}) for Nullpoint' '--description', f'Fedora Server {image["version"]} for Nullpoint'
] ]
subprocess.run(cmd, check=True) subprocess.run(cmd, check=True)
@ -213,26 +151,17 @@ def main():
check_prerequisites() check_prerequisites()
check_hetzner_token() check_hetzner_token()
# Generate Ignition config # Download Fedora Server image
print("Generating Ignition config...") print("Downloading Fedora Server image...")
ignition_config = generate_ignition_config(config) download_fedora_image(config)
with open('config.ign', 'w') as f:
f.write(ignition_config)
# Download FCOS image # Customize image
print("Downloading FCOS image...") print("Customizing image...")
download_fcos_image(config) customize_image(config)
# Get image file # Create snapshot
image_file = get_image_file() print("Creating snapshot...")
create_snapshot(config)
# Delete existing image if it exists
print("Checking for existing image...")
delete_existing_image(config['image']['name'])
# Create new snapshot
print("Creating snapshot from image...")
create_snapshot(config, image_file)
print("Image build complete!") print("Image build complete!")

View File

@ -8,6 +8,39 @@ import json
import shutil import shutil
from datetime import datetime from datetime import datetime
import random import random
import tempfile
import secrets
import string
# List of adjectives for hostname generation
ADJECTIVES = [
# Scientific/Sci-fi
'quantum', 'atomic', 'plasma', 'fusion', 'ionic', 'magnetic', 'cosmic',
'stellar', 'nebular', 'pulsar', 'quasar', 'warp', 'phaser', 'hyper',
'temporal', 'spatial', 'dimensional', 'subspace', 'transwarp',
# Cool/Interesting
'abysmal', 'adamant', 'aerial', 'arcane', 'astral', 'azure', 'celestial',
'crimson', 'cryptic', 'crystalline', 'dormant', 'eerie', 'eldritch',
'ethereal', 'fractal', 'frozen', 'ghostly', 'gilded', 'singular',
'hollow', 'infernal', 'lunar', 'mystic', 'nebulous', 'obsidian',
'occult', 'prismatic', 'radiant', 'shadow', 'solar', 'spectral',
'stellar', 'sublime', 'titanic', 'twilight', 'void', 'volcanic'
]
NOUNS = [
# Star Trek
'enterprise', 'voyager', 'galaxy', 'intrepid', 'nova', 'warbird', 'falcon',
'aegis', 'nemesis', 'equinox', 'stargazer', 'challenger', 'discovery',
'prometheus', 'odyssey', 'daedalus', 'apollo', 'korolev', 'phoenix', 'orion',
# Space
'nebula', 'pulsar', 'quasar', 'nova', 'supernova', 'blackhole',
'wormhole', 'singularity', 'galaxy', 'void', 'rift', 'nexus',
# Cool Concepts
'abyss', 'aether', 'anomaly', 'artifact', 'beacon', 'cipher', 'crystal',
'echo', 'enigma', 'essence', 'fractal', 'horizon', 'infinity',
'paradox', 'phoenix', 'prism', 'spectrum', 'tesseract',
'vector', 'vertex', 'vortex', 'zenith'
]
def load_config(config_file): def load_config(config_file):
"""Load and parse YAML config file.""" """Load and parse YAML config file."""
@ -28,12 +61,12 @@ def check_hetzner_token():
print("Error: HCLOUD_TOKEN environment variable not set") print("Error: HCLOUD_TOKEN environment variable not set")
sys.exit(1) sys.exit(1)
def generate_hostname(config): def generate_hostname():
"""Generate a unique hostname.""" """Generate a unique hostname in adjective-noun-date-time format."""
prefix = config['hostname']['prefix'] adjective = random.choice(ADJECTIVES)
timestamp = datetime.now().strftime('%y%m%d') noun = random.choice(NOUNS)
random_num = random.randint(0, 999) timestamp = datetime.now().strftime('%y%m%d-%H%M')
return f"{prefix}-{timestamp}-{random_num:03d}" return f"{adjective}-{noun}-{timestamp}"
def get_image_id(): def get_image_id():
"""Get the base image ID.""" """Get the base image ID."""
@ -52,9 +85,42 @@ def get_image_id():
return images[0]['id'] return images[0]['id']
def generate_secure_passphrase(length=32):
"""Generate a secure random passphrase."""
alphabet = string.ascii_letters + string.digits + string.punctuation
return ''.join(secrets.choice(alphabet) for _ in range(length))
def create_server(config, hostname, image_id): def create_server(config, hostname, image_id):
"""Create a new server.""" """Create a new server."""
hetzner = config['hetzner'] hetzner = config['hetzner']
# Generate secure passphrase
passphrase = generate_secure_passphrase()
# Generate cloud-init config for this specific server
cloud_init = {
'hostname': hostname,
'timezone': 'UTC',
'users': config.get('users', []),
'package_update': True,
'package_upgrade': True,
'write_files': [
{
'path': '/root/luks-passphrase.txt',
'content': passphrase,
'permissions': '0600'
}
],
'runcmd': [
f'export LUKS_PASSPHRASE="{passphrase}"'
]
}
# Create temporary cloud-init config
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml') as f:
yaml.dump(cloud_init, f)
f.flush()
cmd = [ cmd = [
'hcloud', 'server', 'create', 'hcloud', 'server', 'create',
'--name', hostname, '--name', hostname,
@ -62,10 +128,16 @@ def create_server(config, hostname, image_id):
'--datacenter', hetzner['datacenter'], '--datacenter', hetzner['datacenter'],
'--image', str(image_id), '--image', str(image_id),
'--ssh-key', hetzner['ssh_key_name'], '--ssh-key', hetzner['ssh_key_name'],
'--user-data-from-file', 'config.ign' '--user-data-from-file', f.name
] ]
subprocess.run(cmd, check=True) subprocess.run(cmd, check=True)
# Print passphrase for user to save
print(f"\nIMPORTANT: Save this LUKS passphrase for {hostname}:")
print(f"{passphrase}\n")
print("This passphrase will be needed if TPM+Tang unlock fails.")
print("It is also saved in /root/luks-passphrase.txt on the server.")
def get_server_ip(hostname): def get_server_ip(hostname):
"""Get the server's IP address.""" """Get the server's IP address."""
cmd = ['hcloud', 'server', 'ip', hostname] cmd = ['hcloud', 'server', 'ip', hostname]
@ -86,7 +158,7 @@ def main():
check_hetzner_token() check_hetzner_token()
# Generate hostname # Generate hostname
hostname = generate_hostname(config) hostname = generate_hostname()
# Get image ID # Get image ID
image_id = get_image_id() image_id = get_image_id()