Files
devplacepy/devplacepy/services/containers/workspace/certs.py
T
retoor 372067bbe4 Order the workspace editor certificate at creation
A new tunnel row was inert at pending until the next WorkspaceService
tick picked it up, so the editor certificate waited up to 30 seconds
before the ACME order even started. Opening a workspace and reaching its
public hostname in that window served the wildcard default certificate
and produced ERR_CERT_COMMON_NAME_INVALID in the browser.

certs.certify now owns the whole transition - provisioning, issue,
active or failed, then the owner notification - and both callers use it.
provision.ensure schedules it for the editor tunnel the moment the
workspace exists, claiming the row synchronously so the tick can never
issue the same host twice and burn a duplicate-certificate slot. The
service loop keeps calling certify per pending row as the safety net for
user-created tunnels and for rows created while molohttp was
unconfigured.

Issuance is a loop task rather than an await: an order takes about ten
seconds and certs.issue allows up to 180, which would hang the workspace
POST. The task is held until it completes so it cannot be collected mid
flight.

resume also restarts the idle clock, and the workspace routes gain
HTTP-level tests covering every refusal path they had none for.
2026-08-08 13:52:02 +02:00

118 lines
3.4 KiB
Python

# retoor <retoor@molodetz.nl>
from __future__ import annotations
from collections.abc import Callable
from devplacepy.database import get_setting
from devplacepy.services.containers import store
from devplacepy.stealth import stealth_async_client
from . import tunnels
API_PATH = "/api/v1"
ISSUE_TIMEOUT_SECONDS = 180.0
class CertError(Exception):
pass
def _base_url() -> str:
return (get_setting("workspace_molohttp_base_url", "") or "").rstrip("/")
def _auth_headers() -> dict[str, str]:
api_key = get_setting("workspace_molohttp_api_key", "") or ""
if api_key:
return {"x-api-key": api_key}
return {}
def _basic_auth() -> tuple[str, str] | None:
username = get_setting("workspace_molohttp_username", "") or ""
password = get_setting("workspace_molohttp_password", "") or ""
if username and password:
return (username, password)
return None
def configured() -> bool:
return bool(_base_url()) and bool(_auth_headers() or _basic_auth())
async def issue(hostname: str) -> None:
if not hostname:
raise CertError("no hostname")
base = _base_url()
if not base:
raise CertError("workspace_molohttp_base_url is not configured")
headers = _auth_headers()
auth = _basic_auth()
if not headers and not auth:
raise CertError("no molohttp credentials configured")
payload: dict[str, object] = {"domains": [hostname]}
email = get_setting("workspace_acme_email", "") or ""
if email:
payload["email"] = email
async with stealth_async_client(timeout=ISSUE_TIMEOUT_SECONDS) as client:
response = await client.post(
f"{base}{API_PATH}/certs/issue",
json=payload,
headers=headers,
auth=auth,
)
if response.status_code >= 400:
raise CertError(
f"molohttp cert issue failed ({response.status_code}): "
f"{response.text[:200]}"
)
async def certify(row: dict, log: Callable[[str], None] | None = None) -> bool:
hostname = row.get("hostname", "")
tunnels.update(
row["uid"], {"status": tunnels.STATUS_PROVISIONING, "last_error": ""}
)
try:
await issue(hostname)
except Exception as error:
tunnels.update(
row["uid"],
{"status": tunnels.STATUS_FAILED, "last_error": str(error)[:500]},
)
if log:
log(f"tunnel certificate for {hostname} failed: {error}")
return False
tunnels.update(row["uid"], {"status": tunnels.STATUS_ACTIVE, "last_error": ""})
if log:
log(f"tunnel certificate issued for {hostname}")
announce(row, hostname)
return True
def announce(row: dict, hostname: str) -> None:
from devplacepy.utils import create_notification
from . import provision
instance = store.get_instance(row.get("instance_uid", ""))
if not instance:
return
owner_uid = instance.get("workspace_owner_uid", "")
if not owner_uid:
return
url = f"https://{hostname}"
if provision.is_editor_tunnel(instance, row):
password = instance.get("editor_password") or ""
message = f"Your VS Code workspace is live at {url} (password: {password})."
else:
message = f"Tunnel {row.get('label') or hostname} is live at {url}."
create_notification(
owner_uid,
"workspace",
message,
instance["uid"],
f"/projects/{instance.get('project_uid', '')}/workspace",
)