LLM-optimized reference. Dense, structured, minimal prose. Last updated: 2026-02-22
blockhost/
├── CLAUDE.md # Session rules, submodule constraints
├── ARCHITECTURE.md # THIS FILE
├── docs/BUILD_GUIDE.md # Human build reproduction guide
├── docs/INFRASTRUCTURE.md # Human infrastructure reference + extension guides
├── docs/STANDARDS.md # Development standards and conventions
├── preseed/blockhost.preseed # Debian auto-install config (93 lines)
├── systemd/blockhost-firstboot.service # Runs first-boot.sh after install
├── scripts/
│ ├── build-iso.sh # ISO builder (419 lines)
│ ├── build-packages.sh # Build all submodule .debs (213 lines)
│ ├── first-boot.sh # Post-install orchestrator (~519 lines)
│ ├── check-build-deps.sh # Verify build toolchain
│ └── ci-verify-packages.sh # CI: verify all 6 .debs exist
├── installer/
│ ├── __init__.py
│ ├── common/
│ │ ├── __init__.py
│ │ ├── otp.py # OTP generation/validation (301 lines)
│ │ ├── network.py # Interface detection, DHCP, static (404 lines)
│ │ └── detection.py # Boot medium detection (220 lines)
│ └── web/
│ ├── __init__.py
│ ├── app.py # Flask wizard + routes (~1360 lines)
│ ├── finalize.py # Finalization pipeline + step functions (~880 lines)
│ ├── utils.py # Pure utilities: validation, YAML, disks (~275 lines)
│ ├── validate_system.py # Post-install validation (~1010 lines)
│ ├── static/ # CSS, JS assets
│ └── templates/
│ ├── base.html
│ ├── login.html
│ ├── macros/wizard_steps.html
│ └── wizard/
│ ├── network.html
│ ├── storage.html
│ ├── ipv6.html
│ ├── admin_commands.html
│ └── summary.html # Review + finalization progress UI
├── testing/
│ ├── integration-test-proxmox.sh # E2E test (Proxmox: PVE API + Terraform cleanup)
│ ├── integration-test-libvirt.sh # E2E test (libvirt: vm-status + vm-destroy cleanup)
│ ├── ci-config-proxmox.json # CI config for Proxmox backend
│ ├── ci-config-libvirt.json # CI config for libvirt backend
│ ├── ipv6-login-test.sh # IPv6 PAM web3 SSH login test
│ └── ci-provision.sh # CI: VM lifecycle (create, boot, wizard, finalize)
├── admin/
│ ├── __init__.py
│ ├── __main__.py # python3 -m admin entry
│ ├── app.py # Flask app factory, session config, CLI
│ ├── auth.py # Wallet auth: challenge, verify (bw who), sessions, login_required
│ ├── routes.py # Auth routes + protected API endpoints + dashboard
│ ├── system.py # Data collection + system actions (~200 lines)
│ ├── templates/
│ │ ├── base.html # Dark theme, sidebar nav, topbar wallet + logout
│ │ ├── dashboard.html # Single-page dashboard, all sections
│ │ └── login.html # Standalone login: challenge code, wallet signing + paste signature
│ └── static/css/admin.css # Style overrides (base styles in template)
├── .github/workflows/
│ ├── ci.yml # Push/PR: tests + package build
│ ├── iso-build.yml # Manual/tag: ISO build (self-hosted)
│ └── integration.yml # Manual: full integration test (self-hosted)
├── packages/
│ ├── host/ # .debs installed on host
│ └── template/ # .debs included in VM templates
└── submodules (READ-ONLY, own repos):
├── libpam-web3/
├── blockhost-common/
├── blockhost-provisioner-proxmox/
├── blockhost-provisioner-libvirt/
├── blockhost-engine/
├── blockhost-engine-opnet/
├── blockhost-broker/
└── facts/
build-packages.sh --backend <name> --engine <name>
→ libpam-web3/packaging/build-deb.sh → packages/template/libpam-web3_*.deb
→ blockhost-common/build.sh → packages/host/blockhost-common_*.deb
→ blockhost-provisioner-<backend>/build-deb.sh → packages/host/blockhost-provisioner-<backend>_*.deb
→ blockhost-engine-<engine>/packaging/build.sh → packages/host/blockhost-engine-<engine>_*.deb
→ → packages/template/blockhost-auth-svc_*.deb (if engine produces one)
→ blockhost-broker/scripts/build-deb.sh → packages/host/blockhost-broker-client_*.deb
Note: --engine selects which engine submodule to build (e.g., evm, opnet).
bhcrypt CLI is built and shipped inside the engine .deb.
OPNet engine also produces blockhost-auth-svc template package.
The provisioner's template build must install this package into VMs alongside
libpam-web3. TODO: provisioner template build does not yet know about
engine-supplied template packages.
build-iso.sh --backend <name> --engine <name> [--testing] [--build-deb]
→ extract Debian 12 netinst ISO
→ inject preseed/blockhost.preseed into GRUB + isolinux
→ copy installer/, scripts/first-boot.sh, systemd unit
→ copy packages/host/*.deb, packages/template/*.deb
→ extract contract JSON from .deb artifacts → /blockhost/contracts/
→ [--testing]: apt proxy 192.168.122.1:3142, SSH root login, testing marker
→ rebuild ISO with xorriso → build/blockhost_0.3.0.iso (root-owned)
blockhost.preseed:
locale=en_US.UTF-8, timezone=UTC
partitioning: LVM, full disk
root password: blockhost
packages: python3, python3-flask, curl, wget, openssh-server, sudo
late_command:
cp -r /cdrom/blockhost /opt/blockhost
enable blockhost-firstboot.service
[testing: configure apt proxy, SSH root]
→ reboot
blockhost-firstboot.service → /opt/blockhost/first-boot.sh
State dir: /var/lib/blockhost/
Log: /var/log/blockhost-firstboot.log
Completion marker: /var/lib/blockhost/.setup-complete
Step 1 (.step-network-wait): Wait for network
Step 2 (.step-packages): Install host .debs + copy template .debs to /var/lib/blockhost/template-packages/
Step 2b: Verify blockhost user exists (created by blockhost-common .deb)
Step 2c: Verify root agent running (installed + enabled by blockhost-common .deb), wait for socket
Step 3 (.step-provisioner-hook): Run provisioner first-boot hook (from installed manifest)
→ Hook path: manifest.setup.first_boot_hook (e.g. provisioner-hooks/first-boot.sh)
→ Proxmox hook: hostname fix, install proxmox-ve, terraform, libguestfs-tools
→ Receives STATE_DIR, LOG_FILE as env vars; uses own step markers
→ Requires packages installed first (manifest + hook script are in blockhost-provisioner-proxmox.deb)
Step 3a (.step-bridge): Create Linux bridge (provisioner-agnostic)
→ Skip if bridge with global IPv4 already exists (handles Proxmox vmbr0, pre-configured systems)
→ Detect primary NIC from default route, skip wireless NICs
→ Create br0, migrate IP from NIC to bridge, verify gateway ping
→ Rollback on connectivity failure (restore IP to NIC, delete bridge)
→ Persist to /etc/network/interfaces (Proxmox will overwrite via pvesh — expected)
→ Store bridge name in /run/blockhost/bridge
Step 3b-pre (.step-nodejs): Install Node.js 22 LTS via NodeSource (required by engine)
Step 3b (.step-foundry): Install Foundry (cast, forge, anvil) → /usr/local/bin/ (EVM engine only)
Step 4 (.step-network): Verify network connectivity (DHCP fallback)
Step 5 (.step-otp): Generate OTP → /run/blockhost/otp.json, display on /etc/issue
Step 6: Start Flask wizard on :80 (private) or :443 (public)
Entry: PYTHONPATH=/opt/blockhost python3 -m installer.web.app --host 0.0.0.0 --port 80
Pre-wizard:
/login → OTP verification (6 chars, A-Z2-9, 4hr timeout, 10 attempts)
/wizard/wallet → Admin wallet connect + signature (template provided by engine via get_wallet_template())
Wizard steps (WIZARD_STEPS in app.py, dynamically built):
Core:
1. /wizard/network → DHCP or static IP
2. /wizard/storage → Disk selection for LVM
Engine (from engine manifest.setup.wizard_module → Flask Blueprint):
3. /wizard/<engine> → chain-specific config (chain_id, rpc_url, wallet, contracts, plan, revenue_share)
Provisioner (from provisioner manifest.setup.wizard_module → Flask Blueprint):
4. /wizard/<provisioner> → provisioner-specific config (ip_pool, gc_grace_days, ...)
Post:
5. /wizard/ipv6 → broker allocation or manual prefix
6. /wizard/admin_commands → port knocking config
7. /wizard/summary → review → POST confirm=yes → /wizard/install
All state stored in Flask session (see SESSION SCHEMA below).
POST /api/finalize → run_finalization_with_state() in thread (finalize.py)
State file: /var/lib/blockhost/setup-state.json (SetupState class, app.py)
Poll: GET /api/finalize/status
Retry: POST /api/finalize/retry {step_id?}
Reset: POST /api/finalize/reset
Step dispatch (get_finalization_steps() in finalize.py, dynamically built, 5 phases):
Engine pre-steps (from engine module get_finalization_steps()):
(varies by engine — e.g. EVM: keypair, wallet, contracts, chain_config)
Provisioner steps (from provisioner module get_finalization_steps()):
(varies by provisioner — e.g. Proxmox: token, terraform, template)
Installer post-steps (hardcoded):
ipv6 _finalize_ipv6 → broker-client or manual prefix, WireGuard
https _finalize_https → dns_zone hostname (preferred) or sslip.io fallback, Let's Encrypt cert
signup _finalize_signup → blockhost-generate-signup → /var/www/blockhost/signup.html (static)
nginx _finalize_nginx → install nginx, write reverse proxy config, enable (not start)
Engine post-steps (from engine module get_post_finalization_steps()):
(varies by engine — e.g. EVM: mint_nft, plan, revenue_share)
Final steps (hardcoded):
finalize _finalize_complete → .setup-complete marker, enable services, permissions
validate _finalize_validate → System validation (testing mode only)
Each step: skip if completed, mark running → completed|failed, supports retry.
Services enabled by finalization (all run as User=blockhost except root-agent and nginx):
blockhost-root-agent.service → Privileged ops daemon (root, installed + enabled by blockhost-common .deb)
blockhost-monitor.service → TypeScript event watcher (blockhost-engine)
blockhost-admin.service → Admin panel Flask app (127.0.0.1:8443, behind nginx)
nginx.service → TLS terminator: signup page (static) + admin panel reverse proxy (:443)
blockhost-gc.timer → Daily garbage collection (2 AM)
Subscription purchase flow:
1. User visits signup page → connects wallet → signs message
2. Signup page calls BlockhostSubscriptions.buySubscription(planId, days, paymentMethodId, userEncrypted)
- userEncrypted = ECIES-encrypted data blob (wallet-derived key)
3. Contract emits SubscriptionCreated event with encrypted user data
4. blockhost-monitor detects event → handleSubscriptionCreated:
a. Decrypt userEncrypted via bhcrypt (ECIES, server.key)
b. Call provisioner create (WITHOUT --nft-token-id) → parse JSON summary (ip, vmid, username)
c. Encrypt connection details via bhcrypt (symmetric AES-GCM, user signature as key)
d. Call blockhost-mint-nft → capture actual token ID from stdout
e. Call update-gecos with actual token ID
f. Mark NFT minted in database (awaited — never fire-and-forget)
5. User retrieves NFT → re-signs publicSecret → derives key → decrypts connection details
6. VM named: blockhost-XXX (3-digit zero-padded subscription ID)
VM authentication flow (on each SSH login):
1. SSH connect → PAM module (pam_web3.so) generates OTP
OTP = HMAC-SHA3(machine_id + timestamp + secret_key), 6 chars
2. PAM writes session file to /run/libpam-web3/pending/<session_id>.json
(callback mode detected by directory existence — auth-svc creates the dir via tmpfiles.d)
3. User sees signing page URL with ?session=<id> (https://{ip}:8443, self-signed TLS)
Prompt shows "Press Enter after signing in browser (or paste signature):"
4. Two input paths (first one wins):
Path A (callback): User opens URL → signing page auto-fills OTP+machine → user signs →
page POSTs signature to /auth/callback/<session_id> → PAM detects .sig file → authenticates
Path B (manual): User copies OTP, signs offline, pastes raw hex signature in terminal
5. PAM verifies identity: EVM = secp256k1 ecrecover, OPNet = Schnorr sig + OTP validation via .sig file
6. PAM checks wallet=ADDRESS in Linux user GECOS field (no blockchain query)
7. Match → access granted as that Linux user
8. PAM cleans up session files
Note: PAM is scoped to provisioned user only via pam_succeed_if.so guard. Other users get pam_deny.
Expiry flow:
blockhost-gc.py (daily via systemd timer) checks expired subscriptions
Phase 1 (suspend): QEMU shutdown, disk preserved
Phase 2 (destroy): after gc_grace_days, delete disk + Terraform state + IPv6 host route
Extension flow:
User calls extendSubscription() on-chain
→ monitor detects SubscriptionExtended event
→ resume suspended VM (if within grace period)
Admin commands flow:
Admin signs command on-chain (ECIES-encrypted, anti-replay nonce)
→ blockhost-engine src/admin/ processes command
→ e.g. port knocking: temporarily open ports on VM
Post-install web dashboard for system management. Wallet-based auth (same flow as libpam-web3 SSH login). Runs as standalone Flask app.
Entry: python3 -m admin.app --port 8443
Bind: 127.0.0.1:8443 (localhost only — accessed via nginx reverse proxy at configurable prefix, default /admin)
Service: blockhost-admin.service (systemd)
Full interface specification: facts/ADMIN_INTERFACE.md
admin/
├── app.py → create_app() factory, session config, CLI (argparse --port/--host/--debug)
├── auth.py → Challenge generation, signature verification (bw who + engine constraints), session management, login_required decorator
├── routes.py → Blueprint "admin", auth routes + protected API + page routes
├── system.py → Data collection (reads host files/commands), action wrappers
├── root-agent-actions/
│ └── admin_panel.py → Root agent plugin: admin-path-update (nginx + service restart)
├── templates/
│ ├── base.html → Dark theme, sidebar nav, topbar status, shared JS (apiGet/apiPost/showAlert/formatUptime/formatBytes)
│ ├── system.html → System & Storage page (hostname, uptime, disk usage, block devices)
│ ├── network.html → Network & Security page (IPv4, broker, knock settings, admin path)
│ ├── wallet.html → Wallet & Addressbook page (balances, transfer, withdraw, addressbook CRUD)
│ ├── vms.html → VMs & Accounts page (VM table with actions, accounts placeholder)
│ └── login.html → Standalone login page: challenge code, wallet signing + paste-signature paths
└── static/css/ → Style overrides
Wallet-signing flow identical to libpam-web3 SSH login. Access is NFT-gated: only the current holder of the admin credential NFT can authenticate.
1. Visit any page → redirected to /login (login_required decorator)
2. Login page generates random 6-char OTP (A-Z2-9, no ambiguous chars), shows:
"Code: A3B7K2 | Machine: blockhost | Sign at: /sign"
3. Two signing paths:
Path A: Open /sign (serves engine signing page), sign, paste signature
Path B: Engine-specific inline signing (e.g. MetaMask for EVM), auto-submits
4. POST /api/auth/verify {code, signature}
5. Backend: bw who admin → queries chain for NFT owner → 0x...
6. Backend: bw who "<message>" <signature> → recovers signer address
7. Compare recovered address with NFT owner (case-insensitive)
8. Match → session cookie → redirect to dashboard
Message format: "Authenticate to {hostname} with code: {code}" — same as PAM module.
Auth state (module-level dicts in auth.py):
_challenges: code → expiry (TTL 300s, one-time use)_sessions: token → (address, expiry) (TTL 3600s)- Admin wallet resolved via
bw who admin→ queriesownerOf(credential_nft_id)on-chain (cached 60s) - Signature verification:
bw who <message> <signature>subprocess (blockhost-engine) - Format constraints (address/signature patterns): loaded from engine manifest
constraintskey at startup - If NFT #0 is transferred, the new holder becomes admin — no config changes needed
Session config: SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE=Lax, SESSION_COOKIE_SECURE=True, PERMANENT_SESSION_LIFETIME=1h.
| Page | Cards | Read Source | Writable |
|---|---|---|---|
| System & Storage | System, Storage | /proc/uptime, /etc/os-release, lsblk -J, shutil.disk_usage() |
Hostname (hostnamectl) |
| Network & Security | Network, Security, Admin Path | ip -j addr/route, /etc/resolv.conf, broker-allocation.json, admin-commands.json, admin.json |
Broker lease (root agent), knock settings, admin path (root agent) |
| Wallet | Wallet, Addressbook | addressbook.json, bw balance, /opt/blockhost/.env |
Transfer (bw send), withdraw (bw withdraw), addressbook CRUD (ab CLI) |
| VMs & Accounts | VMs, Accounts (placeholder) | blockhost-vm-list --format json |
Start/stop/kill/destroy via provisioner CLI |
GET /login → login page (standalone, no auth)
GET /sign → serve signing page HTML
POST /api/auth/verify → {code, signature} → verify → set session → {"ok": true}
GET /logout → invalidate session → redirect /login
GET / → System & Storage page (@login_required)
GET /network → Network & Security page
GET /wallet → Wallet Management page
GET /vms → VMs & Accounts page
GET /api/system → {hostname, uptime_seconds, os, kernel}
POST /api/system/hostname → set hostname
GET /api/network → {ipv4, gateway, dns, ipv6_broker}
POST /api/network/broker/renew → root agent broker-renew
GET /api/security → admin-commands.json contents
POST /api/security → update knock settings
GET /api/admin/path → {path_prefix}
POST /api/admin/path → update path prefix (writes admin.json, root agent updates nginx)
GET /api/storage → {devices, usage, boot_device}
GET /api/wallet → addressbook.json wallet list [{role, address, can_sign}]
GET /api/wallet/balance/<role> → bw balance <role>
POST /api/wallet/send → bw send (amount, token, from, to)
POST /api/wallet/withdraw → bw withdraw [token] <to>
POST /api/addressbook/add → ab add <name> <address>
POST /api/addressbook/remove → ab del <name>
POST /api/addressbook/generate → ab new <name>
GET /api/vms → blockhost-vm-list --format json
POST /api/vms/<name>/{start,stop,kill,destroy}
All data/action endpoints protected by @login_required. All POST actions return {"ok": bool, "error": "..."}. VM names validated against ^[a-z0-9-]{1,64}$.
Full interface specification: facts/ENGINE_INTERFACE.md
One active engine per host. Package installs manifest at well-known path.
Example (EVM). OPNet uses name: "opnet", wizard_module: "blockhost.engine_opnet.wizard",
32-byte addresses (^0x[0-9a-fA-F]{64}$), native_token: "btc". Structure identical.
{
"name": "evm",
"version": "0.2.0",
"display_name": "EVM (Ethereum/Polygon)",
"setup": {
"wizard_module": "blockhost.engine_evm.wizard",
"finalization_steps": ["keypair", "wallet", "contracts", "chain_config"],
"post_finalization_steps": ["mint_nft", "plan", "revenue_share"]
},
"config_keys": { "session_key": "blockchain" },
"constraints": {
"address_pattern": "^0x[0-9a-fA-F]{40}$",
"signature_pattern": "^0x[0-9a-fA-F]{130}$",
"native_token": "eth",
"native_token_label": "ETH",
"token_pattern": "^0x[0-9a-fA-F]{40}$",
"address_placeholder": "0x..."
}
}Same pattern as provisioner discovery. Loads manifest JSON, imports wizard module, extracts Flask blueprint.
| Extension | Mechanism | Provider |
|---|---|---|
| Wizard step | Flask Blueprint via wizard_module |
Engine .deb |
| Pre-finalization | get_finalization_steps() from wizard module |
Engine .deb |
| Post-finalization | get_post_finalization_steps() from wizard module |
Engine .deb |
| Summary section | get_summary_data() + get_summary_template() |
Engine .deb |
| UI parameters | get_ui_params(session) → eng_ui context variable (optional) |
Engine .deb |
| Address validation | validate_address(address) → bool |
Engine .deb |
| Format constraints | constraints in manifest (address/signature/token patterns, native token) |
Engine .deb |
| Keypair generation | generate_keypair() → (private_key, address) |
Engine .deb |
| Progress step metadata | get_progress_steps_meta() → list[dict] |
Engine .deb |
The module at blockhost.engine_<name>.wizard must export:
| Export | Type | Description |
|---|---|---|
blueprint |
flask.Blueprint |
Routes for wizard page + API endpoints |
get_finalization_steps() |
-> list[tuple] |
Pre-provisioner steps (keypair, wallet, contracts, config) |
get_post_finalization_steps() |
-> list[tuple] |
Post-nginx steps (mint, plan, revenue_share) |
get_summary_data(session_data) |
-> dict |
Summary values for summary page |
get_summary_template() |
-> str |
Path to custom summary template partial |
get_ui_params(session_data) |
-> dict |
Chain-specific template variables |
get_progress_steps_meta() |
-> list[dict] |
Step metadata for progress UI (id, label, hint) |
validate_address(address) |
-> bool |
Chain-specific address validation |
get_wallet_template() |
-> Optional[str] |
Custom wallet connect template path |
validate_signature(sig) |
-> bool |
Signature format validation |
decrypt_config(sig, ciphertext) |
-> dict |
Decrypt config backup (chain-specific key derivation) |
encrypt_config(sig, plaintext) |
-> str |
Encrypt config backup (chain-specific key derivation) |
Full interface specification: facts/PROVISIONER_INTERFACE.md
One active provisioner per host. Package installs manifest at well-known path.
Example (Proxmox). Commands section is identical across provisioners; setup, root_agent_actions,
and config_keys vary. See facts/PROVISIONER_INTERFACE.md for the full schema.
{
"name": "proxmox",
"version": "0.2.0",
"display_name": "Proxmox VE + Terraform",
"commands": { "create": "blockhost-vm-create", "destroy": "blockhost-vm-destroy", "..." : "..." },
"setup": {
"first_boot_hook": "/usr/share/blockhost/provisioner-hooks/first-boot.sh",
"detect": "blockhost-provisioner-detect",
"wizard_module": "blockhost.provisioner_<name>.wizard",
"finalization_steps": ["..."]
},
"root_agent_actions": "/usr/share/blockhost/root-agent-actions/<name>.py",
"config_keys": { "session_key": "<name>", "provisioner_config": ["..."] }
}| Proxmox | libvirt | |
|---|---|---|
finalization_steps |
["token", "terraform", "bridge", "template"] |
["storage", "network", "template"] |
root_agent_actions |
qm.py |
virsh.py |
config_keys.provisioner_config |
["terraform_dir", "vmid_range"] |
["storage_pool"] |
Loads manifest, dispatches CLI commands by verb → binary name. Falls back to legacy hardcoded paths when no manifest exists (transition period).
| Extension | Mechanism | Provider |
|---|---|---|
| Wizard step | Flask Blueprint via wizard_module |
Provisioner .deb |
| Finalization | get_finalization_steps() from wizard module (3- or 4-tuples) |
Provisioner .deb |
| Summary section | get_summary_data() + get_summary_template() |
Provisioner .deb |
| UI parameters | get_ui_params(session) → prov_ui context variable (optional) |
Provisioner .deb |
| First-boot hook | setup.first_boot_hook script |
Provisioner .deb |
| Root agent actions | .py modules in /usr/share/blockhost/root-agent-actions/ |
Provisioner .deb |
| CLI commands | Binaries named in commands dict |
Provisioner .deb |
All provisioner commands use vm_name (string) as the VM identifier:
create <name> --owner-wallet <addr> [--nft-token-id <int>] [--expiry-days N] [--cpu N] [--memory N] [--disk N] [--apply] [--cloud-init-content <path>]destroy <name>,start <name>,stop <name>,kill <name>status <name>→ stdout:active,suspended,destroyed,unknownlist [--format json]→ stdout: list of VMs
Flask session populated across wizard steps:
session = {
'authenticated': bool,
'admin_wallet': str, # from /wizard/wallet (format: engine-specific)
'admin_signature': str, # wallet signature
'admin_public_secret': str, # signing message (e.g. 'blockhost-access')
'selected_disk': '/dev/sda', # from /wizard/storage
'<engine_session_key>': { # from /wizard/<engine> (key from engine manifest config_keys.session_key)
# Engine-specific keys (e.g. EVM: chain_id, rpc_url, deployer_key, contracts, plan, revenue_share)
# Structure defined by engine wizard module, not hardcoded in installer
},
'<prov_session_key>': { # from /wizard/<provisioner> (key from provisioner manifest config_keys.session_key)
'ip_network': '192.168.122.0/24',
'ip_start': '200',
'ip_end': '250',
'gateway': '192.168.122.1',
'gc_grace_days': 7,
# ... plus provisioner-specific keys (vmid_range, storage, etc.)
},
'ipv6': { # from /wizard/ipv6
'mode': 'broker'|'manual',
'prefix': '2001:db8::/48',
'broker_registry': '0x...',
'broker_node': str,
'wg_config': str,
'allocation_size': 64,
},
'admin_commands': { # from /wizard/admin_commands
'enabled': bool,
'destination_mode': 'self'|'external',
'knock_command': 'blockhost',
'knock_ports': [22],
'knock_timeout': 300,
},
}Directory: /etc/blockhost/
| File | Format | Owner:Group | Permissions | Written by step | Read by |
|---|---|---|---|---|---|
| server.key | hex 64 chars | root:blockhost | 0640 | engine:keypair | blockhost-engine, provisioner |
| server.pubkey | hex 0x04+... | root:blockhost | 0644 | engine:keypair | signup page, NFT mint |
| deployer.key | hex / mnemonic (engine-specific) | root:blockhost | 0640 | engine:wallet | engine contract calls |
| db.yaml | YAML | root:blockhost | 0644 | provisioner:db_config, installer:ipv6 appends ipv6_pool | blockhost-provisioner, blockhost-gc |
| web3-defaults.yaml | YAML | root:blockhost | 0644 | engine:chain_config | blockhost-engine, blockhost-provisioner-proxmox |
| blockhost.yaml | YAML | root:blockhost | 0644 | engine:chain_config | blockhost-engine, signup generator |
| https.json | JSON | root:blockhost | 0644 | https | nginx config generation |
| pve-token | text | root:blockhost | 0640 | token | blockhost-provisioner-proxmox (Terraform) |
| terraform_ssh_key | PEM | root:blockhost | 0640 | token | Terraform SSH provisioner |
| terraform_ssh_key.pub | PEM | root:blockhost | 0644 | token | VM authorized_keys |
| admin-signature.key | hex | root:blockhost | 0640 | finalize | admin command verification |
| admin-commands.json | JSON | root:blockhost | 0644 | finalize | blockhost-engine src/admin/ |
| broker-allocation.json | JSON | root:blockhost | 0644 | ipv6 | blockhost-broker-client |
| addressbook.json | JSON | root:blockhost | 0640 | engine:revenue_share/root-agent | blockhost-engine fund-manager, bw, ab CLIs |
| revenue-share.json | JSON | root:blockhost | 0644 | engine:revenue_share | blockhost-engine fund-manager |
| hot.key | hex 64 chars | root:blockhost | 0640 | root-agent (auto) | blockhost-engine fund-manager (hot wallet signing) |
db_file: /var/lib/blockhost/vms.json
bridge: br0 # from first-boot bridge discovery
terraform_dir: /var/lib/blockhost/terraform # Proxmox only
vmid_range: {start: 100, end: 999} # Proxmox only
ip_pool: {network: '192.168.122.0/24', start: 200, end: 250, gateway: '192.168.122.1'}
ipv6_pool: {start: 2, end: 254}
default_expiry_days: 30
gc_grace_days: 7blockchain: {chain_id: 11155111, rpc_url: str, nft_contract: str, subscription_contract: str, usdc_address: str}
auth: {otp_length: 6, otp_ttl_seconds: 300, public_secret: 'blockhost-access'}
signing_page: {html_path: '/usr/share/blockhost/signing-page/index.html', port: 8443}
deployer: {private_key_file: '/etc/blockhost/deployer.key'}
server: {public_key: '0x04...'}server: {address: '0x...', key_file: '/etc/blockhost/server.key'}
deployer: {key_file: '/etc/blockhost/deployer.key'}
proxmox: {node: str, storage: str, bridge: str}
server_public_key: '0x04...'
public_secret: 'blockhost-access'
admin: {wallet_address: '0x...', credential_nft_id: 0, max_command_age: 300, destination_mode: 'self'}credential_nft_id: Written by engine's mint_nft post-step after minting. Maps the admin alias in bw who admin to an NFT token ID. Default 0 (first mint).
{
"admin": { "address": "0x1234...abcd" },
"server": { "address": "0x5678...ef01", "keyfile": "/etc/blockhost/deployer.key" },
"dev": { "address": "0xe35B5D114eFEA216E6BB5Ff15C261d25dB9E2cb9" },
"broker": { "address": "0x6A5973DDe7E57686122Eb12DA85389c53fe2EE4b" }
}Maps role names to wallet objects. Each entry has address (required) and optionally keyfile (path to private key, only for wallets whose keys live on this machine). Always written during finalization. The engine's fundManager auto-generates a hot entry on first launch.
admin: always present — the operator's wallet, connected via MetaMask during the wizard wallet stepserver: always present — the deployer wallet address (derived from deployer key), withkeyfiledev: only present when operator opted in to dev revenue sharingbroker: only present when operator opted in to broker revenue sharing AND a broker allocation exists; address read frombroker_walletinbroker-allocation.json(recorded by broker-client from the broker'ssubmitResponsetransaction sender)
{
"enabled": true,
"total_percent": 1.0,
"recipients": [
{"role": "dev", "percent": 0.5},
{"role": "broker", "percent": 0.5}
]
}Revenue sharing config. References roles by name (wallets in addressbook.json). Percent split equally among selected recipients.
Directory: /var/lib/blockhost/
| File | Format | Purpose |
|---|---|---|
| .setup-complete | empty | Marker: setup finished (prevents re-run) |
| setup-state.json | JSON | Finalization progress (steps, status, config) |
| vms.json | JSON | VM database (vmid → metadata, IP, NFT token, subscription, expiry) |
| vms.json.lock | empty | Lockfile for atomic DB updates (separate from data file) |
| pipeline.json | JSON | Reserved for future use |
| terraform/ | dir | Terraform state, provider config, .tfvars, per-VM .tf.json |
| template-packages/ | dir | .debs for VM template builds: libpam-web3_.deb, blockhost-auth-svc_.deb (OPNet) |
| validation-output.txt | text | Validation report (testing mode only) |
| fund-manager-state.json | JSON | Fund manager last-run timestamps (auto-created by engine) |
OTP state: /run/blockhost/otp.json (tmpfs, cleared on reboot)
Bridge name: /run/blockhost/bridge (tmpfs, written by first-boot Step 3a, re-populated on resume)
Each submodule is a separate git repo. This project consumes their .deb outputs only.
| Package | Build | Install target | Contents |
|---|---|---|---|
| libpam-web3 | packaging/build-deb.sh | VM template-packages/ | PAM module (pam_web3.so) |
PAM module (Rust, installed in VMs):
- Config:
/etc/pam_web3/config.toml(machine.id, machine.secret_key, auth settings) - Auth: verify identity → check
wallet=ADDRESSin GECOS field (no blockchain query) - Input detection: raw hex = EVM (ecrecover), JSON
.sigfile = OPNet (OTP + wallet assertion) - OTP: HMAC-SHA3(machine_id + timestamp + secret_key), 6 chars, 5min TTL
- Callback: detects auth-svc via
/run/libpam-web3/pending/directory presence (no config flag) - Security: JSON path only accepted from
.sigfiles (callback mode), never from terminal paste - PAM scope: guarded by
pam_succeed_if.so user = <provisioned_user>, others getpam_deny
bhcrypt CLI and auth-svc are shipped by blockhost-engine (see engine components table).
Encryption schemes (ecies.rs): secp256k1 ECIES, x25519, AES-256-GCM
| Package | Build | Install target |
|---|---|---|
| blockhost-common | build.sh | Host |
Python module (blockhost.*, installed to /usr/lib/python3/dist-packages/):
from blockhost.config import load_db_config, load_web3_config, load_blockhost_config
from blockhost.vm_db import VMDatabase, MockVMDatabase, get_database
db = get_database() # Returns VMDatabase (prod) or MockVMDatabase (--mock)
vmid = db.allocate_vmid() # Next available VMID from pool
db.reserve_nft_token_id() # Sequential NFT token ID
db.mark_nft_minted(token_id) # After successful mint
db.mark_nft_failed(token_id) # If VM creation fails (never reuse failed IDs)Reads: /etc/blockhost/db.yaml, /etc/blockhost/web3-defaults.yaml
Dev mode: BLOCKHOST_DEV=1 falls back to ./config/ directory
Dependency: Required by all provisioner packages and blockhost-engine.
| Package | Build | Install target |
|---|---|---|
| blockhost-provisioner-proxmox | build-deb.sh | Proxmox host |
Scripts:
| Script | Purpose | Key args |
|---|---|---|
vm-generator.py |
Create VM | <name> --owner-wallet <addr> [--nft-token-id <int>] [--expiry-days N] [--apply] [--cpu N --memory N --disk N] |
vm-gc.py |
Garbage collect expired VMs | [--execute] [--suspend-only] [--grace-days N] |
build-template.sh |
Build Debian 12 VM template | [PROXMOX_HOST=root@ix TEMPLATE_VMID=9001] |
vm-generator.py workflow:
- Allocate VMID + IPv4 from pool + IPv6 /128 from prefix
- Derive FQDN from
dns_zoneif available:f"{offset:x}.{dns_zone}"(hex offset of IPv6 within prefix) - Render cloud-init from
nft-auth.yamltemplate (GECOS:wallet=ADDR, SIGNING_DOMAIN for HTTPS) - Generate
.tf.jsonin terraform_dir terraform apply(if --apply)- Return JSON summary with connection details (ip, vmid, nft_token_id) Note: NFT token reservation, encryption, and minting are engine responsibilities (see subscription flow).
Cloud-init templates (cloud-init/templates/):
nft-auth.yaml— Default: web3 NFT auth, GECOS wallet=ADDRESSwebserver.yaml— Basic webserverdevbox.yaml— Dev environment
VM naming: blockhost-XXX (3-digit zero-padded subscription ID)
Dependencies: blockhost-common, blockhost-engine (bhcrypt), Terraform (bpg/proxmox provider), libguestfs-tools
| Package | Build | Install target |
|---|---|---|
| blockhost-engine | packaging/build.sh | Host |
Components (TypeScript, language-specific contracts):
Two engine packages exist: blockhost-engine (EVM) and blockhost-engine-opnet (OPNet/Bitcoin L1).
Only one is installed per host. Both implement the same ENGINE_INTERFACE.md contract.
| Directory | Language | Purpose |
|---|---|---|
contracts/ |
Solidity / OPNet TS | Chain-specific smart contracts |
src/monitor/ |
TypeScript | Blockchain event polling (watches for subscription events) |
src/handlers/ |
TypeScript | Event handlers (calls provisioner, encrypts, mints, update-gecos) |
src/admin/ |
TypeScript | On-chain admin commands (ECIES-encrypted, anti-replay nonce) |
src/reconcile/ |
TypeScript | Periodic NFT ownership reconciliation → GECOS sync via provisioner update-gecos |
src/fund-manager/ |
TypeScript | Automated fund withdrawal, revenue sharing, gas management |
src/bw/ |
TypeScript | blockwallet CLI (bw send, bw balance, bw withdraw, bw swap, bw split, bw who, bw plan create, bw config stable, bw set encrypt) |
src/ab/ |
TypeScript | addressbook CLI (ab add, ab del, ab up, ab new, ab list, ab --init) |
src/is/ |
TypeScript | identity predicate CLI (is <wallet> <nft_id>, is contract <address>) |
src/auth-svc/ |
TypeScript | Signing page HTTPS server + callback API (installed on VMs via blockhost-auth-svc template pkg, port 8443) |
src/bhcrypt.ts |
TypeScript | Crypto CLI (keypair gen, ECIES decrypt, symmetric encrypt/decrypt) |
scripts/generate-signup-page |
Python | Generates signup.html from template (extensionless) |
scripts/mint_nft |
Python/TS | Mint access credential NFT (extensionless, --owner-wallet, --user-encrypted) |
scripts/deploy-contracts |
Bash/TS | Deploy smart contracts (extensionless) |
Smart contracts:
AccessCredentialNFT (ERC721, stripped):
mint(address to, bytes userEncrypted) — owner-only, 2 params
updateUserEncrypted(uint256 tokenId, bytes data) — owner-only
getUserEncrypted(uint256 tokenId) → bytes — view
Standard ERC721/Enumerable/Ownable — transfer, burn, ownerOf, totalSupply
Full spec: facts/NFT_CONTRACT_INTERFACE.md
BlockhostSubscriptions:
Events (monitored by blockhost-monitor):
SubscriptionCreated, SubscriptionExtended, SubscriptionCancelled
PlanCreated, PlanUpdated, PaymentMethodAdded, PaymentMethodUpdated
Full spec: chain-specific (EVM: Solidity, OPNet: TypeScript contracts)
Fund manager (integrated into monitor polling loop):
- Runs fund cycle (every 24h default): withdraw contract funds → hot wallet gas top-up → server stablecoin buffer → revenue shares → remainder to admin
- Runs gas check (every 30min default): top up hot wallet ETH, swap USDC→ETH if server low
- Hot wallet auto-generated on first fund cycle, key saved to
/etc/blockhost/hot.key(0600), added to addressbook.json ashotentry - Config:
fund_manager:key inblockhost.yaml(all settings have defaults, section optional) - Reads:
addressbook.json,revenue-share.json
Services:
blockhost-monitor.service— Event watcher + fund manager (TypeScript:npm run monitor)- Maintenance scheduler — suspend/destroy lifecycle for expired subscriptions
Config reads: /etc/blockhost/web3-defaults.yaml, /etc/blockhost/blockhost.yaml, /etc/blockhost/admin-commands.json, /etc/blockhost/addressbook.json, /etc/blockhost/revenue-share.json
| Package | Build | Install target |
|---|---|---|
| blockhost-broker-client | scripts/build-deb.sh | Host (client only) |
Client CLI (broker-client, Python):
| Command | Purpose |
|---|---|
request --nft-contract <0x> --wallet-key <path> |
Request IPv6 allocation |
status |
Check allocation status |
list-brokers |
List available brokers from registry |
install |
Install persistent WireGuard config |
release --wallet-key <path> [--cleanup-wg] |
Release allocation |
On-chain allocation flow:
- Client queries
BrokerRegistry(global contract) for available brokers - Client generates WireGuard keypair + ECIES keypair
- Client encrypts request with broker's public key (secp256k1 ECIES)
- Client calls
BrokerRequests.submitRequest(nftContract, encryptedPayload)- Auth: broker verifies NFT contract exists +
Ownable.owner() == msg.sender
- Auth: broker verifies NFT contract exists +
- Broker daemon (Rust) detects request via lazy polling
- Broker allocates prefix from SQLite IPAM, adds WireGuard peer
- Broker encrypts response with client's public key
- Broker calls
BrokerRequests.submitResponse(requestId, encryptedPayload) - Client decrypts response → configures WireGuard tunnel
Re-requests: Same NFT contract submitting a new request gets the same allocation with updated WireGuard public key (supports key rotation without losing prefix).
Contracts (Sepolia):
- BrokerRegistry:
0x0E5b567E7d5C5c36D8fD70DE8129c35B473d0Aaf - Registry config: https://raw.githubusercontent.com/mwaddip/blockhost-broker/main/registry.json
Broker daemon (Rust, runs on separate VPS — NOT on Proxmox host):
- WireGuard on port 51820
- SQLite IPAM database
- Config:
/etc/blockhost-broker/config.toml(includeswireguard.upstream_interfacefor NDP proxy) - ECIES keypair:
/etc/blockhost-broker/ecies.key - Silent rejections (invalid requests just expire)
- NDP proxy: auto-manages proxy entries for allocated prefixes on upstream interface (e.g. SIT tunnels); configures
net.ipv6.conf.all.forwarding=1,proxy_ndp=1, UFW rules
Broker manager (separate package, Flask web UI on port 8443):
- Wallet-based auth (MetaMask + nonce signing)
- View/release active leases, wallet info + ETH top-up
- Configurable session expiry (default 1hr,
SESSION_LIFETIME_HOURS)
| Symbol | Purpose |
|---|---|
| WIZARD_STEPS | Ordered wizard step list (core + engine + provisioner + post) |
| SETUP_STATE_FILE | Path to setup-state.json |
| class SetupState | Persistent finalization state management |
| _discover_engine() | Load engine manifest + import wizard module |
| _discover_provisioner() | Load provisioner manifest + import wizard module |
| create_app() | Flask app factory with all routes |
| _build_vm_template() | Background template build (uses _jobs) |
| Function | Purpose |
|---|---|
| get_finalization_steps(provisioner, engine) | Build step list (engine pre + provisioner + post + engine post + final) |
| run_finalization_with_state(state, config, prov, engine) | Step dispatcher loop |
| run_finalization(job_id, config, jobs, prov, engine) | Legacy wrapper |
| _finalize_ipv6 | Broker allocation or manual prefix |
| _finalize_https | dns_zone / sslip.io hostname + Let's Encrypt |
| _finalize_signup | blockhost-generate-signup (static file only) |
| _finalize_nginx | Install + configure nginx reverse proxy |
| _finalize_complete | Permissions, markers, enable services |
| _finalize_validate | System validation (testing only) |
| Function | Purpose |
|---|---|
| detect_disks | List available disks (lsblk) |
| is_valid_address | Basic address validation (fallback when no engine loaded) |
| is_valid_ipv6_prefix | Validate prefix/length format |
| get_broker_registry | Look up broker registry by chain_id |
| get_wallet_balance | eth_getBalance via JSON-RPC |
| fetch_broker_registry_from_github | Fetch registry address from GitHub |
| request_broker_allocation | Call blockhost-broker-client CLI |
| write_yaml | Write YAML file (pyyaml or fallback) |
| set_blockhost_ownership | Set file to root:blockhost |
| generate_self_signed_cert | Self-signed cert for run_server |
| generate_self_signed_cert_for_finalization | Self-signed cert for HTTPS step |
On-chain calls from the installer (chain-agnostic):
| Action | Tool | Contract | Function | Called by |
|---|---|---|---|---|
| Broker allocation | broker-client | BrokerRequests | submitRequest(address,bytes) | _finalize_ipv6 |
On-chain calls from the engine wizard plugin (chain-specific, e.g. EVM):
| Action | Tool | Contract | Function | Called by |
|---|---|---|---|---|
| Deploy contracts | blockhost-deploy-contracts CLI | — | — | engine:contracts step |
| Create plan | bw plan create CLI | BlockhostSubscriptions | createPlan(string,uint256) | engine:plan step |
| Set stablecoin | bw config stable CLI | BlockhostSubscriptions | setPrimaryStablecoin(address) | engine:plan step |
| Mint NFT | blockhost-mint-nft CLI | AccessCredentialNFT | mint(address, bytes) | engine:mint_nft step |
| Init addressbook | ab --init CLI | — | — | engine:revenue_share step |
Runtime on-chain interactions (by submodule services, not this repo):
| Action | Service | Contract | Events/Functions |
|---|---|---|---|
| Watch subscriptions | blockhost-monitor (TypeScript) | BlockhostSubscriptions | SubscriptionCreated, SubscriptionExtended, SubscriptionCancelled |
| Query expired | blockhost-gc (Python) | BlockhostSubscriptions | Off-chain expiry check via getSubscription() |
| Admin commands | blockhost-engine src/admin/ | — | ECIES-encrypted on-chain commands |
| NFT reconciliation | blockhost-engine src/reconcile/ | AccessCredentialNFT | Periodic ownership check → GECOS sync via provisioner update-gecos |
Internet
│
├─ nginx :443 (TLS terminator, Let's Encrypt cert)
│ ├─ / → static: /var/www/blockhost/signup.html
│ └─ {path_prefix}/* → proxy: 127.0.0.1:8443 (Flask admin panel, strips prefix; default /admin, configurable via admin.json)
│
├─ eth0/ens* (physical NIC, bridge port after first-boot Step 3a)
│
├─ br0 (Linux bridge, created by first-boot Step 3a)
│ │ Proxmox: may be vmbr0 (pre-existing or created by PVE installer)
│ ├─ Host bridge IP (migrated from NIC; same subnet as VMs)
│ └─ VM NICs (tap devices)
│ ├─ VMs get IPv4 from ip_pool range
│ └─ Each VM serves signing page on port 8443 (HTTPS, self-signed TLS via engine auth-svc)
│
└─ wg-broker (WireGuard, if broker mode)
└─ IPv6 prefix from broker allocation
├─ VMs get /128 from ipv6_pool range (host routes added per VM)
└─ DNS: {hex_offset}.{dns_zone} → prefix::{hex_offset} (broker authoritative DNS)
└─ Enables Let's Encrypt on VM signing pages (replaces self-signed cert)
| User | UID type | Purpose |
|---|---|---|
| root | system | Runs root-agent daemon only |
| blockhost | system (nologin) | Runs all other runtime services |
Created by first-boot Step 2b. Group blockhost grants read access to config files in /etc/blockhost/.
blockhost-root-agent.service — Python asyncio daemon running as root.
- Socket:
/run/blockhost/root-agent.sock(root:blockhost 0660, auto-created viaRuntimeDirectory=blockhost) - Protocol: 4-byte big-endian length prefix + JSON payload (both directions)
- Response:
{"ok": true, ...}or{"ok": false, "error": "reason"} - Install path:
/usr/share/blockhost/root-agent/blockhost_root_agent.py(shipped by blockhost-common .deb)
Common actions (from blockhost-common):
| Action | Params | Caller |
|---|---|---|
ip6-route-add |
address:str(/128), dev:str | provisioner (vm-create) |
ip6-route-del |
address:str(/128), dev:str | provisioner (vm-gc, vm-destroy) |
iptables-open |
port:int, proto:str, comment:str | engine (admin knock) |
iptables-close |
port:int, proto:str, comment:str | engine (admin knock) |
virt-customize |
image_path:str, commands:list | provisioner (template build) |
generate-wallet |
name:str | engine (fund-manager, ab CLI) |
addressbook-save |
entries:dict | engine (fund-manager, ab CLI) |
Proxmox provisioner actions (from qm.py):
| Action | Params | Caller |
|---|---|---|
qm-start |
vmid:int | provisioner (vm-resume), engine (cancellation) |
qm-stop |
vmid:int | provisioner (vm-gc) |
qm-shutdown |
vmid:int | provisioner (vm-gc) |
qm-destroy |
vmid:int | provisioner (vm-gc) |
qm-create |
vmid:int, name:str, args:list | provisioner (vm-generator) |
qm-importdisk |
vmid:int, disk_path:str, storage:str | provisioner |
qm-set |
vmid:int, options:dict | provisioner |
qm-template |
vmid:int | provisioner |
libvirt provisioner actions (from virsh.py):
| Action | Params | Caller |
|---|---|---|
virsh-start |
name:str | provisioner (vm-resume) |
virsh-shutdown |
name:str | provisioner (vm-gc) |
virsh-destroy |
name:str | provisioner (vm-gc, vm-destroy) |
virsh-reboot |
name:str | provisioner |
virsh-define |
xml:str | provisioner (vm-create) |
virsh-undefine |
name:str, flags:list | provisioner (vm-destroy) |
| Operation | Why unprivileged | Service |
|---|---|---|
terraform init/apply/destroy |
HTTP API auth, working dir owned by blockhost | provisioner |
bw who, is contract |
HTTP RPC, reads config via group perm | admin panel, installer validation |
bhcrypt decrypt/encrypt |
User-space binary, reads keys via group | provisioner, engine |
pgrep |
No privilege needed | engine (reconcile) |
| python3 db scripts | Writes to blockhost-owned /var/lib/blockhost/ |
engine |
| Path | Owner:Group | Mode |
|---|---|---|
/etc/blockhost/ |
root:blockhost | 750 |
/etc/blockhost/*.key |
root:blockhost | 640 |
/etc/blockhost/*.yaml |
root:blockhost | 640 |
/var/lib/blockhost/ |
blockhost:blockhost | 750 |
/var/lib/blockhost/terraform/ |
blockhost:blockhost | 750 |
/run/blockhost/root-agent.sock |
root:blockhost | 660 |
/opt/blockhost/.env |
root:blockhost | 640 |
| Service | User | Depends on root-agent |
|---|---|---|
| blockhost-root-agent | root | — |
| blockhost-monitor | blockhost | Yes (iptables, wallet, addressbook) |
| blockhost-gc | blockhost | Yes (provisioner actions, ip6-route) |
| blockhost-admin | blockhost | Yes (via bw, ab CLIs) |
| nginx | root (master) / www-data (workers) | No |
Three distinct encryption contexts:
| Context | Scheme | Key source | Purpose |
|---|---|---|---|
| NFT user data (userEncrypted) | AES-256-GCM | Chain-specific key derivation from wallet signature of publicSecret | Connection details (hostname, port, username) only decryptable by NFT holder |
| Broker allocation | secp256k1 ECIES | Broker's published public key / client's ephemeral key | Request/response payloads between client and broker |
| Admin commands | ECIES | Admin wallet | On-chain admin commands with anti-replay nonce |
NFT userEncrypted format: IV[12 bytes] || ciphertext || authTag[16 bytes] (hex-encoded)
publicSecret: Host config value (e.g. "blockhost-access"), stored in blockhost.yaml → auth.public_secret. Not an NFT field.
summary.network.{ip, gateway}
summary.ipv6.{mode, prefix, broker_node, broker_registry}
summary.admin.{wallet, enabled, destination_mode, command_count}
engine_summary # dict from engine module's get_summary_data(session), or None
engine_summary_template # template path from engine module's get_summary_template(), or None
provisioner_summary # dict from provisioner module's get_summary_data(session), or None
provisioner_summary_template # template path from provisioner module's get_summary_template(), or None
all_finalization_steps # list[dict] — all steps in pipeline order: {id, label, hint?}
# Rendered as dynamic <li> elements in progress list
# Source: engine pre + provisioner + installer post + engine post + finalwizard_steps # WIZARD_STEPS list for step bar rendering
prov_ui # dict from provisioner module's get_ui_params(session), or {} if no provisioner
# Keys: management_url, management_label, knock_ports_default, knock_description,
# storage_hint, storage_extra_hint (all optional, templates use | default())
eng_ui # dict from engine module's get_ui_params(session), or {} if no engine| Workflow | Trigger | Runner | Jobs |
|---|---|---|---|
ci.yml |
Push develop, PR master | ubuntu-latest | rust-tests, engine-tests, forge-tests, build-packages |
iso-build.yml |
workflow_dispatch, tag v* | self-hosted (blockhost-iso) | Build ISO, upload artifact |
integration.yml |
workflow_dispatch only | self-hosted (blockhost-{proxmox,libvirt}, blockhost-phone) | provision → integration-test → ipv6-login-test → cleanup |
Testing-only endpoint (legacy). Accepts all wizard config in a single JSON POST. Not used by CI anymore — CI steps through the wizard pages like a real user.
- Guard: Returns 404 unless
/etc/blockhost/.testing-modeexists (created bybuild-iso.sh --testing) - Auth: Verifies OTP from request body (same as
/login) - Flow: Populates Flask session → triggers finalization
- Poll: Returns
poll_url: /api/finalize/status(use cookie jar for session)
VM lifecycle automation for integration tests. Steps through the wizard like a real user (form POSTs to each wizard page, server-side wallet generation, then finalization).
Phase 1: virt-install (ISO boot, virbr0 NAT network)
Phase 2: Wait for preseed install (VM shuts off)
Phase 3: Eject ISO, boot from HDD (first-boot begins)
Phase 4: Poll SSH + /run/blockhost/otp.json (first-boot complete)
Phase 5: Read OTP via SSH
Phase 5.5: Step through wizard (login → wallet → network → storage →
generate-wallet → fund → engine → provisioner → ipv6 → admin)
Phase 6: POST /api/finalize, poll /api/finalize/status until completed
Phase 7-9: Read contracts, reboot, verify services
Output: VM_NAME, VM_IP, NFT_CONTRACT, REQUESTS_CONTRACT (to GITHUB_OUTPUT)
Backend is auto-detected from the config file's provisioner section key (e.g., proxmox or libvirt).
| Secret | Purpose |
|---|---|
DEPLOYER_KEY |
Deployer private key (owns contracts, funds test wallets) |
NFT_CONTRACT |
AccessCredentialNFT address on Sepolia |
SUBSCRIPTION_CONTRACT |
BlockhostSubscriptions address on Sepolia |
Single runner on dev machine with four labels:
blockhost-iso— has xorriso, isolinux, build toolchainsblockhost-proxmox— has virsh, virt-install, sshpass, sudoblockhost-libvirt— same runner, same tools (libvirt and proxmox CI both use virt-install)blockhost-phone— has adb-connected Android phone with carrier IPv6