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.
118 lines
3.4 KiB
Python
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",
|
|
)
|