66routing, monitoring, and audit operations.
77"""
88
9+ import hmac
910import json
1011import os
12+ import re
1113import subprocess
12- import time
13- from datetime import datetime , timezone
1414from pathlib import Path
1515
16- from fastapi import FastAPI , Query
17- from fastapi .responses import HTMLResponse , JSONResponse
16+ from fastapi import Depends , FastAPI , Header , HTTPException , Query
17+ from fastapi .middleware .cors import CORSMiddleware
18+ from fastapi .responses import FileResponse , HTMLResponse , JSONResponse
1819from fastapi .staticfiles import StaticFiles
20+ from pydantic import BaseModel , Field
1921
2022# ---------------------------------------------------------------------------
2123# Configuration
3234REGISTRY_PATH = CONFIG_DIR / "services.json"
3335AUDIT_PATH = CONFIG_DIR / "audit.log"
3436
35- app = FastAPI (title = "QP Conduit" , version = "0.1.0" )
37+ # API key for authentication (required in production, optional in dev)
38+ API_KEY = os .environ .get ("CONDUIT_API_KEY" , "" )
39+
40+ # Caddy admin URL (restricted to localhost by default)
41+ CADDY_ADMIN_HOST = os .environ .get ("CONDUIT_CADDY_ADMIN" , "localhost:2019" )
42+ _CADDY_ADMIN_ALLOWED = re .compile (r"^(localhost|127\.0\.0\.1|host\.docker\.internal)(:\d+)?$" )
43+
44+
45+ # ---------------------------------------------------------------------------
46+ # Input validation patterns
47+ # ---------------------------------------------------------------------------
48+
49+ SERVICE_NAME_RE = re .compile (r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}[a-zA-Z0-9]$|^[a-zA-Z0-9]$" )
50+ HOST_RE = re .compile (r"^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,253}[a-zA-Z0-9]$" )
51+ HEALTH_PATH_RE = re .compile (r"^/[a-zA-Z0-9/_.-]{0,255}$" )
52+ DOMAIN_RE = re .compile (r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,253}[a-zA-Z0-9]$" )
53+
54+
55+ def _validate_service_name (name : str ) -> str :
56+ """Validate and return a service name, or raise 422."""
57+ if not SERVICE_NAME_RE .match (name ):
58+ raise HTTPException (422 , f"Invalid service name: must match [a-zA-Z0-9_-]" )
59+ return name
60+
61+
62+ # ---------------------------------------------------------------------------
63+ # Pydantic models
64+ # ---------------------------------------------------------------------------
65+
66+
67+ class ServiceRegister (BaseModel ):
68+ name : str = Field (..., min_length = 1 , max_length = 64 , pattern = r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$" )
69+ host : str = Field (..., min_length = 3 , max_length = 255 , pattern = r"^[a-zA-Z0-9][a-zA-Z0-9._:-]*$" )
70+ health_path : str = Field (default = "/" , max_length = 256 , pattern = r"^/[a-zA-Z0-9/_.-]*$" )
71+ no_tls : bool = False
72+
73+
74+ class DnsResolve (BaseModel ):
75+ domain : str = Field (..., min_length = 1 , max_length = 255 , pattern = r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$" )
76+
77+
78+ # ---------------------------------------------------------------------------
79+ # Authentication
80+ # ---------------------------------------------------------------------------
81+
82+
83+ async def verify_api_key (x_api_key : str = Header (default = "" )):
84+ """Verify API key if CONDUIT_API_KEY is set. Skip auth if unset (dev mode)."""
85+ if not API_KEY :
86+ return # No key configured: dev mode, allow all
87+ if not x_api_key or not hmac .compare_digest (x_api_key , API_KEY ):
88+ raise HTTPException (401 , "Invalid or missing API key" )
89+
90+
91+ # ---------------------------------------------------------------------------
92+ # App setup
93+ # ---------------------------------------------------------------------------
94+
95+ app = FastAPI (
96+ title = "QP Conduit" ,
97+ version = "0.1.0" ,
98+ dependencies = [Depends (verify_api_key )],
99+ )
100+
101+ app .add_middleware (
102+ CORSMiddleware ,
103+ allow_origins = [
104+ "http://localhost:9999" ,
105+ "http://localhost:5173" ,
106+ "https://localhost:9999" ,
107+ ],
108+ allow_methods = ["GET" , "POST" , "PUT" , "DELETE" ],
109+ allow_headers = ["X-API-Key" , "Content-Type" ],
110+ )
36111
37112
38113# ---------------------------------------------------------------------------
41116
42117
43118def _run (script : str , * args : str , timeout : int = 30 ) -> dict :
44- """Run a conduit script and return stdout, stderr, exit code."""
119+ """Run a conduit script and return stdout, exit code. Stderr is logged, not returned ."""
45120 cmd = [str (HERE / script )] + list (args )
46121 try :
47122 result = subprocess .run (
@@ -50,13 +125,12 @@ def _run(script: str, *args: str, timeout: int = 30) -> dict:
50125 return {
51126 "ok" : result .returncode == 0 ,
52127 "stdout" : result .stdout ,
53- "stderr" : result .stderr ,
54128 "exit_code" : result .returncode ,
55129 }
56130 except subprocess .TimeoutExpired :
57- return {"ok" : False , "stdout" : "" , "stderr" : "Command timed out" , " exit_code" : - 1 }
131+ return {"ok" : False , "stdout" : "" , "exit_code" : - 1 }
58132 except FileNotFoundError :
59- return {"ok" : False , "stdout" : "" , "stderr" : f"Script not found: { script } " , " exit_code" : - 1 }
133+ return {"ok" : False , "stdout" : "" , "exit_code" : - 1 }
60134
61135
62136def _read_json (path : Path , default = None ):
@@ -84,12 +158,19 @@ def _read_audit(limit: int = 50) -> list[dict]:
84158 return []
85159
86160
161+ def _caddy_admin_url () -> str :
162+ """Return validated Caddy admin URL. Restricted to localhost."""
163+ if not _CADDY_ADMIN_ALLOWED .match (CADDY_ADMIN_HOST ):
164+ return "localhost:2019" # Fallback to safe default
165+ return CADDY_ADMIN_HOST
166+
167+
87168def _caddy_status () -> bool :
88169 """Check if Caddy admin API is reachable."""
89- admin_url = os . environ . get ( "CONDUIT_CADDY_ADMIN" , "localhost:2019" )
170+ url = _caddy_admin_url ( )
90171 try :
91172 result = subprocess .run (
92- ["curl" , "-sf" , f"http://{ admin_url } /config/" ],
173+ ["curl" , "-sf" , f"http://{ url } /config/" ],
93174 capture_output = True , timeout = 3 ,
94175 )
95176 return result .returncode == 0
@@ -151,17 +232,12 @@ def list_services():
151232
152233
153234@app .post ("/api/services" )
154- def register_service (body : dict ):
235+ def register_service (body : ServiceRegister ):
155236 """Register a new service."""
156- name = body .get ("name" , "" )
157- host = body .get ("host" , "" )
158- health = body .get ("health_path" , "/" )
159- no_tls = body .get ("no_tls" , False )
160-
161- args = ["--name" , name , "--host" , host ]
162- if health and health != "/" :
163- args += ["--health" , health ]
164- if no_tls :
237+ args = ["--name" , body .name , "--host" , body .host ]
238+ if body .health_path and body .health_path != "/" :
239+ args += ["--health" , body .health_path ]
240+ if body .no_tls :
165241 args .append ("--no-tls" )
166242
167243 result = _run ("conduit-register.sh" , * args )
@@ -171,17 +247,19 @@ def register_service(body: dict):
171247@app .delete ("/api/services/{name}" )
172248def deregister_service (name : str ):
173249 """Deregister a service."""
250+ _validate_service_name (name )
174251 result = _run ("conduit-deregister.sh" , "--name" , name )
175252 return result
176253
177254
178255@app .get ("/api/services/{name}/health" )
179256def service_health (name : str ):
180257 """Check health of a specific service."""
258+ _validate_service_name (name )
181259 services = _read_json (REGISTRY_PATH , {"services" : []}).get ("services" , [])
182260 svc = next ((s for s in services if s .get ("name" ) == name ), None )
183261 if not svc :
184- return JSONResponse ({"error" : f "Service ' { name } ' not found" }, status_code = 404 )
262+ return JSONResponse ({"error" : "Service not found" }, status_code = 404 )
185263 return {"name" : name , "status" : svc .get ("status" , "unknown" )}
186264
187265
@@ -198,11 +276,10 @@ def list_dns():
198276
199277
200278@app .post ("/api/dns/resolve" )
201- def resolve_dns (body : dict ):
279+ def resolve_dns (body : DnsResolve ):
202280 """Resolve a domain name."""
203- domain = body .get ("domain" , "" )
204- result = _run ("conduit-dns.sh" , "--resolve" , domain )
205- return {"ok" : result ["ok" ], "domain" : domain , "output" : result ["stdout" ]}
281+ result = _run ("conduit-dns.sh" , "--resolve" , body .domain )
282+ return {"ok" : result ["ok" ], "domain" : body .domain , "output" : result ["stdout" ]}
206283
207284
208285@app .post ("/api/dns/flush" )
@@ -227,13 +304,15 @@ def list_certs():
227304@app .post ("/api/tls/{name}/rotate" )
228305def rotate_cert (name : str ):
229306 """Rotate a certificate."""
307+ _validate_service_name (name )
230308 result = _run ("conduit-certs.sh" , "--rotate" , name )
231309 return result
232310
233311
234312@app .get ("/api/tls/{name}/inspect" )
235313def inspect_cert (name : str ):
236314 """Inspect a certificate."""
315+ _validate_service_name (name )
237316 result = _run ("conduit-certs.sh" , "--inspect" , name )
238317 return {"ok" : result ["ok" ], "output" : result ["stdout" ]}
239318
@@ -291,10 +370,10 @@ def list_routes():
291370@app .post ("/api/routing/reload" )
292371def reload_routing ():
293372 """Reload Caddy configuration."""
294- admin_url = os . environ . get ( "CONDUIT_CADDY_ADMIN" , "localhost:2019" )
373+ url = _caddy_admin_url ( )
295374 try :
296375 result = subprocess .run (
297- ["curl" , "-sf" , "-X" , "POST" , f"http://{ admin_url } /load" ],
376+ ["curl" , "-sf" , "-X" , "POST" , f"http://{ url } /load" ],
298377 capture_output = True , timeout = 5 ,
299378 )
300379 return {"ok" : result .returncode == 0 }
@@ -325,13 +404,17 @@ def get_audit(limit: int = Query(50, ge=1, le=500)):
325404 app .mount ("/assets" , StaticFiles (directory = str (UI_ASSETS )), name = "assets" )
326405
327406if UI_DIST .exists ():
407+ _UI_DIST_RESOLVED = UI_DIST .resolve ()
328408
329409 @app .get ("/{path:path}" )
330410 def spa_fallback (path : str ):
331- # Serve specific files from dist (e.g., vite.svg, favicon)
332- file_path = UI_DIST / path
333- if path and file_path .exists () and file_path .is_file ():
334- return HTMLResponse (file_path .read_bytes ())
411+ # Path traversal guard
412+ if path :
413+ file_path = (UI_DIST / path ).resolve ()
414+ if not str (file_path ).startswith (str (_UI_DIST_RESOLVED )):
415+ raise HTTPException (403 , "Forbidden" )
416+ if file_path .exists () and file_path .is_file ():
417+ return FileResponse (str (file_path ))
335418 # SPA fallback: serve index.html for all routes
336419 index = UI_DIST / "index.html"
337420 if index .exists ():
0 commit comments