Skip to content

Commit ebac756

Browse files
committed
Show effective tunnel status
1 parent e106eab commit ebac756

11 files changed

Lines changed: 667 additions & 1 deletion

File tree

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ MAILCUE_CORS_ORIGINS=[]
4949
# tunnel overlay, its compose file configures the validation relay automatically.
5050
MAILCUE_VALIDATION_SMTP_PROBE_ENABLED=true
5151
MAILCUE_VALIDATION_SMTP_TIMEOUT_SECONDS=8
52+
# Optional sidecar status endpoint used by Settings > Tunnels.
53+
MAILCUE_TUNNEL_METRICS_URL=http://mailcue-sidecar:9325
5254
# Hard end-to-end budget; keep below the SDK's default 30-second timeout.
5355
MAILCUE_VALIDATION_TOTAL_TIMEOUT_SECONDS=25
5456
MAILCUE_VALIDATION_PROBE_RELAY_HOST=

backend/app/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ class Settings(BaseSettings):
125125

126126
# ── Tunnels (optional outbound relay through remote VPS edges) ─
127127
tunnels_config_path: str = "/etc/mailcue-sidecar/tunnels.json"
128+
tunnel_metrics_url: str = "http://mailcue-sidecar:9325"
128129

129130
@field_validator("mode", mode="before")
130131
@classmethod

backend/app/tunnels/router.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from app.database import get_db
1313
from app.dependencies import require_admin, require_scope
1414
from app.tunnels.schemas import (
15+
EffectiveTunnelStatusResponse,
1516
TunnelClientIdentityRequest,
1617
TunnelClientIdentityResponse,
1718
TunnelCreate,
@@ -23,6 +24,7 @@
2324
from app.tunnels.service import (
2425
create_tunnel,
2526
delete_tunnel,
27+
effective_tunnel_status,
2628
get_or_init_client_identity,
2729
get_tunnel,
2830
health_check,
@@ -98,6 +100,24 @@ async def reload_tunnels_config(
98100
# ── Tunnel CRUD ──────────────────────────────────────────────────
99101

100102

103+
@router.get(
104+
"/status",
105+
response_model=EffectiveTunnelStatusResponse,
106+
dependencies=[Depends(require_scope(scopes.TUNNEL_READ))],
107+
)
108+
async def get_effective_tunnel_status(
109+
_admin: User = Depends(require_admin),
110+
db: AsyncSession = Depends(get_db),
111+
) -> EffectiveTunnelStatusResponse:
112+
"""Return file-managed tunnels plus live sidecar health. **Admin only.**"""
113+
reachable, detail, tunnels = await effective_tunnel_status(db)
114+
return EffectiveTunnelStatusResponse(
115+
sidecar_reachable=reachable,
116+
status_detail=detail,
117+
tunnels=tunnels,
118+
)
119+
120+
101121
@router.get(
102122
"",
103123
response_model=list[TunnelResponse],

backend/app/tunnels/schemas.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,33 @@ class TunnelResponse(BaseModel):
122122
model_config = ConfigDict(from_attributes=True)
123123

124124

125+
class EffectiveTunnelStatus(BaseModel):
126+
"""Effective sidecar tunnel, including file-managed configurations."""
127+
128+
id: str
129+
name: str
130+
endpoint_host: str
131+
endpoint_port: int
132+
enabled: bool
133+
weight: int
134+
source: str
135+
managed: bool
136+
healthy: bool | None = None
137+
idle_connections: int | None = None
138+
inflight: int | None = None
139+
requests_ok: int | None = None
140+
requests_err: int | None = None
141+
last_success: datetime | None = None
142+
143+
144+
class EffectiveTunnelStatusResponse(BaseModel):
145+
"""Sidecar reachability and its effective tunnel configuration."""
146+
147+
sidecar_reachable: bool
148+
status_detail: str | None = None
149+
tunnels: list[EffectiveTunnelStatus]
150+
151+
125152
class TunnelClientIdentityRequest(BaseModel):
126153
"""Request body for upserting the client identity public key."""
127154

backend/app/tunnels/service.py

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,32 @@
1010
import json
1111
import logging
1212
import os
13+
import re
1314
import uuid
1415
from datetime import UTC, datetime
1516
from pathlib import Path
1617
from typing import Any
1718

19+
import httpx
1820
from sqlalchemy import select
1921
from sqlalchemy.ext.asyncio import AsyncSession
2022

2123
from app.config import settings
2224
from app.exceptions import ConflictError, NotFoundError, ValidationError
2325
from app.tunnels.models import Tunnel, TunnelClientIdentity
24-
from app.tunnels.schemas import TunnelCreate, TunnelUpdate
26+
from app.tunnels.schemas import EffectiveTunnelStatus, TunnelCreate, TunnelUpdate
2527

2628
logger = logging.getLogger("mailcue.tunnels")
2729

2830
_SIDECAR_KEY_PATH = "/var/lib/mailcue-sidecar/client.key"
2931
_TUNNELS_JSON_VERSION = 1
3032
_DEFAULT_SELECTION = "round_robin"
3133
_HEALTH_CHECK_TIMEOUT_SECONDS = 5.0
34+
_SIDECAR_STATUS_TIMEOUT_SECONDS = 3.0
35+
_PROM_LINE_RE = re.compile(
36+
r"^mailcue_tunnel_(?P<metric>up|requests_total|last_success_seconds|inflight|idle_connections)"
37+
r"\{(?P<labels>[^}]*)\}\s+(?P<value>[0-9.eE+-]+)$"
38+
)
3239

3340

3441
# ── CRUD ──────────────────────────────────────────────────────────
@@ -41,6 +48,133 @@ async def list_tunnels(db: AsyncSession) -> list[Tunnel]:
4148
return list(result.scalars().all())
4249

4350

51+
def _read_effective_tunnel_entries(path: str | Path) -> tuple[list[dict[str, Any]], str | None]:
52+
"""Read the sidecar's effective config without exposing public keys."""
53+
try:
54+
data = json.loads(Path(path).read_text(encoding="utf-8"))
55+
except FileNotFoundError:
56+
return [], f"Tunnel configuration is not mounted at {path}"
57+
except PermissionError:
58+
return [], f"Tunnel configuration is not readable at {path}"
59+
except (OSError, json.JSONDecodeError) as exc:
60+
return [], f"Could not read tunnel configuration: {exc}"
61+
entries = data.get("tunnels") if isinstance(data, dict) else None
62+
if not isinstance(entries, list):
63+
return [], "Tunnel configuration does not contain a tunnels list"
64+
return [entry for entry in entries if isinstance(entry, dict)], None
65+
66+
67+
def _parse_prometheus_labels(raw: str) -> dict[str, str]:
68+
labels: dict[str, str] = {}
69+
for match in re.finditer(r'(\w+)="((?:\\.|[^"])*)"', raw):
70+
labels[match.group(1)] = match.group(2).replace(r"\"", '"').replace("\\\\", "\\")
71+
return labels
72+
73+
74+
def _parse_sidecar_metrics(body: str) -> dict[str, dict[str, float]]:
75+
"""Parse the small, fixed Prometheus surface emitted by the sidecar."""
76+
result: dict[str, dict[str, float]] = {}
77+
for line in body.splitlines():
78+
match = _PROM_LINE_RE.fullmatch(line.strip())
79+
if match is None:
80+
continue
81+
labels = _parse_prometheus_labels(match.group("labels"))
82+
tunnel_id = labels.get("tunnel")
83+
if not tunnel_id:
84+
continue
85+
metric = match.group("metric")
86+
if metric == "requests_total":
87+
outcome = labels.get("outcome")
88+
if outcome not in {"ok", "err"}:
89+
continue
90+
metric = f"requests_{outcome}"
91+
result.setdefault(tunnel_id, {})[metric] = float(match.group("value"))
92+
return result
93+
94+
95+
async def _fetch_sidecar_metrics() -> tuple[bool, str | None, dict[str, dict[str, float]]]:
96+
url = settings.tunnel_metrics_url.rstrip("/") + "/metrics"
97+
try:
98+
async with httpx.AsyncClient(timeout=_SIDECAR_STATUS_TIMEOUT_SECONDS) as client:
99+
response = await client.get(url)
100+
response.raise_for_status()
101+
except httpx.HTTPError as exc:
102+
return False, f"Could not reach sidecar metrics at {url}: {exc}", {}
103+
return True, None, _parse_sidecar_metrics(response.text)
104+
105+
106+
async def effective_tunnel_status(
107+
db: AsyncSession,
108+
) -> tuple[bool, str | None, list[EffectiveTunnelStatus]]:
109+
"""Return the sidecar's effective file config enriched with live health."""
110+
database_tunnels = await list_tunnels(db)
111+
database_by_id = {tunnel.id: tunnel for tunnel in database_tunnels}
112+
file_entries, config_error = await asyncio.to_thread(
113+
_read_effective_tunnel_entries, settings.tunnels_config_path
114+
)
115+
sidecar_reachable, metrics_error, metrics = await _fetch_sidecar_metrics()
116+
117+
statuses: list[EffectiveTunnelStatus] = []
118+
seen: set[str] = set()
119+
for entry in file_entries:
120+
tunnel_id = str(entry.get("id", "")).strip()
121+
name = str(entry.get("name", tunnel_id)).strip()
122+
host = str(entry.get("host", "")).strip()
123+
if not tunnel_id or not name or not host:
124+
continue
125+
try:
126+
port = int(entry.get("port", 7843))
127+
weight = int(entry.get("weight", 1))
128+
except (TypeError, ValueError):
129+
continue
130+
stat = metrics.get(tunnel_id, {})
131+
last_success_value = int(stat.get("last_success_seconds", 0))
132+
statuses.append(
133+
EffectiveTunnelStatus(
134+
id=tunnel_id,
135+
name=name,
136+
endpoint_host=host,
137+
endpoint_port=port,
138+
enabled=bool(entry.get("enabled", True)),
139+
weight=weight,
140+
source="database" if tunnel_id in database_by_id else "config_file",
141+
managed=tunnel_id in database_by_id,
142+
healthy=bool(stat.get("up")) if tunnel_id in metrics else None,
143+
idle_connections=int(stat["idle_connections"])
144+
if "idle_connections" in stat
145+
else None,
146+
inflight=int(stat["inflight"]) if "inflight" in stat else None,
147+
requests_ok=int(stat["requests_ok"]) if "requests_ok" in stat else None,
148+
requests_err=int(stat["requests_err"]) if "requests_err" in stat else None,
149+
last_success=datetime.fromtimestamp(last_success_value, UTC)
150+
if last_success_value > 0
151+
else None,
152+
)
153+
)
154+
seen.add(tunnel_id)
155+
156+
# A database row may not have reached the sidecar file yet. Surface it as
157+
# managed but not loaded instead of silently dropping it from the UI.
158+
for tunnel in database_tunnels:
159+
if tunnel.id in seen:
160+
continue
161+
statuses.append(
162+
EffectiveTunnelStatus(
163+
id=tunnel.id,
164+
name=tunnel.name,
165+
endpoint_host=tunnel.endpoint_host,
166+
endpoint_port=tunnel.endpoint_port,
167+
enabled=tunnel.enabled,
168+
weight=tunnel.weight,
169+
source="database",
170+
managed=True,
171+
)
172+
)
173+
174+
status_detail = config_error or metrics_error
175+
return sidecar_reachable, status_detail, sorted(statuses, key=lambda item: item.name)
176+
177+
44178
async def get_tunnel(tunnel_id: str, db: AsyncSession) -> Tunnel:
45179
"""Fetch a single tunnel by ID or raise :class:`NotFoundError`."""
46180
tunnel = await db.get(Tunnel, tunnel_id)

backend/tests/test_tunnels.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,34 @@
2323
from httpx import ASGITransport, AsyncClient
2424
from sqlalchemy.ext.asyncio import AsyncSession
2525

26+
from app.tunnels.service import _parse_sidecar_metrics
27+
2628
# 32 bytes of zeros encoded as base64 -- shape-valid X25519 public key.
2729
_VALID_PUBKEY_B64: str = base64.b64encode(b"\x00" * 32).decode()
2830
_VALID_PUBKEY_B64_ALT: str = base64.b64encode(b"\x01" * 32).decode()
2931

3032

33+
def test_parse_sidecar_metrics_groups_health_and_outcomes() -> None:
34+
metrics = _parse_sidecar_metrics(
35+
"\n".join(
36+
[
37+
'mailcue_tunnel_up{tunnel="edge-1"} 1',
38+
'mailcue_tunnel_requests_total{tunnel="edge-1",outcome="ok"} 7',
39+
'mailcue_tunnel_requests_total{tunnel="edge-1",outcome="err"} 2',
40+
'mailcue_tunnel_idle_connections{tunnel="edge-1"} 3',
41+
]
42+
)
43+
)
44+
assert metrics == {
45+
"edge-1": {
46+
"up": 1.0,
47+
"requests_ok": 7.0,
48+
"requests_err": 2.0,
49+
"idle_connections": 3.0,
50+
}
51+
}
52+
53+
3154
def _override_settings_path(monkeypatch: pytest.MonkeyPatch, target: Path) -> None:
3255
"""Point ``settings.tunnels_config_path`` at *target* for the duration of the test."""
3356
from app.config import settings
@@ -99,6 +122,68 @@ async def test_create_tunnel_rejects_invalid_name(
99122
# ── CRUD round-trip + tunnels.json shape ─────────────────────────
100123

101124

125+
async def test_effective_status_surfaces_file_managed_tunnels_and_metrics(
126+
client: AsyncClient,
127+
monkeypatch: pytest.MonkeyPatch,
128+
tmp_path: Path,
129+
) -> None:
130+
"""The UI must show working sidecar tunnels even when the DB is empty."""
131+
json_path = tmp_path / "tunnels.json"
132+
_override_settings_path(monkeypatch, json_path)
133+
json_path.write_text(
134+
json.dumps(
135+
{
136+
"version": 1,
137+
"tunnels": [
138+
{
139+
"id": "relay-us",
140+
"name": "US relay",
141+
"host": "relay-us.example.com",
142+
"port": 7843,
143+
"edge_pubkey": _VALID_PUBKEY_B64,
144+
"enabled": True,
145+
"weight": 2,
146+
}
147+
],
148+
}
149+
)
150+
)
151+
152+
async def _fake_metrics() -> tuple[bool, str | None, dict[str, dict[str, float]]]:
153+
return (
154+
True,
155+
None,
156+
{
157+
"relay-us": {
158+
"up": 1,
159+
"idle_connections": 3,
160+
"inflight": 1,
161+
"requests_ok": 12,
162+
"requests_err": 2,
163+
"last_success_seconds": 1_785_369_600,
164+
}
165+
},
166+
)
167+
168+
monkeypatch.setattr("app.tunnels.service._fetch_sidecar_metrics", _fake_metrics)
169+
response = await client.get("/api/v1/tunnels/status")
170+
assert response.status_code == 200, response.text
171+
body = response.json()
172+
assert body["sidecar_reachable"] is True
173+
assert len(body["tunnels"]) == 1
174+
tunnel = body["tunnels"][0]
175+
assert tunnel["id"] == "relay-us"
176+
assert tunnel["endpoint_host"] == "relay-us.example.com"
177+
assert tunnel["source"] == "config_file"
178+
assert tunnel["managed"] is False
179+
assert tunnel["healthy"] is True
180+
assert tunnel["idle_connections"] == 3
181+
assert tunnel["requests_ok"] == 12
182+
# Public keys from tunnels.json must never be returned by the status API.
183+
assert "edge_pubkey" not in tunnel
184+
assert "server_pubkey" not in tunnel
185+
186+
102187
async def test_full_crud_and_tunnels_json(
103188
client: AsyncClient,
104189
monkeypatch: pytest.MonkeyPatch,

0 commit comments

Comments
 (0)