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.