1010import json
1111import logging
1212import os
13+ import re
1314import uuid
1415from datetime import UTC , datetime
1516from pathlib import Path
1617from typing import Any
1718
19+ import httpx
1820from sqlalchemy import select
1921from sqlalchemy .ext .asyncio import AsyncSession
2022
2123from app .config import settings
2224from app .exceptions import ConflictError , NotFoundError , ValidationError
2325from app .tunnels .models import Tunnel , TunnelClientIdentity
24- from app .tunnels .schemas import TunnelCreate , TunnelUpdate
26+ from app .tunnels .schemas import EffectiveTunnelStatus , TunnelCreate , TunnelUpdate
2527
2628logger = 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+
44178async 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 )
0 commit comments