Skip to main content

heliosdb_proxy/
config.rs

1//! Proxy Configuration
2//!
3//! Configuration management for HeliosDB Proxy.
4
5use crate::{ProxyError, Result};
6use serde::{Deserialize, Serialize};
7use std::path::Path;
8use std::time::Duration;
9
10// =============================================================================
11// POOL MODE TYPES
12// =============================================================================
13
14/// Connection pooling mode
15///
16/// Determines when connections are returned to the pool.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
18#[serde(rename_all = "lowercase")]
19pub enum PoolingMode {
20    /// Session mode: 1:1 client-to-backend mapping
21    #[default]
22    Session,
23    /// Transaction mode: Return after COMMIT/ROLLBACK
24    Transaction,
25    /// Statement mode: Return after each statement
26    Statement,
27}
28
29/// Prepared statement handling mode
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
31#[serde(rename_all = "lowercase")]
32pub enum PreparedStatementMode {
33    /// Disable prepared statements
34    #[default]
35    Disable,
36    /// Track and recreate on new connections
37    Track,
38    /// Use protocol-level named statements
39    Named,
40}
41
42/// Pool mode configuration
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct PoolModeConfig {
45    /// Default pooling mode
46    #[serde(default)]
47    pub mode: PoolingMode,
48    /// Maximum connections per node
49    #[serde(default = "default_pool_mode_max_size")]
50    pub max_pool_size: u32,
51    /// Minimum idle connections
52    #[serde(default = "default_pool_mode_min_idle")]
53    pub min_idle: u32,
54    /// Idle timeout (seconds)
55    #[serde(default = "default_pool_mode_idle_timeout")]
56    pub idle_timeout_secs: u64,
57    /// Max connection lifetime (seconds)
58    #[serde(default = "default_pool_mode_max_lifetime")]
59    pub max_lifetime_secs: u64,
60    /// Acquire timeout (seconds)
61    #[serde(default = "default_pool_mode_acquire_timeout")]
62    pub acquire_timeout_secs: u64,
63    /// Reset query to run when returning connection to pool
64    #[serde(default = "default_reset_query")]
65    pub reset_query: String,
66    /// Prepared statement mode
67    #[serde(default)]
68    pub prepared_statement_mode: PreparedStatementMode,
69    /// Conditional reset (Transaction/Statement pooling): when true, a
70    /// connection that provably touched no session state (no `SET`/GUC, temp
71    /// table, prepared statement, `LISTEN`, advisory lock, …) is parked WITHOUT
72    /// running `reset_query`, saving a backend round-trip per clean transaction.
73    /// Classification is conservative — anything not provably session-neutral
74    /// still runs the full reset — so a misclassification only ever costs an
75    /// unnecessary reset, never leaks state. Off by default; intended for
76    /// autocommit / simple-protocol workloads. See the `stmt_leaves_session_state`
77    /// classifier for the exact (documented) limitation around session-setting
78    /// user functions.
79    #[serde(default)]
80    pub skip_clean_reset: bool,
81}
82
83fn default_pool_mode_max_size() -> u32 {
84    100
85}
86
87fn default_pool_mode_min_idle() -> u32 {
88    10
89}
90
91fn default_pool_mode_idle_timeout() -> u64 {
92    600
93}
94
95fn default_pool_mode_max_lifetime() -> u64 {
96    3600
97}
98
99fn default_pool_mode_acquire_timeout() -> u64 {
100    5
101}
102
103fn default_reset_query() -> String {
104    "DISCARD ALL".to_string()
105}
106
107impl Default for PoolModeConfig {
108    fn default() -> Self {
109        Self {
110            mode: PoolingMode::default(),
111            max_pool_size: default_pool_mode_max_size(),
112            min_idle: default_pool_mode_min_idle(),
113            idle_timeout_secs: default_pool_mode_idle_timeout(),
114            max_lifetime_secs: default_pool_mode_max_lifetime(),
115            acquire_timeout_secs: default_pool_mode_acquire_timeout(),
116            reset_query: default_reset_query(),
117            prepared_statement_mode: PreparedStatementMode::default(),
118            skip_clean_reset: false,
119        }
120    }
121}
122
123impl PoolModeConfig {
124    /// Create config for session mode
125    pub fn session_mode() -> Self {
126        Self {
127            mode: PoolingMode::Session,
128            prepared_statement_mode: PreparedStatementMode::Named,
129            ..Default::default()
130        }
131    }
132
133    /// Create config for transaction mode
134    pub fn transaction_mode() -> Self {
135        Self {
136            mode: PoolingMode::Transaction,
137            prepared_statement_mode: PreparedStatementMode::Track,
138            ..Default::default()
139        }
140    }
141
142    /// Create config for statement mode
143    pub fn statement_mode() -> Self {
144        Self {
145            mode: PoolingMode::Statement,
146            prepared_statement_mode: PreparedStatementMode::Disable,
147            ..Default::default()
148        }
149    }
150
151    /// Get idle timeout as Duration
152    pub fn idle_timeout(&self) -> Duration {
153        Duration::from_secs(self.idle_timeout_secs)
154    }
155
156    /// Get max lifetime as Duration
157    pub fn max_lifetime(&self) -> Duration {
158        Duration::from_secs(self.max_lifetime_secs)
159    }
160
161    /// Get acquire timeout as Duration
162    pub fn acquire_timeout(&self) -> Duration {
163        Duration::from_secs(self.acquire_timeout_secs)
164    }
165}
166
167// =============================================================================
168// MAIN PROXY CONFIG
169// =============================================================================
170
171/// Proxy configuration
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct ProxyConfig {
174    /// Listen address for client connections
175    pub listen_address: String,
176    /// Admin API address
177    pub admin_address: String,
178    /// Bearer token required on admin API requests. When set, every admin
179    /// endpoint except liveness probes (`/health*`, `/livez`, `/readyz`)
180    /// requires `Authorization: Bearer <token>`. Absent (default) = open on
181    /// loopback only — the proxy refuses to start with a non-loopback
182    /// `admin_address` and no token (see `validate`), because the admin API runs
183    /// privileged operations (arbitrary SQL, forced failover, cutover, branch
184    /// DROP DATABASE, replay against arbitrary targets).
185    #[serde(default)]
186    pub admin_token: Option<String>,
187    /// Explicit opt-in to expose the admin API on a non-loopback address WITHOUT
188    /// a token. Default `false` — leave it so unless you front the admin port
189    /// with your own authenticating proxy/network policy.
190    #[serde(default)]
191    pub admin_allow_insecure: bool,
192    /// Enable TR (Transaction Replay)
193    pub tr_enabled: bool,
194    /// TR mode
195    pub tr_mode: TrMode,
196    /// Connection pool configuration
197    pub pool: PoolConfig,
198    /// Pool mode configuration (Session/Transaction/Statement)
199    #[serde(default)]
200    pub pool_mode: PoolModeConfig,
201    /// Load balancer configuration
202    pub load_balancer: LoadBalancerConfig,
203    /// Health check configuration
204    pub health: HealthConfig,
205    /// Backend nodes
206    pub nodes: Vec<NodeConfig>,
207    /// TLS configuration
208    pub tls: Option<TlsConfig>,
209    /// Write timeout during failover (seconds)
210    /// When primary is unavailable, wait this long for a new primary before returning error
211    #[serde(default = "default_write_timeout_secs")]
212    pub write_timeout_secs: u64,
213    /// Plugin system configuration. Only consumed when the `wasm-plugins`
214    /// feature is enabled; on a feature-off build, values are parsed and
215    /// ignored so existing configs don't break.
216    #[serde(default)]
217    pub plugins: PluginToml,
218    /// pg_hba-style connection admission rules, evaluated in order before any
219    /// backend connection is opened. Empty (the default) means admit all
220    /// (current behaviour preserved).
221    #[serde(default)]
222    pub hba: Vec<HbaRule>,
223    /// Client authentication mode. Absent/default = pass-through (the proxy
224    /// relays the client's auth to the backend, current behaviour).
225    #[serde(default)]
226    pub auth: AuthConfig,
227    /// MCP (Model Context Protocol) agent gateway. Disabled by default.
228    #[serde(default)]
229    pub mcp: McpConfig,
230    /// Per-agent SQL contracts (scoped grants). Referenced by id from the
231    /// MCP gateway (`[mcp] contract`). Empty by default.
232    #[serde(default)]
233    pub agent_contracts: Vec<crate::agent_contract::AgentContract>,
234    /// HTTP SQL gateway (Neon-serverless-driver compatible). Disabled by
235    /// default — lets edge/serverless clients run SQL over HTTP.
236    #[serde(default)]
237    pub http_gateway: HttpGatewayConfig,
238    /// Continuous traffic mirroring to a secondary backend. Disabled by
239    /// default — the on-ramp to a PG->Nano migration mirror.
240    #[serde(default)]
241    pub mirror: MirrorConfig,
242    /// Edge / geo proxy mode (role=home caches reads + broadcasts
243    /// invalidations to subscribed edges; role=edge serves reads from a
244    /// local cache and forwards misses/writes to the home). Disabled by
245    /// default. Parsed on every build so configs round-trip, but
246    /// `enabled = true` requires the `edge-proxy` compile-time feature —
247    /// `validate()` rejects it otherwise.
248    #[serde(default)]
249    pub edge: crate::edge::EdgeConfig,
250    /// Instant branch databases. Disabled by default — provisions
251    /// CREATE DATABASE ... TEMPLATE clones through the proxy.
252    #[serde(default)]
253    pub branch: BranchConfig,
254    /// SQL-comment routing hints (`/*helios:route=primary*/`). Disabled by
255    /// default — when enabled, the proxy parses hints from query SQL and
256    /// applies them as a route override that wins over the default verb
257    /// routing (but never over a plugin `Block`). Only consumed when the
258    /// `routing-hints` feature is compiled in; parsed-and-ignored otherwise.
259    #[serde(default)]
260    pub routing_hints: RoutingHintsConfig,
261    /// Multi-dimensional rate limiting (token bucket + concurrency). Disabled
262    /// by default. Only enforced when the `rate-limiting` feature is compiled
263    /// in; parsed-and-ignored otherwise.
264    #[serde(default)]
265    pub rate_limit: RateLimitToml,
266    /// Per-node circuit breaker (trip failing backends out of rotation,
267    /// fast-fail while open). Disabled by default. Only enforced when the
268    /// `circuit-breaker` feature is compiled in.
269    #[serde(default)]
270    pub circuit_breaker: CircuitBreakerToml,
271    /// Query analytics (fingerprinting, per-query statistics, slow-query log,
272    /// pattern detection). Disabled by default. Only active when the
273    /// `query-analytics` feature is compiled in.
274    #[serde(default)]
275    pub analytics: AnalyticsToml,
276    /// Replica-lag-aware routing + read-your-writes. Disabled by default. Only
277    /// enforced when the `lag-routing` feature is compiled in.
278    #[serde(default)]
279    pub lag_routing: LagRoutingToml,
280    /// Query-result cache (L1 hot / L2 warm). Disabled by default. Only active
281    /// when the `query-cache` feature is compiled in.
282    #[serde(default)]
283    pub cache: CacheToml,
284    /// SQL query rewriting (rules engine). Disabled by default. Only active
285    /// when the `query-rewriting` feature is compiled in.
286    #[serde(default)]
287    pub query_rewrite: QueryRewriteToml,
288    /// Multi-tenancy (per-tenant row isolation via injected predicates).
289    /// Disabled by default. Only active when the `multi-tenancy` feature is
290    /// compiled in.
291    #[serde(default)]
292    pub multi_tenancy: MultiTenancyToml,
293    /// Schema/workload-aware routing (route OLAP queries to an analytics node).
294    /// Disabled by default. Only active when the `schema-routing` feature is on.
295    #[serde(default)]
296    pub schema_routing: SchemaRoutingToml,
297    /// GraphQL-to-SQL gateway (separate HTTP listener). Disabled by default.
298    /// Only active when the `graphql-gateway` feature is compiled in.
299    #[serde(default)]
300    pub graphql_gateway: GraphqlGatewayConfig,
301    /// Proxy-side unnamed-`Parse` promotion (Batch H). When a client re-sends an
302    /// identical unnamed extended `Parse` (the dominant pgbench/ORM pattern),
303    /// the proxy skips forwarding it to a backend that already holds that exact
304    /// unnamed statement and synthesizes the `ParseComplete` locally — cutting
305    /// the per-cycle re-`Parse` overhead. Default on; a kill-switch for drivers
306    /// that somehow depend on the redundant round trip.
307    #[serde(default = "default_true")]
308    pub optimize_unnamed_parse: bool,
309    /// How long a graceful binary-handoff drain (SIGUSR2) keeps serving
310    /// in-flight connections before the old process exits (Batch H). After this
311    /// many seconds, any still-open connections are dropped so the handoff
312    /// completes in bounded time. Overridable at runtime via the
313    /// `HELIOS_DRAIN_TIMEOUT_SECS` env var.
314    #[serde(default = "default_drain_timeout_secs")]
315    pub shutdown_drain_timeout_secs: u64,
316}
317
318fn default_drain_timeout_secs() -> u64 {
319    60
320}
321
322/// Branch-database configuration: the maintenance connection the proxy uses
323/// to provision `CREATE DATABASE <branch> TEMPLATE <base>` clones.
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct BranchConfig {
326    #[serde(default)]
327    pub enabled: bool,
328    #[serde(default = "default_localhost")]
329    pub backend_host: String,
330    #[serde(default = "default_pg_port")]
331    pub backend_port: u16,
332    /// A role with CREATEDB privilege.
333    #[serde(default = "default_pg_user")]
334    pub admin_user: String,
335    pub admin_password: Option<String>,
336    /// Maintenance database to issue CREATE/DROP DATABASE against (not the
337    /// branch itself). Defaults to "postgres".
338    #[serde(default = "default_admin_db")]
339    pub admin_database: String,
340    /// Default template database to branch from when a request omits `base`.
341    #[serde(default = "default_admin_db")]
342    pub base_database: String,
343}
344
345impl Default for BranchConfig {
346    fn default() -> Self {
347        Self {
348            enabled: false,
349            backend_host: default_localhost(),
350            backend_port: default_pg_port(),
351            admin_user: default_pg_user(),
352            admin_password: None,
353            admin_database: default_admin_db(),
354            base_database: default_admin_db(),
355        }
356    }
357}
358
359fn default_admin_db() -> String {
360    "postgres".to_string()
361}
362
363/// Traffic-mirror configuration: replay a sampled share of live (simple-query)
364/// writes to a secondary backend, asynchronously and off the client hot path.
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct MirrorConfig {
367    #[serde(default)]
368    pub enabled: bool,
369    /// Fraction of eligible statements to mirror, 0.0..=1.0.
370    #[serde(default = "default_sample_rate")]
371    pub sample_rate: f64,
372    /// Mirror only write/DDL statements (default). When false, all simple
373    /// queries are mirrored.
374    #[serde(default = "default_true_bool")]
375    pub writes_only: bool,
376    /// Bounded queue depth; when full, statements are dropped (and counted)
377    /// rather than blocking the client path.
378    #[serde(default = "default_mirror_queue")]
379    pub queue_size: usize,
380    #[serde(default = "default_localhost")]
381    pub backend_host: String,
382    #[serde(default = "default_pg_port")]
383    pub backend_port: u16,
384    #[serde(default = "default_pg_user")]
385    pub backend_user: String,
386    pub backend_password: Option<String>,
387    pub backend_database: Option<String>,
388    /// Source (primary) connection used by `POST /api/migration/snapshot` to
389    /// read existing data when bootstrapping the secondary. Defaults mirror
390    /// the listener-side backend; set explicitly for a snapshot.
391    #[serde(default = "default_localhost")]
392    pub source_host: String,
393    #[serde(default = "default_pg_port")]
394    pub source_port: u16,
395    #[serde(default = "default_pg_user")]
396    pub source_user: String,
397    pub source_password: Option<String>,
398    pub source_database: Option<String>,
399}
400
401impl Default for MirrorConfig {
402    fn default() -> Self {
403        Self {
404            enabled: false,
405            sample_rate: 1.0,
406            writes_only: true,
407            queue_size: 10_000,
408            backend_host: default_localhost(),
409            backend_port: default_pg_port(),
410            backend_user: default_pg_user(),
411            backend_password: None,
412            backend_database: None,
413            source_host: default_localhost(),
414            source_port: default_pg_port(),
415            source_user: default_pg_user(),
416            source_password: None,
417            source_database: None,
418        }
419    }
420}
421
422fn default_sample_rate() -> f64 {
423    1.0
424}
425fn default_mirror_queue() -> usize {
426    10_000
427}
428
429/// HTTP SQL gateway configuration. A Neon-`@neondatabase/serverless`-style
430/// `POST /sql` endpoint that runs one statement over the backend PG-wire
431/// client and returns `{ command, rowCount, rows, fields }`.
432#[derive(Debug, Clone, Serialize, Deserialize)]
433pub struct HttpGatewayConfig {
434    #[serde(default)]
435    pub enabled: bool,
436    #[serde(default = "default_http_gw_listen")]
437    pub listen_address: String,
438    #[serde(default = "default_localhost")]
439    pub backend_host: String,
440    #[serde(default = "default_pg_port")]
441    pub backend_port: u16,
442    #[serde(default = "default_pg_user")]
443    pub backend_user: String,
444    pub backend_password: Option<String>,
445    pub backend_database: Option<String>,
446    /// Optional Bearer token required on requests.
447    #[serde(default)]
448    pub auth_token: Option<String>,
449}
450
451impl Default for HttpGatewayConfig {
452    fn default() -> Self {
453        Self {
454            enabled: false,
455            listen_address: default_http_gw_listen(),
456            backend_host: default_localhost(),
457            backend_port: default_pg_port(),
458            backend_user: default_pg_user(),
459            backend_password: None,
460            backend_database: None,
461            auth_token: None,
462        }
463    }
464}
465
466fn default_http_gw_listen() -> String {
467    "127.0.0.1:9093".to_string()
468}
469
470/// MCP agent-gateway configuration. When enabled, the proxy exposes a native
471/// MCP server so AI agents call `query`/`list_tables`/`explain` tools instead
472/// of opening raw SQL connections — each call gated by the gateway's policy
473/// (read-only by default) and logged.
474#[derive(Debug, Clone, Serialize, Deserialize)]
475pub struct McpConfig {
476    #[serde(default)]
477    pub enabled: bool,
478    /// HTTP listen address for the MCP JSON-RPC endpoint.
479    #[serde(default = "default_mcp_listen")]
480    pub listen_address: String,
481    /// Backend the gateway runs tool SQL against.
482    #[serde(default = "default_localhost")]
483    pub backend_host: String,
484    #[serde(default = "default_pg_port")]
485    pub backend_port: u16,
486    #[serde(default = "default_pg_user")]
487    pub backend_user: String,
488    pub backend_password: Option<String>,
489    pub backend_database: Option<String>,
490    /// When true (default), the gateway refuses write/DDL statements — agents
491    /// get a read-only database surface.
492    #[serde(default = "default_true_bool")]
493    pub read_only: bool,
494    /// Name of an `[[agent_contracts]]` entry to enforce on every tool call
495    /// (scoped grants + repair hints). None = only the `read_only` guardrail.
496    #[serde(default)]
497    pub contract: Option<String>,
498    /// Bearer token required on every MCP request. When set, a request without
499    /// `Authorization: Bearer <token>` is rejected. Absent (default) = open, so
500    /// set this for any non-loopback deployment — like the HTTP/GraphQL
501    /// gateways, MCP exposes SQL and must not be anonymous off localhost.
502    #[serde(default)]
503    pub auth_token: Option<String>,
504}
505
506impl Default for McpConfig {
507    fn default() -> Self {
508        Self {
509            enabled: false,
510            listen_address: default_mcp_listen(),
511            backend_host: default_localhost(),
512            backend_port: default_pg_port(),
513            backend_user: default_pg_user(),
514            backend_password: None,
515            backend_database: None,
516            read_only: true,
517            contract: None,
518            auth_token: None,
519        }
520    }
521}
522
523fn default_mcp_listen() -> String {
524    "127.0.0.1:9092".to_string()
525}
526fn default_localhost() -> String {
527    "127.0.0.1".to_string()
528}
529fn default_pg_port() -> u16 {
530    5432
531}
532fn default_pg_user() -> String {
533    "postgres".to_string()
534}
535fn default_true_bool() -> bool {
536    true
537}
538
539/// Client-side authentication configuration.
540#[derive(Debug, Clone, Serialize, Deserialize, Default)]
541pub struct AuthConfig {
542    /// `passthrough` (default) relays client auth to the backend.
543    /// `scram` makes the proxy terminate SCRAM-SHA-256 itself against
544    /// `auth_file`, becoming the auth boundary (foundation for pooling).
545    #[serde(default)]
546    pub mode: AuthMode,
547    /// Path to a pgbouncer-style user list (`user:secret`, secret = plaintext
548    /// or a `SCRAM-SHA-256$...` verifier). Required when `mode = "scram"`.
549    #[serde(default)]
550    pub auth_file: Option<String>,
551}
552
553/// Proxy client-authentication mode.
554#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
555#[serde(rename_all = "lowercase")]
556pub enum AuthMode {
557    /// Relay the client's auth exchange straight to the backend.
558    #[default]
559    Passthrough,
560    /// Terminate SCRAM-SHA-256 at the proxy against `auth_file`.
561    Scram,
562}
563
564/// A single pg_hba-style admission rule. The first rule whose `user`,
565/// `database`, and `address` all match the incoming connection decides the
566/// outcome (`allow`/`reject`). If no rule matches, the connection is
567/// admitted (rules are an explicit deny/allow list, not default-deny — add a
568/// trailing `{ action = "reject", user = "all", database = "all", address =
569/// "all" }` for default-deny).
570#[derive(Debug, Clone, Serialize, Deserialize)]
571pub struct HbaRule {
572    /// "allow" or "reject".
573    pub action: HbaAction,
574    /// Matching PostgreSQL user, or "all".
575    #[serde(default = "hba_all")]
576    pub user: String,
577    /// Matching database, or "all".
578    #[serde(default = "hba_all")]
579    pub database: String,
580    /// Matching client address: "all", a bare IP, or a CIDR (e.g.
581    /// "10.0.0.0/8", "::1/128").
582    #[serde(default = "hba_all")]
583    pub address: String,
584}
585
586fn hba_all() -> String {
587    "all".to_string()
588}
589
590/// Admission action for an [`HbaRule`].
591#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
592#[serde(rename_all = "lowercase")]
593pub enum HbaAction {
594    Allow,
595    Reject,
596}
597
598fn default_write_timeout_secs() -> u64 {
599    30 // 30 seconds default write timeout during failover
600}
601
602/// A table exposed by the GraphQL gateway, with its selectable columns.
603#[derive(Debug, Clone, Default, Serialize, Deserialize)]
604#[serde(default)]
605pub struct GqlTableToml {
606    pub name: String,
607    pub columns: Vec<String>,
608}
609
610/// GraphQL-to-SQL gateway configuration. A separate HTTP listener; only active
611/// when the `graphql-gateway` feature is compiled in AND `enabled = true`.
612#[derive(Debug, Clone, Serialize, Deserialize)]
613#[serde(default)]
614pub struct GraphqlGatewayConfig {
615    /// Serve the GraphQL gateway. Default `false`.
616    pub enabled: bool,
617    /// HTTP listen address (e.g. `0.0.0.0:9091`).
618    pub listen_address: String,
619    /// Backend the generated SQL runs against.
620    pub backend_host: String,
621    pub backend_port: u16,
622    pub backend_user: String,
623    pub backend_password: Option<String>,
624    pub backend_database: Option<String>,
625    /// Optional Bearer token required on requests.
626    pub auth_token: Option<String>,
627    /// Tables exposed as GraphQL types.
628    pub tables: Vec<GqlTableToml>,
629}
630
631impl Default for GraphqlGatewayConfig {
632    fn default() -> Self {
633        Self {
634            enabled: false,
635            listen_address: "0.0.0.0:9091".to_string(),
636            backend_host: "127.0.0.1".to_string(),
637            backend_port: 5432,
638            backend_user: "postgres".to_string(),
639            backend_password: None,
640            backend_database: None,
641            auth_token: None,
642            tables: Vec::new(),
643        }
644    }
645}
646
647/// Schema/workload-aware routing configuration (always present). Only active
648/// when the `schema-routing` feature is compiled in AND `enabled = true`.
649#[derive(Debug, Clone, Default, Serialize, Deserialize)]
650#[serde(default)]
651pub struct SchemaRoutingToml {
652    /// Route analytical (OLAP) queries — aggregations, GROUP BY, window
653    /// functions — to a dedicated node. Default `false`.
654    pub enabled: bool,
655    /// Name of the node analytical queries are routed to.
656    pub analytics_node: String,
657}
658
659/// Multi-tenancy configuration (always present). Converted to a
660/// `multi_tenancy::TenantManager` at startup; only active when the
661/// `multi-tenancy` feature is compiled in AND `enabled = true`.
662#[derive(Debug, Clone, Serialize, Deserialize)]
663#[serde(default)]
664pub struct MultiTenancyToml {
665    /// Enforce per-tenant row isolation. Default `false`.
666    pub enabled: bool,
667    /// Which connection attribute names the tenant: a startup parameter name
668    /// (e.g. `application_name`, `user`) or the literal `database`.
669    pub identify_by: String,
670    /// The row-level tenant column injected into queries (e.g. `tenant_id`).
671    pub tenant_column: String,
672    /// Tables that are tenant-scoped (get the filter injected). Other tables
673    /// pass through unchanged.
674    pub tenant_tables: Vec<String>,
675    /// Known tenant ids.
676    pub tenants: Vec<String>,
677}
678
679impl Default for MultiTenancyToml {
680    fn default() -> Self {
681        Self {
682            enabled: false,
683            identify_by: "application_name".to_string(),
684            tenant_column: "tenant_id".to_string(),
685            tenant_tables: Vec::new(),
686            tenants: Vec::new(),
687        }
688    }
689}
690
691/// A single SQL-rewrite rule in TOML form. Maps to a `rewriter::RewriteRule`:
692/// `match_table`/`match_regex` choose which queries it applies to (default: all),
693/// and the first set transformation field is applied.
694#[derive(Debug, Clone, Serialize, Deserialize, Default)]
695#[serde(default)]
696pub struct RewriteRuleToml {
697    /// Apply to queries referencing this table.
698    pub match_table: Option<String>,
699    /// Apply to queries matching this regex.
700    pub match_regex: Option<String>,
701    /// Rewrite `match_table` -> this table name.
702    pub replace_table_with: Option<String>,
703    /// Append `AND <expr>` to the query's WHERE clause.
704    pub append_where: Option<String>,
705    /// Add a `LIMIT n` to an unbounded query.
706    pub add_limit: Option<u32>,
707}
708
709/// SQL query-rewriting configuration (always present). Converted to a
710/// `rewriter::QueryRewriter` at startup; only active when the `query-rewriting`
711/// feature is compiled in AND `enabled = true`.
712#[derive(Debug, Clone, Default, Serialize, Deserialize)]
713#[serde(default)]
714pub struct QueryRewriteToml {
715    /// Rewrite query SQL on the path per the rules below. Default `false`.
716    pub enabled: bool,
717    /// Ordered rewrite rules.
718    pub rules: Vec<RewriteRuleToml>,
719}
720
721/// Query-result cache configuration (TOML-friendly, always present). Converted
722/// to `crate::cache::CacheConfig` at startup and only active when the
723/// `query-cache` feature is compiled in AND `enabled = true`.
724#[derive(Debug, Clone, Serialize, Deserialize)]
725#[serde(default)]
726pub struct CacheToml {
727    /// Serve read SELECT results from an in-process L1/L2 cache. Default `false`.
728    pub enabled: bool,
729    /// Time-to-live for cached results, seconds.
730    pub ttl_secs: u64,
731    /// Maximum single result size to cache, bytes (larger results bypass).
732    pub max_result_bytes: usize,
733}
734
735impl Default for CacheToml {
736    fn default() -> Self {
737        Self {
738            enabled: false,
739            ttl_secs: 300,
740            max_result_bytes: 1024 * 1024,
741        }
742    }
743}
744
745/// Replica-lag-aware routing + read-your-writes configuration (always present;
746/// only enforced when the `lag-routing` feature is compiled in AND enabled).
747#[derive(Debug, Clone, Serialize, Deserialize)]
748#[serde(default)]
749pub struct LagRoutingToml {
750    /// Enable lag-aware read routing + read-your-writes. Default `false`.
751    pub enabled: bool,
752    /// Reads issued within this many milliseconds after a write in the same
753    /// session are pinned to the primary (read-your-writes), so the client
754    /// observes its own writes despite replica lag. 0 disables the window.
755    pub ryw_window_ms: u64,
756    /// Exclude a standby from read routing when its measured replication lag
757    /// exceeds this many bytes. 0 = no lag-based exclusion (default; the proxy
758    /// does not yet populate per-node lag without a configured monitor).
759    pub max_lag_bytes: u64,
760}
761
762impl Default for LagRoutingToml {
763    fn default() -> Self {
764        Self {
765            enabled: false,
766            ryw_window_ms: 500,
767            max_lag_bytes: 0,
768        }
769    }
770}
771
772/// Query-analytics configuration (TOML-friendly, always present). Converted to
773/// `crate::analytics::AnalyticsConfig` at startup and only active when the
774/// `query-analytics` feature is compiled in AND `enabled = true`.
775#[derive(Debug, Clone, Serialize, Deserialize)]
776#[serde(default)]
777pub struct AnalyticsToml {
778    /// Record per-query statistics, slow-query log, and pattern detection.
779    /// Default `false`.
780    pub enabled: bool,
781    /// Queries slower than this (milliseconds) are added to the slow-query log.
782    pub slow_query_ms: u64,
783    /// Maximum distinct query fingerprints to track.
784    pub max_fingerprints: u32,
785}
786
787impl Default for AnalyticsToml {
788    fn default() -> Self {
789        Self {
790            enabled: false,
791            slow_query_ms: 1000,
792            max_fingerprints: 10000,
793        }
794    }
795}
796
797/// Circuit-breaker configuration (TOML-friendly, always present). Converted to
798/// `crate::circuit_breaker::ManagerConfig` at startup and only enforced when
799/// the `circuit-breaker` feature is compiled in AND `enabled = true`.
800#[derive(Debug, Clone, Serialize, Deserialize)]
801#[serde(default)]
802pub struct CircuitBreakerToml {
803    /// Trip backends out of rotation after repeated failures. Default `false`.
804    pub enabled: bool,
805    /// Consecutive failures (within the failure window) that open a node's
806    /// circuit.
807    pub failure_threshold: u32,
808    /// How long a circuit stays open before a half-open probe is allowed.
809    pub open_secs: u64,
810    /// Successful probes required to close a half-open circuit.
811    pub success_threshold: u32,
812}
813
814impl Default for CircuitBreakerToml {
815    fn default() -> Self {
816        Self {
817            enabled: false,
818            failure_threshold: 5,
819            open_secs: 10,
820            success_threshold: 3,
821        }
822    }
823}
824
825/// How rate-limit buckets are keyed.
826#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
827#[serde(rename_all = "snake_case")]
828pub enum RateLimitKeyBy {
829    /// One bucket per authenticated user (startup `user` param).
830    #[default]
831    User,
832    /// One bucket per client IP address.
833    ClientIp,
834    /// One bucket per target database.
835    Database,
836    /// A single global bucket for the whole proxy.
837    Global,
838}
839
840/// Rate-limiting configuration (TOML-friendly, always present so configs
841/// round-trip on any build). Converted to `crate::rate_limit::RateLimitConfig`
842/// at startup and only enforced when the `rate-limiting` feature is compiled
843/// in AND `enabled = true`.
844#[derive(Debug, Clone, Serialize, Deserialize)]
845#[serde(default)]
846pub struct RateLimitToml {
847    /// Enforce rate limits. Default `false`.
848    pub enabled: bool,
849    /// Sustained queries per second per bucket.
850    pub default_qps: u32,
851    /// Burst capacity (token-bucket depth) per bucket.
852    pub default_burst: u32,
853    /// Max concurrent in-flight queries per bucket (0 = use the engine default).
854    pub max_concurrent: u32,
855    /// What each bucket is keyed on.
856    pub key_by: RateLimitKeyBy,
857}
858
859impl Default for RateLimitToml {
860    fn default() -> Self {
861        Self {
862            enabled: false,
863            default_qps: 1000,
864            default_burst: 2000,
865            max_concurrent: 0,
866            key_by: RateLimitKeyBy::User,
867        }
868    }
869}
870
871/// SQL-comment routing-hint configuration.
872///
873/// Always present on `ProxyConfig` so configs round-trip on any build, but the
874/// hints are only parsed and honored when the `routing-hints` feature is
875/// compiled in AND `enabled = true`.
876#[derive(Debug, Clone, Serialize, Deserialize)]
877#[serde(default)]
878pub struct RoutingHintsConfig {
879    /// Parse and honor `/*helios:...*/` routing hints. Default `false`
880    /// (preserves the pure verb-based routing behaviour).
881    pub enabled: bool,
882    /// Strip the hint comment from the SQL before forwarding to the backend.
883    /// Default `true`. Hint comments are valid SQL comments, so leaving them
884    /// in is harmless; stripping keeps backend query logs clean.
885    pub strip_hints: bool,
886}
887
888impl Default for RoutingHintsConfig {
889    fn default() -> Self {
890        Self {
891            enabled: false,
892            strip_hints: true,
893        }
894    }
895}
896
897impl Default for ProxyConfig {
898    fn default() -> Self {
899        Self {
900            listen_address: "0.0.0.0:5432".to_string(),
901            // Loopback by default: the admin API is privileged and must not be
902            // exposed to the network unless the operator opts in (with a token,
903            // or admin_allow_insecure). A fresh install is safe.
904            admin_address: "127.0.0.1:9090".to_string(),
905            admin_token: None,
906            admin_allow_insecure: false,
907            tr_enabled: true,
908            tr_mode: TrMode::Session,
909            pool: PoolConfig::default(),
910            pool_mode: PoolModeConfig::default(),
911            load_balancer: LoadBalancerConfig::default(),
912            health: HealthConfig::default(),
913            nodes: Vec::new(),
914            tls: None,
915            write_timeout_secs: default_write_timeout_secs(),
916            plugins: PluginToml::default(),
917            hba: Vec::new(),
918            auth: AuthConfig::default(),
919            mcp: McpConfig::default(),
920            agent_contracts: Vec::new(),
921            http_gateway: HttpGatewayConfig::default(),
922            mirror: MirrorConfig::default(),
923            edge: crate::edge::EdgeConfig::default(),
924            branch: BranchConfig::default(),
925            routing_hints: RoutingHintsConfig::default(),
926            rate_limit: RateLimitToml::default(),
927            circuit_breaker: CircuitBreakerToml::default(),
928            analytics: AnalyticsToml::default(),
929            lag_routing: LagRoutingToml::default(),
930            cache: CacheToml::default(),
931            query_rewrite: QueryRewriteToml::default(),
932            multi_tenancy: MultiTenancyToml::default(),
933            schema_routing: SchemaRoutingToml::default(),
934            graphql_gateway: GraphqlGatewayConfig::default(),
935            optimize_unnamed_parse: true,
936            shutdown_drain_timeout_secs: default_drain_timeout_secs(),
937        }
938    }
939}
940
941// =============================================================================
942// PLUGIN SYSTEM CONFIG (TOML-friendly shape)
943// =============================================================================
944
945/// Plugin-system configuration, in a TOML-friendly shape.
946///
947/// Always present on `ProxyConfig` so existing configs round-trip, but only
948/// consumed when the `wasm-plugins` feature is enabled. When
949/// `plugins.enabled` is `false` (the default), plugin loading is skipped
950/// entirely and every plugin-hook call site becomes a zero-cost no-op.
951///
952/// Converted to `crate::plugins::PluginRuntimeConfig` at startup via a
953/// feature-gated `From` impl in `src/plugins/config.rs`.
954#[derive(Debug, Clone, Serialize, Deserialize)]
955pub struct PluginToml {
956    /// Enable the plugin subsystem. Defaults to `false` — plugins are
957    /// strictly opt-in.
958    #[serde(default)]
959    pub enabled: bool,
960    /// Directory to scan at startup for `.wasm` plugin files.
961    #[serde(default = "default_plugin_dir")]
962    pub plugin_dir: String,
963    /// Watch `plugin_dir` for file changes and reload plugins hot.
964    #[serde(default)]
965    pub hot_reload: bool,
966    /// Memory limit per plugin instance, in megabytes.
967    #[serde(default = "default_plugin_memory_mb")]
968    pub memory_limit_mb: usize,
969    /// Execution timeout per hook call, in milliseconds.
970    #[serde(default = "default_plugin_timeout_ms")]
971    pub timeout_ms: u64,
972    /// Maximum number of concurrently-loaded plugins.
973    #[serde(default = "default_plugin_max")]
974    pub max_plugins: usize,
975    /// Enable per-call CPU-cycle (fuel) metering to bound plugin runtime.
976    #[serde(default = "default_true")]
977    pub fuel_metering: bool,
978    /// Fuel units allowed per hook call when `fuel_metering = true`.
979    #[serde(default = "default_plugin_fuel")]
980    pub fuel_limit: u64,
981    /// Optional Ed25519 trust-root directory. When set, every loaded
982    /// .wasm requires a sidecar .sig that verifies against one of
983    /// the *.pub files in this directory. When omitted, signatures
984    /// are not checked (preserves the dev-loop ergonomic of dropping
985    /// unsigned .wasm files in the plugin dir).
986    #[serde(default)]
987    pub trust_root: Option<String>,
988}
989
990fn default_plugin_dir() -> String {
991    "/etc/heliosproxy/plugins".to_string()
992}
993fn default_plugin_memory_mb() -> usize {
994    64
995}
996fn default_plugin_timeout_ms() -> u64 {
997    100
998}
999fn default_plugin_max() -> usize {
1000    20
1001}
1002fn default_true() -> bool {
1003    true
1004}
1005fn default_plugin_fuel() -> u64 {
1006    1_000_000
1007}
1008
1009impl Default for PluginToml {
1010    fn default() -> Self {
1011        Self {
1012            enabled: false,
1013            plugin_dir: default_plugin_dir(),
1014            hot_reload: false,
1015            memory_limit_mb: default_plugin_memory_mb(),
1016            timeout_ms: default_plugin_timeout_ms(),
1017            max_plugins: default_plugin_max(),
1018            fuel_metering: true,
1019            fuel_limit: default_plugin_fuel(),
1020            trust_root: None,
1021        }
1022    }
1023}
1024
1025// =============================================================================
1026// ENV-VAR SUBSTITUTION + UNKNOWN-KEY DETECTION (config-loader helpers)
1027// =============================================================================
1028
1029/// Expand `${NAME}` and `${NAME:-default}` environment-variable references in a
1030/// raw config file, in place, BEFORE it is parsed as TOML.
1031///
1032/// * `${NAME}`          → the value of env var `NAME`; returns an `Err` naming
1033///   `NAME` if it is unset (fail-fast, 12-factor — the literal is never left
1034///   in the output).
1035/// * `${NAME:-default}` → env var `NAME` if set, otherwise the literal
1036///   `default` (which may be empty; the default text runs up to the first `}`).
1037///
1038/// `NAME` must match `[A-Za-z_][A-Za-z0-9_]*`. Substitution is IN PLACE, so an
1039/// unquoted `${POOL_MAX:-50}` becomes the bare token `50` (valid TOML) and a
1040/// quoted `"${X:-y}"` becomes `"y"`.
1041///
1042/// SECURITY: this is plain string substitution. It performs ONLY an env lookup
1043/// plus the `:-` default operator — it never spawns a shell or evaluates
1044/// anything. Trust boundary: substitution is textual and runs BEFORE the TOML
1045/// parse, so a substituted value (env var or `:-default`) that contains a quote
1046/// or newline can inject arbitrary TOML structure. Env vars and the config file
1047/// are operator-controlled, so this is acceptable; do NOT feed an
1048/// untrusted-party-controlled env var into a `${...}` reference.
1049///
1050/// Comment-awareness is intentionally LINE-LEVEL only: a line whose first
1051/// non-whitespace byte is `#` (a full-line TOML comment) is copied verbatim so
1052/// the many commented `${VAR}` examples in the shipped reference configs (some
1053/// with no `:-default`) do not trigger the unset-variable error. A trailing
1054/// `#` comment on a value line is NOT treated as a comment — a full
1055/// TOML-comment-aware tokenizer is overkill for the shipped configs and out of
1056/// scope here.
1057fn substitute_env(text: &str) -> Result<String> {
1058    // `split_inclusive` keeps the line terminator attached to each piece, so
1059    // reassembling preserves the original text (including a missing trailing
1060    // newline) exactly outside of the substituted spans.
1061    let mut out = String::with_capacity(text.len());
1062    for line in text.split_inclusive('\n') {
1063        if line.trim_start().starts_with('#') {
1064            // Full-line comment: copy verbatim, never substitute.
1065            out.push_str(line);
1066        } else {
1067            substitute_line(line, &mut out)?;
1068        }
1069    }
1070    Ok(out)
1071}
1072
1073/// Expand every `${...}` reference in a single (non-comment) line into `out`.
1074fn substitute_line(line: &str, out: &mut String) -> Result<()> {
1075    let mut rest = line;
1076    while let Some(idx) = rest.find("${") {
1077        out.push_str(&rest[..idx]);
1078        let body = &rest[idx + 2..]; // text after the opening "${"
1079        match parse_placeholder(body)? {
1080            Some((value, consumed)) => {
1081                out.push_str(&value);
1082                rest = &body[consumed..];
1083            }
1084            None => {
1085                // Not a well-formed `${NAME...}`: emit the literal "${" and
1086                // keep scanning after it so a later valid placeholder on the
1087                // same line still expands.
1088                out.push_str("${");
1089                rest = body;
1090            }
1091        }
1092    }
1093    out.push_str(rest);
1094    Ok(())
1095}
1096
1097/// Parse the body of a placeholder (the text AFTER the opening `${`).
1098///
1099/// On success returns `(replacement, consumed)` where `consumed` is the number
1100/// of bytes of `body` consumed INCLUDING the closing `}`. Returns `Ok(None)`
1101/// when `body` is not a well-formed placeholder (the caller leaves it literal).
1102/// Returns `Err` only for a well-formed `${NAME}` (no default) whose env var is
1103/// unset.
1104fn parse_placeholder(body: &str) -> Result<Option<(String, usize)>> {
1105    let bytes = body.as_bytes();
1106    // NAME = [A-Za-z_][A-Za-z0-9_]*
1107    let mut n = 0;
1108    while n < bytes.len() {
1109        let b = bytes[n];
1110        let valid = if n == 0 {
1111            b.is_ascii_alphabetic() || b == b'_'
1112        } else {
1113            b.is_ascii_alphanumeric() || b == b'_'
1114        };
1115        if valid {
1116            n += 1;
1117        } else {
1118            break;
1119        }
1120    }
1121    if n == 0 {
1122        return Ok(None); // no valid NAME after `${`
1123    }
1124    let name = &body[..n];
1125    let after = &body[n..];
1126
1127    if after.starts_with('}') {
1128        // `${NAME}` — must be set, no fallback.
1129        match std::env::var(name) {
1130            Ok(v) => Ok(Some((v, n + 1))),
1131            Err(_) => Err(ProxyError::Config(format!(
1132                "config env-var substitution: `${{{name}}}` references environment \
1133                 variable `{name}`, which is not set (and no `:-default` fallback \
1134                 was given)"
1135            ))),
1136        }
1137    } else if let Some(after_op) = after.strip_prefix(":-") {
1138        // `${NAME:-default}` — default runs up to the first `}`.
1139        match after_op.find('}') {
1140            Some(end) => {
1141                let default = &after_op[..end];
1142                let value = std::env::var(name).unwrap_or_else(|_| default.to_string());
1143                // consumed = NAME(n) + ":-"(2) + default(end) + "}"(1)
1144                Ok(Some((value, n + 2 + end + 1)))
1145            }
1146            None => Ok(None), // unterminated placeholder: leave literal
1147        }
1148    } else {
1149        Ok(None) // NAME followed by an unexpected char: leave literal
1150    }
1151}
1152
1153/// Top-level keys recognised by [`ProxyConfig`]. Keep in sync with the struct's
1154/// fields (there are no field-level `#[serde(rename)]`s, so these are the exact
1155/// Rust field names). Used ONLY to warn on unknown top-level sections/keys;
1156/// deserialization itself still silently ignores unknowns (there is no
1157/// `deny_unknown_fields`), so a stale entry here only mutes or duplicates a
1158/// warning — it can never reject a config. The
1159/// `test_known_top_level_keys_cover_struct_fields` drift guard fails CI if a
1160/// new non-optional field is added without updating this list.
1161const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
1162    "listen_address",
1163    "admin_address",
1164    "admin_token",
1165    "admin_allow_insecure",
1166    "tr_enabled",
1167    "tr_mode",
1168    "pool",
1169    "pool_mode",
1170    "load_balancer",
1171    "health",
1172    "nodes",
1173    "tls",
1174    "write_timeout_secs",
1175    "plugins",
1176    "hba",
1177    "auth",
1178    "mcp",
1179    "agent_contracts",
1180    "http_gateway",
1181    "mirror",
1182    "edge",
1183    "branch",
1184    "routing_hints",
1185    "rate_limit",
1186    "circuit_breaker",
1187    "analytics",
1188    "lag_routing",
1189    "cache",
1190    "query_rewrite",
1191    "multi_tenancy",
1192    "schema_routing",
1193    "graphql_gateway",
1194    "optimize_unnamed_parse",
1195    "shutdown_drain_timeout_secs",
1196];
1197
1198/// Detect TOP-LEVEL TOML keys that are not fields of [`ProxyConfig`] (silent
1199/// doc drift / typos). Pure and testable: parses `text` as a TOML table and
1200/// diffs its top-level keys against [`KNOWN_TOP_LEVEL_KEYS`], returning the
1201/// unknowns sorted. Nested unknown keys (e.g. `[cache.l1]`) are intentionally
1202/// OUT OF SCOPE. If `text` does not parse as a TOML table this returns empty —
1203/// the caller's `toml::from_str` already reports genuine parse errors.
1204fn unknown_top_level_keys(text: &str) -> Vec<String> {
1205    let Ok(value) = toml::from_str::<toml::Value>(text) else {
1206        return Vec::new();
1207    };
1208    let Some(table) = value.as_table() else {
1209        return Vec::new();
1210    };
1211    let mut unknown: Vec<String> = table
1212        .keys()
1213        .filter(|k| !KNOWN_TOP_LEVEL_KEYS.contains(&k.as_str()))
1214        .cloned()
1215        .collect();
1216    unknown.sort();
1217    unknown
1218}
1219
1220impl ProxyConfig {
1221    /// Get write timeout as Duration
1222    pub fn write_timeout(&self) -> Duration {
1223        Duration::from_secs(self.write_timeout_secs)
1224    }
1225
1226    /// Load configuration from file
1227    pub fn from_file(path: &str) -> Result<Self> {
1228        let path = Path::new(path);
1229
1230        if !path.exists() {
1231            return Err(ProxyError::Config(format!(
1232                "Configuration file not found: {}",
1233                path.display()
1234            )));
1235        }
1236
1237        let raw = std::fs::read_to_string(path)
1238            .map_err(|e| ProxyError::Config(format!("Failed to read config: {}", e)))?;
1239
1240        // Expand `${VAR}` / `${VAR:-default}` references before parsing — the
1241        // 12-factor substitution the shipped example configs advertise and use.
1242        // Fails fast (naming the variable) if a bare `${VAR}` has no value and
1243        // no `:-default` fallback.
1244        let contents = substitute_env(&raw)?;
1245
1246        let config: Self = toml::from_str(&contents)
1247            .map_err(|e| ProxyError::Config(format!("Failed to parse config: {}", e)))?;
1248
1249        // Surface unknown TOP-LEVEL sections/keys (e.g. documented-but-
1250        // unimplemented `[ha]`/`[logging]`/`[metrics]` blocks) as warnings so
1251        // silent doc drift becomes visible. We deliberately do NOT reject them:
1252        // there is no `deny_unknown_fields`, so deserialization already ignores
1253        // unknown fields and pre-existing configs with such sections keep
1254        // loading. Nested unknown keys are out of scope.
1255        for key in unknown_top_level_keys(&contents) {
1256            tracing::warn!(
1257                "unknown config section/key '{}' ignored (not part of ProxyConfig)",
1258                key
1259            );
1260        }
1261
1262        config.validate()?;
1263
1264        Ok(config)
1265    }
1266
1267    /// Add a node from host:port string
1268    pub fn add_node(&mut self, host_port: &str, role: &str) -> Result<()> {
1269        let parts: Vec<&str> = host_port.rsplitn(2, ':').collect();
1270        if parts.len() != 2 {
1271            return Err(ProxyError::Config(format!(
1272                "Invalid host:port format: {}",
1273                host_port
1274            )));
1275        }
1276
1277        let port: u16 = parts[0]
1278            .parse()
1279            .map_err(|_| ProxyError::Config(format!("Invalid port: {}", parts[0])))?;
1280
1281        let host = parts[1].to_string();
1282
1283        let role = match role {
1284            "primary" => NodeRole::Primary,
1285            "standby" => NodeRole::Standby,
1286            "replica" => NodeRole::ReadReplica,
1287            _ => return Err(ProxyError::Config(format!("Unknown role: {}", role))),
1288        };
1289
1290        self.nodes.push(NodeConfig {
1291            host,
1292            port,
1293            http_port: default_http_port(),
1294            role,
1295            weight: 100,
1296            enabled: true,
1297            name: None,
1298        });
1299
1300        Ok(())
1301    }
1302
1303    /// Validate configuration
1304    pub fn validate(&self) -> Result<()> {
1305        // Must have at least one node
1306        if self.nodes.is_empty() {
1307            return Err(ProxyError::Config(
1308                "No backend nodes configured".to_string(),
1309            ));
1310        }
1311
1312        // Must have a primary node
1313        let has_primary = self.nodes.iter().any(|n| n.role == NodeRole::Primary);
1314        if !has_primary {
1315            return Err(ProxyError::Config("No primary node configured".to_string()));
1316        }
1317
1318        // Validate pool config
1319        if self.pool.max_connections < self.pool.min_connections {
1320            return Err(ProxyError::Config(
1321                "max_connections must be >= min_connections".to_string(),
1322            ));
1323        }
1324
1325        // A zero health-check interval panics `tokio::time::interval` at
1326        // construction, silently killing the health task (which then never
1327        // probes, so the proxy keeps routing to dead backends). Reject it here;
1328        // the checker also clamps to a 1s floor as belt-and-suspenders.
1329        if self.health.check_interval_secs == 0 {
1330            return Err(ProxyError::Config(
1331                "health.check_interval_secs must be >= 1".to_string(),
1332            ));
1333        }
1334
1335        // Refuse to expose the admin API beyond loopback without a token. The
1336        // admin surface runs privileged operations (arbitrary SQL via
1337        // /api/sql, forced failover via /api/chaos, migration cutover, branch
1338        // CREATE/DROP DATABASE, replay/shadow against operator-chosen targets),
1339        // so an anonymous non-loopback bind is a critical hole. Only enforced
1340        // when the address parses to a concrete IP; a hostname is left to the
1341        // operator's DNS/network policy.
1342        if self.admin_token.is_none() && !self.admin_allow_insecure {
1343            if let Ok(sa) = self.admin_address.parse::<std::net::SocketAddr>() {
1344                if !sa.ip().is_loopback() {
1345                    return Err(ProxyError::Config(format!(
1346                        "admin_address '{}' is not loopback but admin_token is unset — the admin \
1347                         API runs privileged operations and must not be exposed anonymously. Set \
1348                         admin_token, bind admin_address to 127.0.0.1, or set \
1349                         admin_allow_insecure = true to override.",
1350                        self.admin_address
1351                    )));
1352                }
1353            }
1354        }
1355
1356        // Edge / geo proxy mode. The [edge] section is parsed on every build
1357        // (so configs round-trip), but enabling it needs the compile-time
1358        // feature, and an edge role needs both its control plane (the home's
1359        // admin URL for the invalidation subscription) and its data plane
1360        // (at least one [[nodes]] entry pointing at the home's PG-wire
1361        // listener, through which misses and writes forward).
1362        if self.edge.enabled {
1363            if !cfg!(feature = "edge-proxy") {
1364                return Err(ProxyError::Config(
1365                    "edge.enabled = true but this binary was built without the 'edge-proxy' \
1366                     feature — rebuild with `--features edge-proxy` or remove/disable the \
1367                     [edge] section."
1368                        .to_string(),
1369                ));
1370            }
1371            // Same rationale as health.check_interval_secs: a zero interval
1372            // panics `tokio::time::interval` at construction, silently killing
1373            // the registry GC task. And a zero liveness window would prune
1374            // every edge on the first sweep. Reject both up front.
1375            if self.edge.subscribe_gc_secs == 0 {
1376                return Err(ProxyError::Config(
1377                    "edge.subscribe_gc_secs must be >= 1".to_string(),
1378                ));
1379            }
1380            if self.edge.liveness_window_secs == 0 {
1381                return Err(ProxyError::Config(
1382                    "edge.liveness_window_secs must be >= 1".to_string(),
1383                ));
1384            }
1385            // A zero TTL births every entry expired — the cache would
1386            // silently never serve a hit while looking enabled.
1387            if self.edge.default_ttl_secs == 0 {
1388                return Err(ProxyError::Config(
1389                    "edge.default_ttl_secs must be >= 1 when edge is enabled".to_string(),
1390                ));
1391            }
1392            if self.edge.role == crate::edge::EdgeRole::Edge {
1393                if self.edge.home_url.trim().is_empty() {
1394                    return Err(ProxyError::Config(
1395                        "edge.role = 'edge' requires edge.home_url — the home proxy's admin \
1396                         base URL (e.g. \"https://home-proxy:9090\") the edge subscribes to \
1397                         for cache invalidations."
1398                            .to_string(),
1399                    ));
1400                }
1401                // The auth_token is the home's ADMIN bearer (arbitrary SQL,
1402                // chaos, replay). Never transmit it in cleartext across the
1403                // edge<->home WAN link: require https, or an explicit opt-out
1404                // for provably private links (mirrors admin_allow_insecure).
1405                // URL schemes are case-insensitive (RFC 3986); compare lowered
1406                // so a legitimate `HTTPS://` is not wrongly rejected here (nor
1407                // left without downgrade protection in the client).
1408                if !self.edge.auth_token.is_empty()
1409                    && !self.edge.allow_insecure_home_url
1410                    && !self
1411                        .edge
1412                        .home_url
1413                        .trim()
1414                        .to_ascii_lowercase()
1415                        .starts_with("https://")
1416                {
1417                    return Err(ProxyError::Config(format!(
1418                        "edge.home_url '{}' is not https:// but edge.auth_token is set — the \
1419                         token is the home's admin bearer and must not cross the network in \
1420                         cleartext. Front the home admin port with a TLS terminator and use \
1421                         https://, or set edge.allow_insecure_home_url = true for private \
1422                         links (VPN/WireGuard/service mesh).",
1423                        self.edge.home_url.trim()
1424                    )));
1425                }
1426                // The query-result cache is not wired to SSE invalidations:
1427                // on an edge it would keep serving rows the home already
1428                // invalidated, for the full query-cache TTL. Refuse the
1429                // combination (the edge cache covers cacheable SELECTs here).
1430                if cfg!(feature = "query-cache") && self.cache.enabled {
1431                    return Err(ProxyError::Config(
1432                        "edge.role = 'edge' cannot be combined with [cache] enabled = true — \
1433                         the query-result cache does not receive edge invalidations and would \
1434                         serve stale rows past the edge coherence bound. Disable [cache] on \
1435                         edge-role proxies; the edge cache serves cacheable SELECTs there."
1436                            .to_string(),
1437                    ));
1438                }
1439                // Redundant with the global no-nodes check above today, but
1440                // kept for a message that says *what the node is for* in
1441                // edge mode should that check ever be relaxed.
1442                if self.nodes.is_empty() {
1443                    return Err(ProxyError::Config(
1444                        "edge.role = 'edge' requires at least one [[nodes]] entry pointing \
1445                         at the home proxy's PG-wire listener — cache misses and writes \
1446                         forward there."
1447                            .to_string(),
1448                    ));
1449                }
1450            }
1451        }
1452
1453        Ok(())
1454    }
1455
1456    /// Get primary node
1457    pub fn primary_node(&self) -> Option<&NodeConfig> {
1458        self.nodes
1459            .iter()
1460            .find(|n| n.role == NodeRole::Primary && n.enabled)
1461    }
1462
1463    /// Get standby nodes
1464    pub fn standby_nodes(&self) -> Vec<&NodeConfig> {
1465        self.nodes
1466            .iter()
1467            .filter(|n| n.role == NodeRole::Standby && n.enabled)
1468            .collect()
1469    }
1470
1471    /// Get all enabled nodes
1472    pub fn enabled_nodes(&self) -> Vec<&NodeConfig> {
1473        self.nodes.iter().filter(|n| n.enabled).collect()
1474    }
1475}
1476
1477/// TR (Transaction Replay) mode
1478#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1479#[serde(rename_all = "lowercase")]
1480#[derive(Default)]
1481pub enum TrMode {
1482    /// No transaction replay
1483    None,
1484    /// Re-establish session only
1485    #[default]
1486    Session,
1487    /// Re-execute SELECT queries
1488    Select,
1489    /// Full transaction replay
1490    Transaction,
1491}
1492
1493/// Connection pool configuration
1494#[derive(Debug, Clone, Serialize, Deserialize)]
1495pub struct PoolConfig {
1496    /// Minimum connections per node
1497    pub min_connections: usize,
1498    /// Maximum connections per node
1499    pub max_connections: usize,
1500    /// Connection idle timeout (seconds)
1501    pub idle_timeout_secs: u64,
1502    /// Maximum connection lifetime (seconds)
1503    pub max_lifetime_secs: u64,
1504    /// Connection acquire timeout (seconds)
1505    pub acquire_timeout_secs: u64,
1506    /// Test connection before use
1507    pub test_on_acquire: bool,
1508}
1509
1510impl Default for PoolConfig {
1511    fn default() -> Self {
1512        Self {
1513            min_connections: 2,
1514            max_connections: 100,
1515            idle_timeout_secs: 300,
1516            max_lifetime_secs: 1800,
1517            acquire_timeout_secs: 30,
1518            test_on_acquire: true,
1519        }
1520    }
1521}
1522
1523impl PoolConfig {
1524    /// Get idle timeout as Duration
1525    pub fn idle_timeout(&self) -> Duration {
1526        Duration::from_secs(self.idle_timeout_secs)
1527    }
1528
1529    /// Get max lifetime as Duration
1530    pub fn max_lifetime(&self) -> Duration {
1531        Duration::from_secs(self.max_lifetime_secs)
1532    }
1533
1534    /// Get acquire timeout as Duration
1535    pub fn acquire_timeout(&self) -> Duration {
1536        Duration::from_secs(self.acquire_timeout_secs)
1537    }
1538}
1539
1540/// Load balancer configuration
1541#[derive(Debug, Clone, Serialize, Deserialize)]
1542pub struct LoadBalancerConfig {
1543    /// Routing strategy for read queries
1544    pub read_strategy: Strategy,
1545    /// Enable read/write splitting
1546    pub read_write_split: bool,
1547    /// Latency threshold for unhealthy marking (ms)
1548    pub latency_threshold_ms: u64,
1549}
1550
1551impl Default for LoadBalancerConfig {
1552    fn default() -> Self {
1553        Self {
1554            read_strategy: Strategy::RoundRobin,
1555            read_write_split: true,
1556            latency_threshold_ms: 100,
1557        }
1558    }
1559}
1560
1561/// Load balancing strategy
1562#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1563#[serde(rename_all = "snake_case")]
1564pub enum Strategy {
1565    /// Round-robin across nodes
1566    RoundRobin,
1567    /// Weighted round-robin
1568    WeightedRoundRobin,
1569    /// Route to least loaded node
1570    LeastConnections,
1571    /// Route to lowest latency node
1572    LatencyBased,
1573    /// Random selection
1574    Random,
1575}
1576
1577/// Health check configuration
1578#[derive(Debug, Clone, Serialize, Deserialize)]
1579pub struct HealthConfig {
1580    /// Check interval (seconds)
1581    pub check_interval_secs: u64,
1582    /// Check timeout (seconds)
1583    pub check_timeout_secs: u64,
1584    /// Failures before marking unhealthy
1585    pub failure_threshold: u32,
1586    /// Successes before marking healthy
1587    pub success_threshold: u32,
1588    /// Health check query
1589    pub check_query: String,
1590}
1591
1592impl Default for HealthConfig {
1593    fn default() -> Self {
1594        Self {
1595            check_interval_secs: 5,
1596            check_timeout_secs: 3,
1597            failure_threshold: 3,
1598            success_threshold: 2,
1599            check_query: "SELECT 1".to_string(),
1600        }
1601    }
1602}
1603
1604impl HealthConfig {
1605    /// Get check interval as Duration
1606    pub fn check_interval(&self) -> Duration {
1607        Duration::from_secs(self.check_interval_secs)
1608    }
1609
1610    /// Get check timeout as Duration
1611    pub fn check_timeout(&self) -> Duration {
1612        Duration::from_secs(self.check_timeout_secs)
1613    }
1614}
1615
1616/// Backend node configuration
1617#[derive(Debug, Clone, Serialize, Deserialize)]
1618pub struct NodeConfig {
1619    /// Node host
1620    pub host: String,
1621    /// Node port (PostgreSQL protocol)
1622    pub port: u16,
1623    /// Node HTTP API port (for SQL API forwarding)
1624    /// Defaults to 8080 if not specified
1625    #[serde(default = "default_http_port")]
1626    pub http_port: u16,
1627    /// Node role
1628    pub role: NodeRole,
1629    /// Weight for load balancing
1630    pub weight: u32,
1631    /// Whether node is enabled
1632    pub enabled: bool,
1633    /// Optional node name for logging
1634    pub name: Option<String>,
1635}
1636
1637fn default_http_port() -> u16 {
1638    8080
1639}
1640
1641impl NodeConfig {
1642    /// Get address string
1643    pub fn address(&self) -> String {
1644        format!("{}:{}", self.host, self.port)
1645    }
1646
1647    /// Get display name
1648    pub fn display_name(&self) -> &str {
1649        self.name.as_deref().unwrap_or(&self.host)
1650    }
1651}
1652
1653/// Node role
1654#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1655#[serde(rename_all = "lowercase")]
1656pub enum NodeRole {
1657    /// Primary node (accepts writes)
1658    Primary,
1659    /// Standby node (can be promoted)
1660    Standby,
1661    /// Read replica (read-only, cannot be promoted)
1662    #[serde(rename = "replica")]
1663    ReadReplica,
1664}
1665
1666/// TLS configuration
1667#[derive(Debug, Clone, Serialize, Deserialize)]
1668pub struct TlsConfig {
1669    /// Enable TLS for client connections
1670    pub enabled: bool,
1671    /// Path to certificate file
1672    pub cert_path: String,
1673    /// Path to private key file
1674    pub key_path: String,
1675    /// Path to CA certificate (for client verification)
1676    pub ca_path: Option<String>,
1677    /// Require client certificates
1678    pub require_client_cert: bool,
1679}
1680
1681#[cfg(test)]
1682mod tests {
1683    use super::*;
1684
1685    #[test]
1686    fn test_default_config() {
1687        let config = ProxyConfig::default();
1688        assert_eq!(config.listen_address, "0.0.0.0:5432");
1689        assert!(config.tr_enabled);
1690    }
1691
1692    #[test]
1693    fn test_add_node() {
1694        let mut config = ProxyConfig::default();
1695        config.add_node("localhost:5432", "primary").unwrap();
1696        config.add_node("localhost:5433", "standby").unwrap();
1697
1698        assert_eq!(config.nodes.len(), 2);
1699        assert!(config.primary_node().is_some());
1700        assert_eq!(config.standby_nodes().len(), 1);
1701    }
1702
1703    // -------------------------------------------------------------------------
1704    // Env-var substitution (`${VAR}` / `${VAR:-default}`)
1705    //
1706    // Tests use `HELIOS_SUBST_TEST_*` variable names that no shipped config
1707    // references, so they cannot perturb `test_all_shipped_configs_parse`
1708    // (Cargo runs tests in parallel threads that share one process env).
1709    // -------------------------------------------------------------------------
1710
1711    #[test]
1712    fn test_substitute_env_set_value_wins() {
1713        std::env::set_var("HELIOS_SUBST_TEST_SET", "hello");
1714        // `${NAME:-default}`: a set var beats the default.
1715        assert_eq!(
1716            substitute_env("x = \"${HELIOS_SUBST_TEST_SET:-fallback}\"").unwrap(),
1717            "x = \"hello\""
1718        );
1719        // `${NAME}`: bare form uses the set value.
1720        assert_eq!(
1721            substitute_env("x = \"${HELIOS_SUBST_TEST_SET}\"").unwrap(),
1722            "x = \"hello\""
1723        );
1724        std::env::remove_var("HELIOS_SUBST_TEST_SET");
1725    }
1726
1727    #[test]
1728    fn test_substitute_env_default_fallback() {
1729        std::env::remove_var("HELIOS_SUBST_TEST_UNSET_A");
1730        assert_eq!(
1731            substitute_env("s = \"${HELIOS_SUBST_TEST_UNSET_A:-abc}\"").unwrap(),
1732            "s = \"abc\""
1733        );
1734    }
1735
1736    #[test]
1737    fn test_substitute_env_empty_default() {
1738        std::env::remove_var("HELIOS_SUBST_TEST_UNSET_B");
1739        assert_eq!(
1740            substitute_env("s = \"${HELIOS_SUBST_TEST_UNSET_B:-}\"").unwrap(),
1741            "s = \"\""
1742        );
1743    }
1744
1745    #[test]
1746    fn test_substitute_env_missing_no_default_errors() {
1747        std::env::remove_var("HELIOS_SUBST_TEST_UNSET_C");
1748        let err = substitute_env("s = \"${HELIOS_SUBST_TEST_UNSET_C}\"").unwrap_err();
1749        let msg = err.to_string();
1750        assert!(
1751            msg.contains("HELIOS_SUBST_TEST_UNSET_C"),
1752            "error must name the missing variable, got: {msg}"
1753        );
1754    }
1755
1756    #[test]
1757    fn test_substitute_env_skips_full_line_comments() {
1758        std::env::remove_var("HELIOS_SUBST_TEST_UNSET_D");
1759        // A commented line carrying a bare `${VAR}` (no default) must neither
1760        // error nor be substituted — it is copied verbatim.
1761        let input = "  # default_password = \"${HELIOS_SUBST_TEST_UNSET_D}\"\nx = 1\n";
1762        assert_eq!(substitute_env(input).unwrap(), input);
1763    }
1764
1765    #[test]
1766    fn test_substitute_env_multiple_on_one_line() {
1767        std::env::remove_var("HELIOS_SUBST_TEST_UNSET_E");
1768        std::env::remove_var("HELIOS_SUBST_TEST_UNSET_F");
1769        assert_eq!(
1770            substitute_env(
1771                "addr = \"${HELIOS_SUBST_TEST_UNSET_E:-host}:${HELIOS_SUBST_TEST_UNSET_F:-5432}\""
1772            )
1773            .unwrap(),
1774            "addr = \"host:5432\""
1775        );
1776    }
1777
1778    #[test]
1779    fn test_substitute_env_unquoted_numeric_position() {
1780        std::env::remove_var("HELIOS_SUBST_TEST_UNSET_G");
1781        // Unquoted `${..:-50}` must become the bare token `50` (valid TOML).
1782        let out = substitute_env("max_connections = ${HELIOS_SUBST_TEST_UNSET_G:-50}").unwrap();
1783        assert_eq!(out, "max_connections = 50");
1784        // ...and that must now deserialize as an integer, not a string.
1785        #[derive(serde::Deserialize)]
1786        struct P {
1787            max_connections: u32,
1788        }
1789        let p: P = toml::from_str(&out).unwrap();
1790        assert_eq!(p.max_connections, 50);
1791    }
1792
1793    #[test]
1794    fn test_substitute_env_leaves_malformed_literal() {
1795        // A `$` not opening a valid `${NAME...}` is left untouched.
1796        assert_eq!(substitute_env("cost = $5.00\n").unwrap(), "cost = $5.00\n");
1797        std::env::remove_var("HELIOS_SUBST_TEST_UNSET_H");
1798        // Unterminated placeholder is left literal (no error).
1799        assert_eq!(
1800            substitute_env("x = \"${HELIOS_SUBST_TEST_UNSET_H:-oops\"").unwrap(),
1801            "x = \"${HELIOS_SUBST_TEST_UNSET_H:-oops\""
1802        );
1803    }
1804
1805    // -------------------------------------------------------------------------
1806    // Unknown top-level key detection
1807    // -------------------------------------------------------------------------
1808
1809    #[test]
1810    fn test_unknown_top_level_keys_detection() {
1811        let text = "listen_address = \"x\"\n\
1812                    [pool]\nmin_connections = 1\n\
1813                    [ha]\nenabled = true\n\
1814                    [logging]\nlevel = \"info\"\n";
1815        // `listen_address` and `pool` are known; `ha` and `logging` are not.
1816        assert_eq!(
1817            unknown_top_level_keys(text),
1818            vec!["ha".to_string(), "logging".to_string()]
1819        );
1820    }
1821
1822    #[test]
1823    fn test_unknown_top_level_keys_nested_are_out_of_scope() {
1824        // `[cache.l1]` is a NESTED unknown key under the known `cache` table;
1825        // it must NOT be reported (nested detection is out of scope).
1826        let text = "[cache]\nenabled = true\n[cache.l1]\nsize = 500\n";
1827        assert!(unknown_top_level_keys(text).is_empty());
1828    }
1829
1830    #[test]
1831    fn test_known_top_level_keys_cover_struct_fields() {
1832        // Drift guard: every key a default `ProxyConfig` serialises to must be
1833        // present in `KNOWN_TOP_LEVEL_KEYS`. `Option` fields that default to
1834        // `None` (`tls`, `admin_token`) are absent from the serialised form, so
1835        // this is a subset check — it still catches a new non-optional field
1836        // added without updating the list.
1837        let value = toml::Value::try_from(ProxyConfig::default()).unwrap();
1838        let table = value.as_table().unwrap();
1839        for k in table.keys() {
1840            assert!(
1841                KNOWN_TOP_LEVEL_KEYS.contains(&k.as_str()),
1842                "field '{k}' is present in a serialised default ProxyConfig but \
1843                 missing from KNOWN_TOP_LEVEL_KEYS"
1844            );
1845        }
1846    }
1847
1848    // -------------------------------------------------------------------------
1849    // CI guard: every shipped config must load after substitution
1850    // -------------------------------------------------------------------------
1851
1852    #[test]
1853    fn test_all_shipped_configs_parse() {
1854        // For every `config/*.toml` and `scripts/regress/*.toml`: run env
1855        // substitution (with NO env vars set → `:-default` fallbacks) and assert
1856        // it deserializes into `ProxyConfig`.
1857        //
1858        // For the `config/*.toml` reference configs we go further and run the
1859        // FULL load path — `ProxyConfig::from_file`, which does substitution +
1860        // parse + `validate()` exactly as `heliosdb-proxy -c <file>` does. This
1861        // is the real guard behind "the shipped configs load": a deserialize-only
1862        // check passes even when `validate()` would reject the file (e.g. the
1863        // admin-loopback guard), so it would not have caught the non-loopback
1864        // `admin_address` default that made every reference config fail to start.
1865        //
1866        // The `scripts/regress/*.toml` files are intentionally deserialize-only:
1867        // they reference placeholder/non-loopback backend hosts, some enable the
1868        // `[edge]` section (which `validate()` gates on the compile-time
1869        // `edge-proxy` feature and on an `edge.home_url`), so `validate()` is not
1870        // universally applicable there. We still guarantee they parse.
1871        let manifest = env!("CARGO_MANIFEST_DIR");
1872        let config_dir = format!("{manifest}/config");
1873        let regress_dir = format!("{manifest}/scripts/regress");
1874
1875        // Reference configs: must survive the entire from_file() load path.
1876        let mut config_checked = 0usize;
1877        let entries = std::fs::read_dir(&config_dir)
1878            .unwrap_or_else(|e| panic!("config dir {config_dir} unreadable: {e}"));
1879        for entry in entries {
1880            let path = entry.unwrap().path();
1881            if path.extension().and_then(|e| e.to_str()) != Some("toml") {
1882                continue;
1883            }
1884            let path_str = path
1885                .to_str()
1886                .unwrap_or_else(|| panic!("non-UTF-8 config path {}", path.display()));
1887            let loaded = ProxyConfig::from_file(path_str);
1888            assert!(
1889                loaded.is_ok(),
1890                "shipped config {} failed to load via from_file() \
1891                 (substitute + parse + validate): {}",
1892                path.display(),
1893                loaded.err().unwrap()
1894            );
1895            config_checked += 1;
1896        }
1897        assert!(
1898            config_checked >= 3,
1899            "expected to load at least the 3 config/*.toml files, checked {config_checked}"
1900        );
1901
1902        // Regression harness configs: must at least deserialize after
1903        // substitution (validate() deliberately skipped — see above).
1904        if let Ok(entries) = std::fs::read_dir(&regress_dir) {
1905            for entry in entries {
1906                let path = entry.unwrap().path();
1907                if path.extension().and_then(|e| e.to_str()) != Some("toml") {
1908                    continue;
1909                }
1910                let raw = std::fs::read_to_string(&path).unwrap();
1911                let substituted = substitute_env(&raw).unwrap_or_else(|e| {
1912                    panic!("env substitution failed for {}: {e}", path.display())
1913                });
1914                let parsed = toml::from_str::<ProxyConfig>(&substituted);
1915                assert!(
1916                    parsed.is_ok(),
1917                    "regress config {} failed to deserialize: {}",
1918                    path.display(),
1919                    parsed.err().unwrap()
1920                );
1921            }
1922        }
1923    }
1924
1925    #[test]
1926    fn test_validate_no_nodes() {
1927        let config = ProxyConfig::default();
1928        assert!(config.validate().is_err());
1929    }
1930
1931    #[test]
1932    fn test_validate_no_primary() {
1933        let mut config = ProxyConfig::default();
1934        config.add_node("localhost:5432", "standby").unwrap();
1935        assert!(config.validate().is_err());
1936    }
1937
1938    #[test]
1939    fn test_validate_success() {
1940        let mut config = ProxyConfig::default();
1941        config.add_node("localhost:5432", "primary").unwrap();
1942        assert!(config.validate().is_ok());
1943    }
1944
1945    #[test]
1946    fn test_validate_refuses_anonymous_nonloopback_admin() {
1947        let base = || {
1948            let mut c = ProxyConfig::default();
1949            c.add_node("localhost:5432", "primary").unwrap();
1950            c
1951        };
1952        // Loopback + no token: allowed (the default).
1953        let mut c = base();
1954        c.admin_address = "127.0.0.1:9090".to_string();
1955        assert!(c.validate().is_ok());
1956        // Non-loopback + no token: REFUSED.
1957        let mut c = base();
1958        c.admin_address = "0.0.0.0:9090".to_string();
1959        assert!(
1960            c.validate().is_err(),
1961            "anonymous 0.0.0.0 admin must be refused"
1962        );
1963        // Non-loopback + token: allowed.
1964        let mut c = base();
1965        c.admin_address = "0.0.0.0:9090".to_string();
1966        c.admin_token = Some("secret".to_string());
1967        assert!(c.validate().is_ok());
1968        // Non-loopback + explicit opt-in: allowed.
1969        let mut c = base();
1970        c.admin_address = "0.0.0.0:9090".to_string();
1971        c.admin_allow_insecure = true;
1972        assert!(c.validate().is_ok());
1973    }
1974
1975    #[test]
1976    fn test_validate_rejects_zero_health_interval() {
1977        // A zero health-check interval panics tokio::time::interval; validation
1978        // must reject it up front rather than let the health task die silently.
1979        let mut config = ProxyConfig::default();
1980        config.add_node("localhost:5432", "primary").unwrap();
1981        config.health.check_interval_secs = 0;
1982        assert!(config.validate().is_err());
1983        config.health.check_interval_secs = 1;
1984        assert!(config.validate().is_ok());
1985    }
1986
1987    #[test]
1988    fn test_validate_edge_disabled_section_is_inert() {
1989        // The default [edge] section (enabled = false) must never affect
1990        // validation, whatever features the binary was built with.
1991        let mut config = ProxyConfig::default();
1992        config.add_node("localhost:5432", "primary").unwrap();
1993        assert!(!config.edge.enabled);
1994        assert!(config.validate().is_ok());
1995    }
1996
1997    #[test]
1998    fn test_validate_edge_enabled_requires_feature() {
1999        let mut config = ProxyConfig::default();
2000        config.add_node("localhost:5432", "primary").unwrap();
2001        config.edge.enabled = true;
2002        // role=home needs nothing beyond the compile-time feature.
2003        if cfg!(feature = "edge-proxy") {
2004            assert!(config.validate().is_ok());
2005        } else {
2006            assert!(
2007                config.validate().is_err(),
2008                "edge.enabled on a build without the edge-proxy feature must be refused"
2009            );
2010        }
2011    }
2012
2013    #[cfg(feature = "edge-proxy")]
2014    #[test]
2015    fn test_validate_edge_rejects_zero_intervals() {
2016        // Zero GC cadence panics tokio::time::interval; zero liveness
2017        // window prunes every edge on the first sweep. Both refused.
2018        let base = || {
2019            let mut c = ProxyConfig::default();
2020            c.add_node("localhost:5432", "primary").unwrap();
2021            c.edge.enabled = true;
2022            c
2023        };
2024        let mut c = base();
2025        c.edge.subscribe_gc_secs = 0;
2026        assert!(c.validate().is_err());
2027        let mut c = base();
2028        c.edge.liveness_window_secs = 0;
2029        assert!(c.validate().is_err());
2030        // Only enforced when the section is enabled: an inert config
2031        // with odd values must not fail validation.
2032        let mut c = base();
2033        c.edge.enabled = false;
2034        c.edge.subscribe_gc_secs = 0;
2035        assert!(c.validate().is_ok());
2036    }
2037
2038    #[cfg(feature = "edge-proxy")]
2039    #[test]
2040    fn test_validate_edge_role_requires_home_url() {
2041        let base = || {
2042            let mut c = ProxyConfig::default();
2043            c.add_node("localhost:5432", "primary").unwrap();
2044            c.edge.enabled = true;
2045            c.edge.role = crate::edge::EdgeRole::Edge;
2046            c
2047        };
2048        // role=edge without home_url: refused, and the message says why.
2049        let c = base();
2050        let err = c.validate().unwrap_err().to_string();
2051        assert!(err.contains("home_url"), "unexpected error: {}", err);
2052        // role=edge + home_url (+ the node from base): allowed.
2053        let mut c = base();
2054        c.edge.home_url = "http://home-proxy:9090".to_string();
2055        assert!(c.validate().is_ok());
2056    }
2057
2058    #[cfg(feature = "edge-proxy")]
2059    #[test]
2060    fn test_validate_edge_zero_ttl_refused_when_enabled() {
2061        let mut c = ProxyConfig::default();
2062        c.add_node("localhost:5432", "primary").unwrap();
2063        c.edge.enabled = true;
2064        c.edge.default_ttl_secs = 0;
2065        let err = c.validate().unwrap_err().to_string();
2066        assert!(
2067            err.contains("default_ttl_secs"),
2068            "unexpected error: {}",
2069            err
2070        );
2071        // Inert section: odd values tolerated when disabled.
2072        c.edge.enabled = false;
2073        assert!(c.validate().is_ok());
2074    }
2075
2076    #[cfg(feature = "edge-proxy")]
2077    #[test]
2078    fn test_validate_edge_token_requires_https_home_url() {
2079        let base = || {
2080            let mut c = ProxyConfig::default();
2081            c.add_node("localhost:5432", "primary").unwrap();
2082            c.edge.enabled = true;
2083            c.edge.role = crate::edge::EdgeRole::Edge;
2084            c.edge.home_url = "http://home-proxy:9090".to_string();
2085            c
2086        };
2087        // Tokenless plain-http: fine (no credential to leak).
2088        assert!(base().validate().is_ok());
2089        // Token over plain http: refused, message names both remedies.
2090        let mut c = base();
2091        c.edge.auth_token = "secret".to_string();
2092        let err = c.validate().unwrap_err().to_string();
2093        assert!(err.contains("https"), "unexpected error: {}", err);
2094        assert!(err.contains("allow_insecure_home_url"), "{}", err);
2095        // Explicit opt-out for private links: allowed.
2096        let mut c = base();
2097        c.edge.auth_token = "secret".to_string();
2098        c.edge.allow_insecure_home_url = true;
2099        assert!(c.validate().is_ok());
2100        // Token over https: allowed.
2101        let mut c = base();
2102        c.edge.auth_token = "secret".to_string();
2103        c.edge.home_url = "https://home-proxy:9090".to_string();
2104        assert!(c.validate().is_ok());
2105    }
2106
2107    #[cfg(all(feature = "edge-proxy", feature = "query-cache"))]
2108    #[test]
2109    fn test_validate_edge_role_rejects_query_cache_combo() {
2110        // The query-result cache never hears SSE invalidations — on an
2111        // edge it would serve stale rows past the edge coherence bound.
2112        let mut c = ProxyConfig::default();
2113        c.add_node("localhost:5432", "primary").unwrap();
2114        c.edge.enabled = true;
2115        c.edge.role = crate::edge::EdgeRole::Edge;
2116        c.edge.home_url = "https://home-proxy:9090".to_string();
2117        c.cache.enabled = true;
2118        let err = c.validate().unwrap_err().to_string();
2119        assert!(err.contains("[cache]"), "unexpected error: {}", err);
2120        // Home role + query-cache stays permitted (local writes
2121        // invalidate both caches on the same path).
2122        c.edge.role = crate::edge::EdgeRole::Home;
2123        c.edge.home_url.clear();
2124        assert!(c.validate().is_ok());
2125        // Edge role with the cache disabled is fine.
2126        c.edge.role = crate::edge::EdgeRole::Edge;
2127        c.edge.home_url = "https://home-proxy:9090".to_string();
2128        c.cache.enabled = false;
2129        assert!(c.validate().is_ok());
2130    }
2131
2132    #[test]
2133    fn test_pool_config_durations() {
2134        let config = PoolConfig::default();
2135        assert_eq!(config.idle_timeout(), Duration::from_secs(300));
2136        assert_eq!(config.max_lifetime(), Duration::from_secs(1800));
2137    }
2138
2139    #[test]
2140    fn test_pool_mode_default() {
2141        let config = PoolModeConfig::default();
2142        assert_eq!(config.mode, PoolingMode::Session);
2143        assert_eq!(config.max_pool_size, 100);
2144        assert_eq!(config.min_idle, 10);
2145        assert_eq!(config.reset_query, "DISCARD ALL");
2146    }
2147
2148    #[test]
2149    fn test_pool_mode_session() {
2150        let config = PoolModeConfig::session_mode();
2151        assert_eq!(config.mode, PoolingMode::Session);
2152        assert_eq!(config.prepared_statement_mode, PreparedStatementMode::Named);
2153    }
2154
2155    #[test]
2156    fn test_pool_mode_transaction() {
2157        let config = PoolModeConfig::transaction_mode();
2158        assert_eq!(config.mode, PoolingMode::Transaction);
2159        assert_eq!(config.prepared_statement_mode, PreparedStatementMode::Track);
2160    }
2161
2162    #[test]
2163    fn test_pool_mode_statement() {
2164        let config = PoolModeConfig::statement_mode();
2165        assert_eq!(config.mode, PoolingMode::Statement);
2166        assert_eq!(
2167            config.prepared_statement_mode,
2168            PreparedStatementMode::Disable
2169        );
2170    }
2171
2172    #[test]
2173    fn test_pool_mode_durations() {
2174        let config = PoolModeConfig::default();
2175        assert_eq!(config.idle_timeout(), Duration::from_secs(600));
2176        assert_eq!(config.max_lifetime(), Duration::from_secs(3600));
2177        assert_eq!(config.acquire_timeout(), Duration::from_secs(5));
2178    }
2179
2180    #[test]
2181    fn test_proxy_config_has_pool_mode() {
2182        let config = ProxyConfig::default();
2183        assert_eq!(config.pool_mode.mode, PoolingMode::Session);
2184    }
2185
2186    /// `plugins` defaults to `enabled = false` so adding the field to
2187    /// `ProxyConfig` doesn't spontaneously turn on the plugin subsystem
2188    /// for existing deployments.
2189    #[test]
2190    fn test_plugin_toml_default_is_disabled() {
2191        let config = ProxyConfig::default();
2192        assert!(!config.plugins.enabled);
2193        assert_eq!(config.plugins.plugin_dir, "/etc/heliosproxy/plugins");
2194        assert_eq!(config.plugins.memory_limit_mb, 64);
2195        assert_eq!(config.plugins.timeout_ms, 100);
2196    }
2197
2198    /// Existing TOML configs (written before this field existed) must
2199    /// round-trip through `Deserialize` without failing. The `plugins`
2200    /// section is `#[serde(default)]`, so omitting it yields the default.
2201    #[test]
2202    fn test_proxy_config_toml_without_plugins_section_still_parses() {
2203        let toml_text = r#"
2204            listen_address = "0.0.0.0:5432"
2205            admin_address = "0.0.0.0:9090"
2206            tr_enabled = true
2207            tr_mode = "session"
2208            nodes = []
2209
2210            [pool]
2211            min_connections = 2
2212            max_connections = 10
2213            idle_timeout_secs = 300
2214            max_lifetime_secs = 1800
2215            acquire_timeout_secs = 30
2216            test_on_acquire = true
2217
2218            [load_balancer]
2219            read_strategy = "round_robin"
2220            read_write_split = true
2221            latency_threshold_ms = 100
2222
2223            [health]
2224            check_interval_secs = 5
2225            check_timeout_secs = 3
2226            failure_threshold = 3
2227            success_threshold = 2
2228            check_query = "SELECT 1"
2229        "#;
2230        let config: ProxyConfig = toml::from_str(toml_text).expect("parse");
2231        assert!(!config.plugins.enabled);
2232    }
2233
2234    /// A `[plugins]` section with overrides round-trips and populates the
2235    /// struct correctly.
2236    #[test]
2237    fn test_plugin_toml_overrides_parse() {
2238        let toml_text = r#"
2239            listen_address = "0.0.0.0:5432"
2240            admin_address = "0.0.0.0:9090"
2241            tr_enabled = true
2242            tr_mode = "session"
2243            nodes = []
2244
2245            [pool]
2246            min_connections = 2
2247            max_connections = 10
2248            idle_timeout_secs = 300
2249            max_lifetime_secs = 1800
2250            acquire_timeout_secs = 30
2251            test_on_acquire = true
2252
2253            [load_balancer]
2254            read_strategy = "round_robin"
2255            read_write_split = true
2256            latency_threshold_ms = 100
2257
2258            [health]
2259            check_interval_secs = 5
2260            check_timeout_secs = 3
2261            failure_threshold = 3
2262            success_threshold = 2
2263            check_query = "SELECT 1"
2264
2265            [plugins]
2266            enabled = true
2267            plugin_dir = "/tmp/helios-plugins"
2268            hot_reload = true
2269            memory_limit_mb = 128
2270            timeout_ms = 250
2271        "#;
2272        let config: ProxyConfig = toml::from_str(toml_text).expect("parse");
2273        assert!(config.plugins.enabled);
2274        assert_eq!(config.plugins.plugin_dir, "/tmp/helios-plugins");
2275        assert!(config.plugins.hot_reload);
2276        assert_eq!(config.plugins.memory_limit_mb, 128);
2277        assert_eq!(config.plugins.timeout_ms, 250);
2278        // Un-specified fields retain their defaults.
2279        assert_eq!(config.plugins.max_plugins, 20);
2280        assert!(config.plugins.fuel_metering);
2281    }
2282}