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>
</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
- Fedora CoreOS base
- Fedora Server base
- Full disk encryption with LUKS
- Remote unlock via Tang server
- TPM-based boot verification
- BTRFS RAID1 storage
- BTRFS RAID1 storage with optimized subvolumes
- Automated deployment to Hetzner
- Cloud-init based configuration
## Security Model
### Tang Server Operation
The Tang server provides secure remote unlocking of LUKS volumes:
1. First connection: Client verifies Tang's public key advertisement
2. Boot time: Client sends encrypted challenge to Tang
3. Tang proves identity by decrypting challenge
4. Client receives key to unlock LUKS volume
### Unlock Methods
The system uses multiple methods to unlock the LUKS volumes:
1. **Primary Method**: TPM2 + Tang server
- TPM2 verifies boot integrity
- Tang server provides remote unlock capability
- 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
- TPM2 chip verifies boot integrity
- PCR measurements ensure system hasn't been tampered with
- 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
```
.
├── build.sh # Build and upload image from build-config
├── deploy.sh # Deployment script for Hetzner from deploy-config
├── build.py # Build and upload image from build-config
├── deploy.py # Deployment script for Hetzner from deploy-config
├── MASTER_README.md # Tang server setup documentation
├── README.md # Main project documentation
└── requirements.txt # Python dependencies
@ -45,11 +57,11 @@ The Tang server provides secure remote unlocking of LUKS volumes:
# Install tools
curl -fsSL https://raw.githubusercontent.com/hetznercloud/cli/master/install.sh | bash
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
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
@ -62,7 +74,7 @@ hcloud ssh-key create --name "fedora-coreos-hetzner" --public-key "$(cat ~/.ssh/
2. **Build Base Image** (one-time setup)
```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**
@ -78,7 +90,7 @@ hcloud ssh-key create --name "fedora-coreos-hetzner" --public-key "$(cat ~/.ssh/
5. **Verify**
```bash
ssh core@your-server
ssh admin@your-server
systemctl status clevis-luks-askpass
lsblk
clevis-luks-list -d /dev/sda2

View File

@ -1,7 +1,7 @@
# Build Configuration
image:
name: fedora-coreos-nullpoint
stream: stable
name: nullpoint
version: 39
arch: x86_64
hetzner_arch: x86
@ -11,15 +11,46 @@ system:
luks:
tang_url: https://tang.example.com
tang_thumbprint: your-tang-thumbprint
# TPM Configuration
tpm:
pcr_bank: sha256 # PCR bank to use (sha1 or sha256)
pcr_ids: [0,4,7,8,9] # PCRs to measure
# PCR descriptions:
# 0: Core System Firmware executable code (BIOS/UEFI) (RECOMMENDED)
# 1: Core System Firmware data (BIOS/UEFI settings)
# 2: Extended or pluggable executable code
# 3: Extended or pluggable firmware data
# 4: Boot Manager Code (bootloader) (RECOMMENDED)
# 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
# Storage Configuration
storage:
boot_size_mib: 512
compression: zstd
subvolumes:
- name: "@"
path: "/"
- name: "@home"
path: "/home"
- name: "@var"
path: "/var"
# 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

277
build.py
View File

@ -5,7 +5,9 @@ import sys
import yaml
import subprocess
import json
import shutil
from pathlib import Path
import tempfile
def load_config(config_file):
"""Load and parse YAML config file."""
@ -14,7 +16,7 @@ def load_config(config_file):
def check_prerequisites():
"""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:
if not shutil.which(tool):
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")
sys.exit(1)
def generate_ignition_config(config):
"""Generate Ignition configuration."""
def generate_cloud_init(config):
"""Generate cloud-init configuration."""
system = config['system']
luks = system['luks']
storage = system['storage']
cloud_init = system['cloud_init']
tpm_config = system['luks']['tpm']
# Generate the Ignition config
ignition_config = {
"ignition": {
"version": "3.4.0",
"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"
}
]
}
# Generate TPM config
tpm_json = {
'pcr_bank': tpm_config['pcr_bank'],
'pcr_ids': ','.join(map(str, tpm_config['pcr_ids']))
}
# Base cloud-init config
cloud_config = {
'timezone': cloud_init['timezone'],
'users': cloud_init['users'],
'package_update': True,
'package_upgrade': True,
'packages': cloud_init['packages'],
'write_files': [
{
'path': '/etc/clevis/tang.conf',
'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 yaml.dump(cloud_config)
# Add BTRFS filesystems
for subvol in storage['subvolumes']:
ignition_config['storage']['filesystems'].append({
"path": subvol['path'],
"device": "/dev/disk/by-id/dm-name-root",
"format": "btrfs",
"label": "root",
"wipeFilesystem": subvol['name'] == "@",
"options": [
f"subvol={subvol['name']}",
f"compress={storage['compression']}"
]
})
return json.dumps(ignition_config, indent=2)
def download_fcos_image(config):
"""Download FCOS image."""
def download_fedora_image(config):
"""Download Fedora Server cloud 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 = [
'coreos-installer', 'download',
'-s', image['stream'],
'-p', 'hetzner',
'-a', image['arch'],
'-f', 'raw.xz'
'virt-customize',
'-a', 'fedora-server.qcow2',
'--selinux-relabel',
'--run-command', 'dnf clean all',
'--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)
def get_image_file():
"""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):
def create_snapshot(config):
"""Create new snapshot from image."""
image = config['image']
cmd = [
'hcloud-upload-image', 'upload',
'--architecture', image['hetzner_arch'],
'--compression', 'xz',
'--image-path', str(image_file),
'--image-path', 'fedora-server.qcow2',
'--name', image['name'],
'--labels', f'os=fedora-coreos,channel={image["stream"]}',
'--description', f'Fedora CoreOS ({image["stream"]}, {image["arch"]}) for Nullpoint'
'--labels', f'os=fedora-server,version={image["version"]}',
'--description', f'Fedora Server {image["version"]} for Nullpoint'
]
subprocess.run(cmd, check=True)
@ -213,26 +151,17 @@ def main():
check_prerequisites()
check_hetzner_token()
# Generate Ignition config
print("Generating Ignition config...")
ignition_config = generate_ignition_config(config)
with open('config.ign', 'w') as f:
f.write(ignition_config)
# Download Fedora Server image
print("Downloading Fedora Server image...")
download_fedora_image(config)
# Download FCOS image
print("Downloading FCOS image...")
download_fcos_image(config)
# Customize image
print("Customizing image...")
customize_image(config)
# Get image file
image_file = get_image_file()
# 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)
# Create snapshot
print("Creating snapshot...")
create_snapshot(config)
print("Image build complete!")

106
deploy.py
View File

@ -8,6 +8,39 @@ import json
import shutil
from datetime import datetime
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):
"""Load and parse YAML config file."""
@ -28,12 +61,12 @@ def check_hetzner_token():
print("Error: HCLOUD_TOKEN environment variable not set")
sys.exit(1)
def generate_hostname(config):
"""Generate a unique hostname."""
prefix = config['hostname']['prefix']
timestamp = datetime.now().strftime('%y%m%d')
random_num = random.randint(0, 999)
return f"{prefix}-{timestamp}-{random_num:03d}"
def generate_hostname():
"""Generate a unique hostname in adjective-noun-date-time format."""
adjective = random.choice(ADJECTIVES)
noun = random.choice(NOUNS)
timestamp = datetime.now().strftime('%y%m%d-%H%M')
return f"{adjective}-{noun}-{timestamp}"
def get_image_id():
"""Get the base image ID."""
@ -52,19 +85,58 @@ def get_image_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):
"""Create a new server."""
hetzner = config['hetzner']
cmd = [
'hcloud', 'server', 'create',
'--name', hostname,
'--type', hetzner['server_type'],
'--datacenter', hetzner['datacenter'],
'--image', str(image_id),
'--ssh-key', hetzner['ssh_key_name'],
'--user-data-from-file', 'config.ign'
]
subprocess.run(cmd, check=True)
# 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 = [
'hcloud', 'server', 'create',
'--name', hostname,
'--type', hetzner['server_type'],
'--datacenter', hetzner['datacenter'],
'--image', str(image_id),
'--ssh-key', hetzner['ssh_key_name'],
'--user-data-from-file', f.name
]
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):
"""Get the server's IP address."""
@ -86,7 +158,7 @@ def main():
check_hetzner_token()
# Generate hostname
hostname = generate_hostname(config)
hostname = generate_hostname()
# Get image ID
image_id = get_image_id()