feat: add homelab deployment configs, documentation, and automation scripts

This commit is contained in:
2026-11-12 16:57:08 -05:00
parent 1228b957de
commit 5a70460e89
270 changed files with 9709 additions and 47 deletions

View File

@@ -0,0 +1,73 @@
# 🌲 SnarfNet Game Seed Management Plane
Declarative, GitOps-ready game server management plane for the SnarfNet dedicated gaming machine (`bc:24:11:67:16:cd` @ `140.44.44.80`), modeled on the declarative seed pattern (`arasoi/yggdrasil-releases` and `arasoi/yggdrasil-seeds`).
---
## 🎮 Declared Game Servers
| Game | Steam AppID | Container Image | Ports | RAM (Res / Limit) | World Save Path |
| :--- | :--- | :--- | :--- | :--- | :--- |
| **Enshrouded** | `2278520` | `skarab42/enshrouded-server:latest` | `15636/udp`, `15637/udp` | 8GB / 16GB | `/opt/game-servers/enshrouded/savegame` |
| **Farming Simulator 25** | `2300320` | `thijsvanloef/farming-simulator-25:latest` | `10823/tcp`, `10823/udp` | 6GB / 12GB | `/opt/game-servers/fs25/savegame` |
| **Stationeers** | `600760` | `sprits/stationeers-server:latest` | `27016/udp`, `27015/udp` | 4GB / 8GB | `/opt/game-servers/stationeers/saves` |
| **Valheim** (Crossplay) | `896660` | `lloesche/valheim-server:latest` | `2456/udp`, `2457/udp` | 4GB / 8GB | `/opt/game-servers/valheim/worlds` |
| **V Rising** | `1829350` | `trueosiris/vrising:latest` | `9876/udp`, `9877/udp` | 4GB / 8GB | `/opt/game-servers/vrising/Saves` |
---
## 🛠️ Management CLI (`core/manager.py`)
The manager CLI reconciles declared seeds against the host runtime, triggers automated world backups to Synology NAS, and issues Wake-on-LAN magic packets.
### Commands
```bash
# 1. Preview declared server configuration
python core/manager.py plan
# 2. Render docker-compose.yml for live deployment
python core/manager.py compose
# 3. Trigger manual atomic snapshot & sync to Synology NAS
python core/manager.py backup
# 4. Wake up the gaming PC via Wake-on-LAN (bc:24:11:67:16:cd)
python core/manager.py wol
```
---
## 💾 Backup & Disaster Recovery Architecture
All game world save files are preserved in atomic tarballs and synchronized to the Synology NAS:
- **NAS Backup NFS Target**: `14.10.10.71:/volume1/docker/game-backups`
- **Host Mount**: `/mnt/nas/game-backups`
- **Automated Timer**: Daily at 04:00 AM via `gameseed-backup.timer`
- **Retention**: 14 rolling snapshots per game
---
## 🌐 UDM Pro Port Forwarding Matrix
To permit external friends and players to join the servers, ensure the following port forwards are mapped from WAN to `140.44.44.80` on the UDM Pro:
```
Enshrouded: UDP 15636-15637 -> 140.44.44.80
Farming Simulator 25: TCP/UDP 10823 -> 140.44.44.80
Stationeers: UDP 27015-27016 -> 140.44.44.80
Valheim: UDP 2456-2457 -> 140.44.44.80
V Rising: UDP 9876-9877 -> 140.44.44.80
```
---
## 🚀 Bootstrap New Host
To provision the physical gaming PC:
```bash
sudo bash scripts/setup-host.sh
python3 core/manager.py compose
docker compose up -d
```

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""
SnarfNet Game Seed Management Plane (Yggdrasil-compatible)
Declarative orchestrator for private game servers on SnarfNet Gaming PC.
"""
import os
import sys
import glob
import yaml
import socket
import struct
import tarfile
import datetime
import subprocess
from pathlib import Path
TARGET_MAC = "bc:24:11:67:16:cd"
BROADCAST_IP = "140.44.44.255"
NAS_BACKUP_ROOT = "/mnt/nas/game-backups"
SEEDS_DIR = Path(__file__).parent.parent / "seeds"
def send_wol(mac=TARGET_MAC, bcast=BROADCAST_IP):
"""Sends a Wake-on-LAN magic packet to the gaming PC."""
clean_mac = mac.replace(":", "").replace("-", "")
if len(clean_mac) != 12:
raise ValueError(f"Invalid MAC address: {mac}")
data = bytes.fromhex("FF" * 6 + clean_mac * 16)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
s.sendto(data, (bcast, 9))
print(f"[WOL] Sent magic packet to {mac} via {bcast}:9")
def load_seeds():
"""Loads all declarative seed YAML files from the seeds directory."""
seeds = []
for filepath in glob.glob(str(SEEDS_DIR / "*.seed.yaml")):
with open(filepath, "r", encoding="utf-8") as f:
docs = yaml.safe_load(f)
if docs and docs.get("kind") == "GameSeed":
seeds.append(docs)
return seeds
def cmd_plan():
"""Previews desired state across all defined game seeds."""
seeds = load_seeds()
print(f"\n=======================================================")
print(f"🌲 SnarfNet Game Seed Plan ({len(seeds)} Seeds Declared)")
print(f"=======================================================")
for s in seeds:
meta = s.get("metadata", {})
spec = s.get("spec", {})
game = spec.get("game", {})
server = spec.get("server", {})
net = spec.get("network", {})
runtime = spec.get("runtime", {})
res = spec.get("resources", {})
ports = ", ".join([f"{p['port']}/{p['protocol']}" for p in net.get("ports", [])])
print(f"\n🎮 [{meta.get('name')}] {meta.get('displayName')}")
print(f" Steam AppID: {game.get('appId')} (Branch: {game.get('branch')})")
print(f" Image: {runtime.get('image')}")
print(f" Ports: {ports}")
print(f" Memory: {res.get('memoryReservation')} -> {res.get('memoryLimit')}")
print(f" Save Dir: {server.get('saveDirectory')}")
print(f" Backup: {spec.get('backup', {}).get('schedule')} (Retention: {spec.get('backup', {}).get('retentionDays')}d)")
def cmd_generate_compose(output_path="docker-compose.yml"):
"""Generates standard docker-compose.yml from all declared seeds."""
seeds = load_seeds()
services = {}
for s in seeds:
name = s["metadata"]["name"]
spec = s["spec"]
runtime = spec.get("runtime", {})
server = spec.get("server", {})
net = spec.get("network", {})
res = spec.get("resources", {})
ports = []
for p in net.get("ports", []):
ports.append(f"{p['port']}:{p['port']}/{p['protocol'].lower()}")
save_dir = server.get("saveDirectory")
data_dir = f"/opt/game-servers/{name}/data"
services[name] = {
"image": runtime.get("image"),
"container_name": f"gameseed-{name}",
"restart": runtime.get("restartPolicy", "unless-stopped"),
"environment": runtime.get("environment", {}),
"ports": ports,
"volumes": [
f"{save_dir}:{save_dir}",
f"{data_dir}:/serverdata"
],
"deploy": {
"resources": {
"limits": {
"memory": res.get("memoryLimit", "8Gi"),
"cpus": res.get("cpuLimit", "4")
},
"reservations": {
"memory": res.get("memoryReservation", "4Gi")
}
}
}
}
compose = {
"version": "3.8",
"services": services
}
with open(output_path, "w", encoding="utf-8") as f:
yaml.dump(compose, f, sort_keys=False)
print(f"[COMPOSE] Rendered {len(services)} services to {output_path}")
def cmd_backup():
"""Executes atomic snapshots of all world save directories and copies to Synology NAS."""
seeds = load_seeds()
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
nas_root = Path(NAS_BACKUP_ROOT)
print(f"\n📦 Starting Declarative Game Save Backups ({timestamp})...")
for s in seeds:
name = s["metadata"]["name"]
save_dir = Path(s["spec"]["server"]["saveDirectory"])
target_dir = nas_root / name
target_dir.mkdir(parents=True, exist_ok=True)
archive_name = f"{name}_save_{timestamp}.tar.gz"
archive_path = target_dir / archive_name
if save_dir.exists():
print(f" Archiving {name}: {save_dir} -> {archive_path}")
with tarfile.open(archive_path, "w:gz") as tar:
tar.add(save_dir, arcname=name)
print(f" ✓ Backup complete: {archive_path} ({archive_path.stat().st_size} bytes)")
else:
print(f" [SKIP] Save directory {save_dir} not found on host.")
def main():
if len(sys.argv) < 2:
print("Usage: python manager.py <plan|compose|backup|wol>")
sys.exit(1)
action = sys.argv[1].lower()
if action == "plan":
cmd_plan()
elif action == "compose":
cmd_generate_compose()
elif action == "backup":
cmd_backup()
elif action == "wol":
send_wol()
else:
print(f"Unknown command: {action}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# ==============================================================================
# SnarfNet Gaming PC Bootstrap Script
# MAC: bc:24:11:67:16:cd | IP: 140.44.44.80 (Compute / Game VLAN)
# ==============================================================================
set -euo pipefail
echo "======================================================="
echo "🌲 Bootstrapping SnarfNet Dedicated Game Server Host"
echo "======================================================="
# 1. System Updates & Essential Tools
echo ">>> Installing prerequisites and system tools..."
apt-get update && apt-get install -y \
curl wget git jq ufw nfs-common ethtool net-tools \
python3 python3-yaml python3-pip apt-transport-https \
ca-certificates gnupg lsb-release
# 2. Configure Wake-on-LAN (Persistent via systemd)
echo ">>> Configuring Wake-on-LAN for primary interface..."
NIC=$(ip route show default | awk '{print $5}' | head -n1)
ethtool -s "$NIC" wol g || true
cat <<EOF > /etc/systemd/system/wol.service
[Unit]
Description=Enable Wake-on-LAN on $NIC
After=network.target
[Service]
Type=oneshot
ExecStart=/sbin/ethtool -s $NIC wol g
[Install]
WantedBy=basic.target
EOF
systemctl daemon-reload
systemctl enable wol.service
# 3. Mount Synology NAS Game Backup Share
echo ">>> Configuring Synology NAS NFS Mount (14.10.10.71:/volume1/docker/game-backups)..."
mkdir -p /mnt/nas/game-backups
if ! grep -qs '/mnt/nas/game-backups' /etc/fstab; then
echo "14.10.10.71:/volume1/docker/game-backups /mnt/nas/game-backups nfs rw,hard,intr,nolock,timeo=14 0 0" >> /etc/fstab
fi
mount -a || true
# 4. Install Docker & Compose (if not present)
if ! command -v docker &> /dev/null; then
echo ">>> Installing Docker Engine..."
curl -fsSL https://get.docker.com | sh
systemctl enable docker
systemctl start docker
fi
# 5. Prepare Game Directory Structure
echo ">>> Creating game server storage paths..."
mkdir -p /opt/game-servers/{enshrouded,fs25,stationeers,valheim,vrising}/{savegame,saves,worlds,data}
chmod -R 775 /opt/game-servers
# 6. Setup Seed Manager Service & Daily Cron
echo ">>> Installing SnarfNet Seed Reconciler and Backup Timer..."
cat <<EOF > /etc/systemd/system/gameseed-backup.service
[Unit]
Description=SnarfNet Declarative Game Server Save Backup
After=network.target docker.service
[Service]
Type=oneshot
WorkingDirectory=/opt/snarfnet-game-seeds
ExecStart=/usr/bin/python3 /opt/snarfnet-game-seeds/core/manager.py backup
[Install]
WantedBy=multi-user.target
EOF
cat <<EOF > /etc/systemd/system/gameseed-backup.timer
[Unit]
Description=Nightly Game Save Backup Timer (04:00 AM)
[Timer]
OnCalendar=*-*-* 04:00:00
Persistent=true
[Install]
Timers.target
EOF
systemctl daemon-reload
systemctl enable gameseed-backup.timer
systemctl start gameseed-backup.timer
echo "======================================================="
echo "✓ Host bootstrap complete!"
echo "Run: python3 /opt/snarfnet-game-seeds/core/manager.py compose && docker compose up -d"
echo "======================================================="

View File

@@ -0,0 +1,42 @@
apiVersion: yggdrasil.snarfnet.net/v1alpha1
kind: GameSeed
metadata:
name: enshrouded
displayName: "Enshrouded Dedicated Server"
version: "1.0.0"
spec:
game:
appId: 2278520
branch: public
autoUpdate: true
server:
name: "SnarfNet Enshrouded Sanctuary"
passwordSecret: "ENSHROUDED_SERVER_PASSWORD"
maxPlayers: 16
saveDirectory: "/opt/game-servers/enshrouded/savegame"
network:
ip: "140.44.44.80"
ports:
- port: 15636
protocol: UDP
description: "Game Query & Traffic"
- port: 15637
protocol: UDP
description: "Steam Query Port"
resources:
cpuLimit: "8"
memoryLimit: "16Gi"
memoryReservation: "8Gi"
runtime:
image: "skarab42/enshrouded-server:latest"
restartPolicy: unless-stopped
environment:
SERVER_NAME: "SnarfNet Enshrouded Sanctuary"
SERVER_SLOTS: 16
GAME_PORT: 15636
QUERY_PORT: 15637
backup:
schedule: "0 4 * * *" # Daily 04:00 AM
retentionDays: 14
targetPath: "/mnt/nas/game-backups/enshrouded"

View File

@@ -0,0 +1,41 @@
apiVersion: yggdrasil.snarfnet.net/v1alpha1
kind: GameSeed
metadata:
name: farming-simulator-25
displayName: "Farming Simulator 25 Dedicated Server"
version: "1.0.0"
spec:
game:
appId: 2300320
branch: public
autoUpdate: true
server:
name: "SnarfNet FS25 Countryside"
passwordSecret: "FS25_SERVER_PASSWORD"
adminPasswordSecret: "FS25_ADMIN_PASSWORD"
maxPlayers: 16
saveDirectory: "/opt/game-servers/fs25/savegame"
network:
ip: "140.44.44.80"
ports:
- port: 10823
protocol: TCP
description: "Game Web Management & Client Connection"
- port: 10823
protocol: UDP
description: "Game Network Traffic"
resources:
cpuLimit: "6"
memoryLimit: "12Gi"
memoryReservation: "6Gi"
runtime:
image: "thijsvanloef/farming-simulator-25:latest"
restartPolicy: unless-stopped
environment:
SERVER_NAME: "SnarfNet FS25 Countryside"
GAME_PORT: 10823
backup:
schedule: "0 4 * * *"
retentionDays: 14
targetPath: "/mnt/nas/game-backups/farming-simulator-25"

View File

@@ -0,0 +1,44 @@
apiVersion: yggdrasil.snarfnet.net/v1alpha1
kind: GameSeed
metadata:
name: stationeers
displayName: "Stationeers Dedicated Server"
version: "1.0.0"
spec:
game:
appId: 600760
branch: public
autoUpdate: true
server:
name: "SnarfNet Stationeers Moonbase"
worldName: "Moon"
passwordSecret: "STATIONEERS_SERVER_PASSWORD"
maxPlayers: 10
saveDirectory: "/opt/game-servers/stationeers/saves"
network:
ip: "140.44.44.80"
ports:
- port: 27016
protocol: UDP
description: "Game Traffic Port"
- port: 27015
protocol: UDP
description: "Steam Query Port"
resources:
cpuLimit: "4"
memoryLimit: "8Gi"
memoryReservation: "4Gi"
runtime:
image: "sprits/stationeers-server:latest"
restartPolicy: unless-stopped
environment:
SERVER_NAME: "SnarfNet Stationeers Moonbase"
WORLD_NAME: "Moon"
GAME_PORT: 27016
QUERY_PORT: 27015
AUTO_SAVE_INTERVAL: 300
backup:
schedule: "0 4 * * *"
retentionDays: 14
targetPath: "/mnt/nas/game-backups/stationeers"

View File

@@ -0,0 +1,47 @@
apiVersion: yggdrasil.snarfnet.net/v1alpha1
kind: GameSeed
metadata:
name: valheim
displayName: "Valheim Dedicated Server"
version: "1.0.0"
spec:
game:
appId: 896660
branch: public
autoUpdate: true
server:
name: "SnarfNet Valheim Valhalla"
worldName: "Snarfheim"
passwordSecret: "VALHEIM_SERVER_PASSWORD"
crossplay: true
maxPlayers: 10
saveDirectory: "/opt/game-servers/valheim/worlds"
network:
ip: "140.44.44.80"
ports:
- port: 2456
protocol: UDP
description: "Game Traffic Port"
- port: 2457
protocol: UDP
description: "Steam Query Port"
resources:
cpuLimit: "6"
memoryLimit: "8Gi"
memoryReservation: "4Gi"
runtime:
image: "lloesche/valheim-server:latest"
restartPolicy: unless-stopped
environment:
SERVER_NAME: "SnarfNet Valheim Valhalla"
WORLD_NAME: "Snarfheim"
SERVER_PORT: 2456
SERVER_PUBLIC: 1
CROSSPLAY: "true"
UPDATE_CRON: "0 5 * * *"
BEPINEX: "false"
backup:
schedule: "0 4 * * *"
retentionDays: 14
targetPath: "/mnt/nas/game-backups/valheim"

View File

@@ -0,0 +1,43 @@
apiVersion: yggdrasil.snarfnet.net/v1alpha1
kind: GameSeed
metadata:
name: vrising
displayName: "V Rising Dedicated Server"
version: "1.0.0"
spec:
game:
appId: 1829350
branch: public
autoUpdate: true
server:
name: "SnarfNet V Rising Vampire Lair"
saveName: "SnarfNet_Vampires"
passwordSecret: "VRISING_SERVER_PASSWORD"
maxPlayers: 20
saveDirectory: "/opt/game-servers/vrising/Saves"
network:
ip: "140.44.44.80"
ports:
- port: 9876
protocol: UDP
description: "Game Traffic Port"
- port: 9877
protocol: UDP
description: "Query Port"
resources:
cpuLimit: "6"
memoryLimit: "8Gi"
memoryReservation: "4Gi"
runtime:
image: "trueosiris/vrising:latest"
restartPolicy: unless-stopped
environment:
SERVERNAME: "SnarfNet V Rising Vampire Lair"
SAVENAME: "SnarfNet_Vampires"
GAMEPORT: 9876
QUERYPORT: 9877
backup:
schedule: "0 4 * * *"
retentionDays: 14
targetPath: "/mnt/nas/game-backups/vrising"