diff --git a/README.md b/README.md index bc01b64..c27241a 100644 --- a/README.md +++ b/README.md @@ -4,36 +4,48 @@
-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 diff --git a/build-config.yaml.example b/build-config.yaml.example index 908885d..e5cc1c0 100644 --- a/build-config.yaml.example +++ b/build-config.yaml.example @@ -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" \ No newline at end of file + # 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 \ No newline at end of file diff --git a/build.py b/build.py index dccf09d..81ce65c 100644 --- a/build.py +++ b/build.py @@ -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!") diff --git a/deploy.py b/deploy.py index a556cad..2eceab9 100644 --- a/deploy.py +++ b/deploy.py @@ -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()