2026-08-24 11:50:25 +02:00
|
|
|
"""Box (WireGuard peer) lifecycle: registration, rotation, deregistration."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import sqlite3
|
|
|
|
|
import uuid
|
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
|
|
|
|
|
from control_plane import routes, tokens
|
|
|
|
|
from control_plane.db import Database
|
|
|
|
|
from control_plane.passwd import hash_password, verify_password
|
feat: ship control-plane standalone, bring your own Traefik/wg-easy
Restructure the deployment model: instead of bundling and owning its own
Traefik + wg-easy, the control-plane now plugs into a Traefik and wg-easy
the operator already runs (as sibling containers, or otherwise reachable).
This avoids duplicating infra self-hosters commonly already have, and
sidesteps having to run a second ACME/DNS setup entirely — the operator's
own Traefik already has a working cert resolver.
- New control_plane/wgeasy_v15.py: a wg-easy v15 REST client, verified live
against a real running container (auth model, route shapes, and the new
per-client PresharedKey all differ materially from v14's API). Threaded
the preshared key through to the box side (furtka/wireguard.py in the
furtka repo) so v15's handshake actually completes.
- GATEWAY_CERT_RESOLVER is now a real env var instead of a GATEWAY_MODE-
derived hardcoded "le", so it can mirror whatever resolver name (if any)
the operator's own Traefik already has configured.
- Root docker-compose.yaml/ops/deploy.sh: control-plane only now. deploy.sh
asks how the operator's Traefik reaches it (same docker network, joined
via `docker network connect`, or a separate host) instead of prompting
for DNS-01 providers and port remaps single-parallel mode needed.
- single-parallel mode is retired outright — nothing needs it once
furtka-gateway doesn't own a second Traefik/wg-easy to remap ports for.
- The old bundled stack (own Traefik+wg-easy, HTTP-01) moves to
examples/starter-stack/ as the documented zero-infra on-ramp.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3jVwAqESRjPdW9P9nvfem
2026-09-19 13:34:29 +02:00
|
|
|
from control_plane.wgeasy import WgEasyProtocol
|
2026-08-24 11:50:25 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class BoxLimitExceeded(Exception):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _box_count(db: Database, account_id: str) -> int:
|
|
|
|
|
row = db.query_one("SELECT COUNT(*) AS n FROM boxes WHERE account_id = ?", (account_id,))
|
|
|
|
|
return row["n"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def register_box(
|
|
|
|
|
db: Database,
|
feat: ship control-plane standalone, bring your own Traefik/wg-easy
Restructure the deployment model: instead of bundling and owning its own
Traefik + wg-easy, the control-plane now plugs into a Traefik and wg-easy
the operator already runs (as sibling containers, or otherwise reachable).
This avoids duplicating infra self-hosters commonly already have, and
sidesteps having to run a second ACME/DNS setup entirely — the operator's
own Traefik already has a working cert resolver.
- New control_plane/wgeasy_v15.py: a wg-easy v15 REST client, verified live
against a real running container (auth model, route shapes, and the new
per-client PresharedKey all differ materially from v14's API). Threaded
the preshared key through to the box side (furtka/wireguard.py in the
furtka repo) so v15's handshake actually completes.
- GATEWAY_CERT_RESOLVER is now a real env var instead of a GATEWAY_MODE-
derived hardcoded "le", so it can mirror whatever resolver name (if any)
the operator's own Traefik already has configured.
- Root docker-compose.yaml/ops/deploy.sh: control-plane only now. deploy.sh
asks how the operator's Traefik reaches it (same docker network, joined
via `docker network connect`, or a separate host) instead of prompting
for DNS-01 providers and port remaps single-parallel mode needed.
- single-parallel mode is retired outright — nothing needs it once
furtka-gateway doesn't own a second Traefik/wg-easy to remap ports for.
- The old bundled stack (own Traefik+wg-easy, HTTP-01) moves to
examples/starter-stack/ as the documented zero-infra on-ramp.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3jVwAqESRjPdW9P9nvfem
2026-09-19 13:34:29 +02:00
|
|
|
wgeasy: WgEasyProtocol,
|
2026-08-24 11:50:25 +02:00
|
|
|
account_id: str,
|
|
|
|
|
box_name: str,
|
|
|
|
|
box_limit: int,
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Create a wg-easy peer for a new box and persist it.
|
|
|
|
|
|
|
|
|
|
Returns everything the box needs to bring its tunnel up itself: a box
|
|
|
|
|
token for future gateway API calls, and the full WireGuard interface
|
|
|
|
|
config (including the private key — see wgeasy.py's module docstring
|
|
|
|
|
for why the gateway ends up handling that at all).
|
|
|
|
|
"""
|
|
|
|
|
if _box_count(db, account_id) >= box_limit:
|
|
|
|
|
raise BoxLimitExceeded(account_id)
|
|
|
|
|
|
|
|
|
|
peer = wgeasy.create_client(box_name)
|
|
|
|
|
|
|
|
|
|
box_id = uuid.uuid4().hex
|
|
|
|
|
box_token, box_secret = tokens.issue(box_id)
|
|
|
|
|
now = datetime.now(UTC).isoformat()
|
|
|
|
|
db.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO boxes
|
|
|
|
|
(id, account_id, name, wg_public_key, wg_peer_id, wg_allowed_ip,
|
|
|
|
|
box_token_hash, registered_at, last_seen_at)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
|
|
|
|
""",
|
|
|
|
|
(
|
|
|
|
|
box_id,
|
|
|
|
|
account_id,
|
|
|
|
|
box_name,
|
|
|
|
|
peer["public_key"],
|
|
|
|
|
peer["id"],
|
|
|
|
|
peer["address"],
|
|
|
|
|
hash_password(box_secret),
|
|
|
|
|
now,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
"box_id": box_id,
|
|
|
|
|
"box_token": box_token,
|
|
|
|
|
"wg": {
|
|
|
|
|
"private_key": peer["private_key"],
|
|
|
|
|
"public_key": peer["public_key"],
|
|
|
|
|
"address": peer["address"],
|
|
|
|
|
"server_public_key": peer["server_public_key"],
|
|
|
|
|
"endpoint": peer["endpoint"],
|
|
|
|
|
"allowed_ips": peer["allowed_ips"],
|
feat: ship control-plane standalone, bring your own Traefik/wg-easy
Restructure the deployment model: instead of bundling and owning its own
Traefik + wg-easy, the control-plane now plugs into a Traefik and wg-easy
the operator already runs (as sibling containers, or otherwise reachable).
This avoids duplicating infra self-hosters commonly already have, and
sidesteps having to run a second ACME/DNS setup entirely — the operator's
own Traefik already has a working cert resolver.
- New control_plane/wgeasy_v15.py: a wg-easy v15 REST client, verified live
against a real running container (auth model, route shapes, and the new
per-client PresharedKey all differ materially from v14's API). Threaded
the preshared key through to the box side (furtka/wireguard.py in the
furtka repo) so v15's handshake actually completes.
- GATEWAY_CERT_RESOLVER is now a real env var instead of a GATEWAY_MODE-
derived hardcoded "le", so it can mirror whatever resolver name (if any)
the operator's own Traefik already has configured.
- Root docker-compose.yaml/ops/deploy.sh: control-plane only now. deploy.sh
asks how the operator's Traefik reaches it (same docker network, joined
via `docker network connect`, or a separate host) instead of prompting
for DNS-01 providers and port remaps single-parallel mode needed.
- single-parallel mode is retired outright — nothing needs it once
furtka-gateway doesn't own a second Traefik/wg-easy to remap ports for.
- The old bundled stack (own Traefik+wg-easy, HTTP-01) moves to
examples/starter-stack/ as the documented zero-infra on-ramp.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3jVwAqESRjPdW9P9nvfem
2026-09-19 13:34:29 +02:00
|
|
|
# Only present for wg-easy v15 (see wgeasy_v15.py) — v15
|
|
|
|
|
# generates a preshared key per client by default and the
|
|
|
|
|
# server enforces it even if the client's own conf omits it,
|
|
|
|
|
# so this must reach the box or the handshake fails silently.
|
|
|
|
|
"preshared_key": peer.get("preshared_key"),
|
2026-08-24 11:50:25 +02:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def authenticate_box_token(db: Database, token: str) -> sqlite3.Row | None:
|
|
|
|
|
parsed = tokens.split(token)
|
|
|
|
|
if parsed is None:
|
|
|
|
|
return None
|
|
|
|
|
box_id, secret = parsed
|
|
|
|
|
row = db.query_one("SELECT * FROM boxes WHERE id = ?", (box_id,))
|
|
|
|
|
if row is None:
|
|
|
|
|
return None
|
|
|
|
|
if not verify_password(secret, row["box_token_hash"]):
|
|
|
|
|
return None
|
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def touch_last_seen(db: Database, box_id: str) -> None:
|
|
|
|
|
db.execute(
|
|
|
|
|
"UPDATE boxes SET last_seen_at = ? WHERE id = ?",
|
|
|
|
|
(datetime.now(UTC).isoformat(), box_id),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def rotate_box_token(db: Database, box_id: str) -> str | None:
|
|
|
|
|
row = db.query_one("SELECT id FROM boxes WHERE id = ?", (box_id,))
|
|
|
|
|
if row is None:
|
|
|
|
|
return None
|
|
|
|
|
box_token, box_secret = tokens.issue(box_id)
|
|
|
|
|
db.execute(
|
|
|
|
|
"UPDATE boxes SET box_token_hash = ? WHERE id = ?",
|
|
|
|
|
(hash_password(box_secret), box_id),
|
|
|
|
|
)
|
|
|
|
|
return box_token
|
|
|
|
|
|
|
|
|
|
|
feat: ship control-plane standalone, bring your own Traefik/wg-easy
Restructure the deployment model: instead of bundling and owning its own
Traefik + wg-easy, the control-plane now plugs into a Traefik and wg-easy
the operator already runs (as sibling containers, or otherwise reachable).
This avoids duplicating infra self-hosters commonly already have, and
sidesteps having to run a second ACME/DNS setup entirely — the operator's
own Traefik already has a working cert resolver.
- New control_plane/wgeasy_v15.py: a wg-easy v15 REST client, verified live
against a real running container (auth model, route shapes, and the new
per-client PresharedKey all differ materially from v14's API). Threaded
the preshared key through to the box side (furtka/wireguard.py in the
furtka repo) so v15's handshake actually completes.
- GATEWAY_CERT_RESOLVER is now a real env var instead of a GATEWAY_MODE-
derived hardcoded "le", so it can mirror whatever resolver name (if any)
the operator's own Traefik already has configured.
- Root docker-compose.yaml/ops/deploy.sh: control-plane only now. deploy.sh
asks how the operator's Traefik reaches it (same docker network, joined
via `docker network connect`, or a separate host) instead of prompting
for DNS-01 providers and port remaps single-parallel mode needed.
- single-parallel mode is retired outright — nothing needs it once
furtka-gateway doesn't own a second Traefik/wg-easy to remap ports for.
- The old bundled stack (own Traefik+wg-easy, HTTP-01) moves to
examples/starter-stack/ as the documented zero-infra on-ramp.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3jVwAqESRjPdW9P9nvfem
2026-09-19 13:34:29 +02:00
|
|
|
def deregister_box(db: Database, wgeasy: WgEasyProtocol, box_id: str) -> bool:
|
2026-08-24 11:50:25 +02:00
|
|
|
row = db.query_one("SELECT wg_peer_id FROM boxes WHERE id = ?", (box_id,))
|
|
|
|
|
if row is None:
|
|
|
|
|
return False
|
|
|
|
|
routes.unpublish_all_for_box(db, box_id)
|
|
|
|
|
wgeasy.delete_client(row["wg_peer_id"])
|
|
|
|
|
db.execute("DELETE FROM boxes WHERE id = ?", (box_id,))
|
|
|
|
|
return True
|