Skip to content

Control Plane Overview

CTFHive is a SaaS control plane that sells and provisions CTFHive CTF tenants. It is a Flask application (not FastAPI) that sits in front of Stripe, the Linode Cloud API, and the provisioner pipeline that runs Ansible-style playbooks over SSH.

The repository has two distinct planes:

Plane Location Purpose
Control plane (this page) CTF_Saas_CTRL_Pane/ Marketing site, billing, tenant lifecycle
Tenant app ctfapp/ The per-tenant CTF web app an organizer actually runs

When a customer signs up, the control plane allocates a Linode server, points a DNS record at it, runs the bootstrap playbook, and hands the organizer a live CTFHive instance. The control plane is not involved in a running event's day-to-day traffic — that all hits the tenant's own server.


Blueprint map

Three Flask blueprints are registered inside create_app:

Blueprint URL prefix What it does
marketing (none) Renders templates for /, /pricing, /about, /contact, /docs, /login, /register
billing /billing POST /billing/checkout and POST /billing/webhook
tenants /tenants GET /tenants/<tenant_id>/status (JSON)

The marketing routes are thin template renderers — they pass the canonical PLANS list from ctrlapp/pricing.py directly into the template so the prices shown to visitors and the prices charged at checkout cannot drift apart. The /login and /register routes render placeholder templates; authentication wiring is not yet implemented.


Request flow: checkout → active tenant

Browser / CLI
  ├─ GET /pricing  ──► marketing blueprint renders pricing.html
  └─ POST /billing/checkout   (plan=standard)
         ├─ validate plan slug (400 if unknown or enterprise/contact-only)
         ├─ service.create_tenant(name, tier, admin_email)  ← state: "provisioning"
         ├─ STRIPE_SECRET_KEY empty?
         │     YES → return {status: "checkout_not_configured", tenant_id}
         │     NO  → (stub) return {status: "pending", tenant_id}
Stripe sends checkout.session.completed
  └─ POST /billing/webhook
         ├─ verify Stripe-Signature header (stdlib HMAC-SHA256, 300 s tolerance)
         ├─ parse event JSON
         ├─ service.create_tenant(name, tier, admin_email)
         ├─ service.provision(tenant_id)   ← calls provisioner pipeline
         │       │
         │       ├─ build TenantSpec (server_secret, registry_password, SSH creds)
         │       ├─ provisioner.orchestrator.provision_tenant(spec, FakeExecutor)
         │       └─ tenant.state = "active" or "failed"
         └─ return {status: "ok", tenant_id, tenant_state}

Organizer polls:
  GET /tenants/<tenant_id>/status  → JSON (id, name, tier, state, fqdn, …)

The sys.path bootstrap

ctrlapp/__init__.py inserts the repo root onto sys.path at module import time:

_REPO_ROOT = os.path.dirname(
    os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
if _REPO_ROOT not in sys.path:
    sys.path.insert(0, _REPO_ROOT)

Without this, import provisioner fails with ModuleNotFoundError when the app is booted by gunicorn or flask run — neither of which adds the repo root to sys.path automatically. The conftest.py at CTF_Saas_CTRL_Pane/ does the same thing for pytest; the guard makes the duplicate insertion a no-op.


Current status

In-memory tenant store

ProvisionService keeps tenants in a process-local dict. All tenant state is lost when the process restarts. A Postgres-backed store is the planned follow-up; the public API surface (create_tenant, provision, get) will not change when it lands.

FakeExecutor by default

service.provision() uses FakeExecutor unless you inject a real ssh_executor_factory. With the fake executor the tenant transitions to "active" without touching any server. Real Linode provisioning requires setting LINODE_API_TOKEN and wiring the SSH factory — see ../operations/provisioning-tenants.md.

Checkout Session creation is a stub

When STRIPE_SECRET_KEY is set the checkout route returns {status: "pending"} but does not yet create a Stripe Checkout Session. Real payment collection happens via the webhook flow after you wire this up manually.