1use crate::{ProxyError, Result};
6use serde::{Deserialize, Serialize};
7use std::path::Path;
8use std::time::Duration;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
18#[serde(rename_all = "lowercase")]
19pub enum PoolingMode {
20 #[default]
22 Session,
23 Transaction,
25 Statement,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
31#[serde(rename_all = "lowercase")]
32pub enum PreparedStatementMode {
33 #[default]
35 Disable,
36 Track,
38 Named,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct PoolModeConfig {
45 #[serde(default)]
47 pub mode: PoolingMode,
48 #[serde(default = "default_pool_mode_max_size")]
50 pub max_pool_size: u32,
51 #[serde(default = "default_pool_mode_min_idle")]
53 pub min_idle: u32,
54 #[serde(default = "default_pool_mode_idle_timeout")]
56 pub idle_timeout_secs: u64,
57 #[serde(default = "default_pool_mode_max_lifetime")]
59 pub max_lifetime_secs: u64,
60 #[serde(default = "default_pool_mode_acquire_timeout")]
62 pub acquire_timeout_secs: u64,
63 #[serde(default = "default_reset_query")]
65 pub reset_query: String,
66 #[serde(default)]
68 pub prepared_statement_mode: PreparedStatementMode,
69 #[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 pub fn session_mode() -> Self {
126 Self {
127 mode: PoolingMode::Session,
128 prepared_statement_mode: PreparedStatementMode::Named,
129 ..Default::default()
130 }
131 }
132
133 pub fn transaction_mode() -> Self {
135 Self {
136 mode: PoolingMode::Transaction,
137 prepared_statement_mode: PreparedStatementMode::Track,
138 ..Default::default()
139 }
140 }
141
142 pub fn statement_mode() -> Self {
144 Self {
145 mode: PoolingMode::Statement,
146 prepared_statement_mode: PreparedStatementMode::Disable,
147 ..Default::default()
148 }
149 }
150
151 pub fn idle_timeout(&self) -> Duration {
153 Duration::from_secs(self.idle_timeout_secs)
154 }
155
156 pub fn max_lifetime(&self) -> Duration {
158 Duration::from_secs(self.max_lifetime_secs)
159 }
160
161 pub fn acquire_timeout(&self) -> Duration {
163 Duration::from_secs(self.acquire_timeout_secs)
164 }
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct ProxyConfig {
174 pub listen_address: String,
176 pub admin_address: String,
178 #[serde(default)]
186 pub admin_token: Option<String>,
187 #[serde(default)]
191 pub admin_allow_insecure: bool,
192 pub tr_enabled: bool,
194 pub tr_mode: TrMode,
196 pub pool: PoolConfig,
198 #[serde(default)]
200 pub pool_mode: PoolModeConfig,
201 pub load_balancer: LoadBalancerConfig,
203 pub health: HealthConfig,
205 pub nodes: Vec<NodeConfig>,
207 pub tls: Option<TlsConfig>,
209 #[serde(default = "default_write_timeout_secs")]
212 pub write_timeout_secs: u64,
213 #[serde(default)]
217 pub plugins: PluginToml,
218 #[serde(default)]
222 pub hba: Vec<HbaRule>,
223 #[serde(default)]
226 pub auth: AuthConfig,
227 #[serde(default)]
229 pub mcp: McpConfig,
230 #[serde(default)]
233 pub agent_contracts: Vec<crate::agent_contract::AgentContract>,
234 #[serde(default)]
237 pub http_gateway: HttpGatewayConfig,
238 #[serde(default)]
241 pub mirror: MirrorConfig,
242 #[serde(default)]
249 pub edge: crate::edge::EdgeConfig,
250 #[serde(default)]
253 pub branch: BranchConfig,
254 #[serde(default)]
260 pub routing_hints: RoutingHintsConfig,
261 #[serde(default)]
265 pub rate_limit: RateLimitToml,
266 #[serde(default)]
270 pub circuit_breaker: CircuitBreakerToml,
271 #[serde(default)]
275 pub analytics: AnalyticsToml,
276 #[serde(default)]
282 pub anomaly: AnomalyToml,
283 #[serde(default)]
286 pub lag_routing: LagRoutingToml,
287 #[serde(default)]
290 pub cache: CacheToml,
291 #[serde(default)]
294 pub query_rewrite: QueryRewriteToml,
295 #[serde(default)]
299 pub multi_tenancy: MultiTenancyToml,
300 #[serde(default)]
303 pub schema_routing: SchemaRoutingToml,
304 #[serde(default)]
307 pub graphql_gateway: GraphqlGatewayConfig,
308 #[serde(default = "default_true")]
315 pub optimize_unnamed_parse: bool,
316 #[serde(default = "default_drain_timeout_secs")]
322 pub shutdown_drain_timeout_secs: u64,
323 #[serde(default)]
329 pub limits: LimitsToml,
330}
331
332fn default_drain_timeout_secs() -> u64 {
333 60
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct BranchConfig {
340 #[serde(default)]
341 pub enabled: bool,
342 #[serde(default = "default_localhost")]
343 pub backend_host: String,
344 #[serde(default = "default_pg_port")]
345 pub backend_port: u16,
346 #[serde(default = "default_pg_user")]
348 pub admin_user: String,
349 pub admin_password: Option<String>,
350 #[serde(default = "default_admin_db")]
353 pub admin_database: String,
354 #[serde(default = "default_admin_db")]
356 pub base_database: String,
357}
358
359impl Default for BranchConfig {
360 fn default() -> Self {
361 Self {
362 enabled: false,
363 backend_host: default_localhost(),
364 backend_port: default_pg_port(),
365 admin_user: default_pg_user(),
366 admin_password: None,
367 admin_database: default_admin_db(),
368 base_database: default_admin_db(),
369 }
370 }
371}
372
373fn default_admin_db() -> String {
374 "postgres".to_string()
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct MirrorConfig {
381 #[serde(default)]
382 pub enabled: bool,
383 #[serde(default = "default_sample_rate")]
385 pub sample_rate: f64,
386 #[serde(default = "default_true_bool")]
389 pub writes_only: bool,
390 #[serde(default = "default_mirror_queue")]
393 pub queue_size: usize,
394 #[serde(default = "default_localhost")]
395 pub backend_host: String,
396 #[serde(default = "default_pg_port")]
397 pub backend_port: u16,
398 #[serde(default = "default_pg_user")]
399 pub backend_user: String,
400 pub backend_password: Option<String>,
401 pub backend_database: Option<String>,
402 #[serde(default = "default_localhost")]
406 pub source_host: String,
407 #[serde(default = "default_pg_port")]
408 pub source_port: u16,
409 #[serde(default = "default_pg_user")]
410 pub source_user: String,
411 pub source_password: Option<String>,
412 pub source_database: Option<String>,
413}
414
415impl Default for MirrorConfig {
416 fn default() -> Self {
417 Self {
418 enabled: false,
419 sample_rate: 1.0,
420 writes_only: true,
421 queue_size: 10_000,
422 backend_host: default_localhost(),
423 backend_port: default_pg_port(),
424 backend_user: default_pg_user(),
425 backend_password: None,
426 backend_database: None,
427 source_host: default_localhost(),
428 source_port: default_pg_port(),
429 source_user: default_pg_user(),
430 source_password: None,
431 source_database: None,
432 }
433 }
434}
435
436fn default_sample_rate() -> f64 {
437 1.0
438}
439fn default_mirror_queue() -> usize {
440 10_000
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize)]
447pub struct HttpGatewayConfig {
448 #[serde(default)]
449 pub enabled: bool,
450 #[serde(default = "default_http_gw_listen")]
451 pub listen_address: String,
452 #[serde(default = "default_localhost")]
453 pub backend_host: String,
454 #[serde(default = "default_pg_port")]
455 pub backend_port: u16,
456 #[serde(default = "default_pg_user")]
457 pub backend_user: String,
458 pub backend_password: Option<String>,
459 pub backend_database: Option<String>,
460 #[serde(default)]
462 pub auth_token: Option<String>,
463}
464
465impl Default for HttpGatewayConfig {
466 fn default() -> Self {
467 Self {
468 enabled: false,
469 listen_address: default_http_gw_listen(),
470 backend_host: default_localhost(),
471 backend_port: default_pg_port(),
472 backend_user: default_pg_user(),
473 backend_password: None,
474 backend_database: None,
475 auth_token: None,
476 }
477 }
478}
479
480fn default_http_gw_listen() -> String {
481 "127.0.0.1:9093".to_string()
482}
483
484#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct McpConfig {
490 #[serde(default)]
491 pub enabled: bool,
492 #[serde(default = "default_mcp_listen")]
494 pub listen_address: String,
495 #[serde(default = "default_localhost")]
497 pub backend_host: String,
498 #[serde(default = "default_pg_port")]
499 pub backend_port: u16,
500 #[serde(default = "default_pg_user")]
501 pub backend_user: String,
502 pub backend_password: Option<String>,
503 pub backend_database: Option<String>,
504 #[serde(default = "default_true_bool")]
507 pub read_only: bool,
508 #[serde(default)]
511 pub contract: Option<String>,
512 #[serde(default)]
517 pub auth_token: Option<String>,
518}
519
520impl Default for McpConfig {
521 fn default() -> Self {
522 Self {
523 enabled: false,
524 listen_address: default_mcp_listen(),
525 backend_host: default_localhost(),
526 backend_port: default_pg_port(),
527 backend_user: default_pg_user(),
528 backend_password: None,
529 backend_database: None,
530 read_only: true,
531 contract: None,
532 auth_token: None,
533 }
534 }
535}
536
537fn default_mcp_listen() -> String {
538 "127.0.0.1:9092".to_string()
539}
540fn default_localhost() -> String {
541 "127.0.0.1".to_string()
542}
543fn default_pg_port() -> u16 {
544 5432
545}
546fn default_pg_user() -> String {
547 "postgres".to_string()
548}
549fn default_true_bool() -> bool {
550 true
551}
552
553#[derive(Debug, Clone, Serialize, Deserialize, Default)]
555pub struct AuthConfig {
556 #[serde(default)]
560 pub mode: AuthMode,
561 #[serde(default)]
564 pub auth_file: Option<String>,
565}
566
567#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
569#[serde(rename_all = "lowercase")]
570pub enum AuthMode {
571 #[default]
573 Passthrough,
574 Scram,
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize)]
585pub struct HbaRule {
586 pub action: HbaAction,
588 #[serde(default = "hba_all")]
590 pub user: String,
591 #[serde(default = "hba_all")]
593 pub database: String,
594 #[serde(default = "hba_all")]
597 pub address: String,
598}
599
600fn hba_all() -> String {
601 "all".to_string()
602}
603
604#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
606#[serde(rename_all = "lowercase")]
607pub enum HbaAction {
608 Allow,
609 Reject,
610}
611
612fn default_write_timeout_secs() -> u64 {
613 30 }
615
616#[derive(Debug, Clone, Default, Serialize, Deserialize)]
618#[serde(default)]
619pub struct GqlTableToml {
620 pub name: String,
621 pub columns: Vec<String>,
622}
623
624#[derive(Debug, Clone, Serialize, Deserialize)]
627#[serde(default)]
628pub struct GraphqlGatewayConfig {
629 pub enabled: bool,
631 pub listen_address: String,
633 pub backend_host: String,
635 pub backend_port: u16,
636 pub backend_user: String,
637 pub backend_password: Option<String>,
638 pub backend_database: Option<String>,
639 pub auth_token: Option<String>,
641 pub tables: Vec<GqlTableToml>,
643}
644
645impl Default for GraphqlGatewayConfig {
646 fn default() -> Self {
647 Self {
648 enabled: false,
649 listen_address: "0.0.0.0:9091".to_string(),
650 backend_host: "127.0.0.1".to_string(),
651 backend_port: 5432,
652 backend_user: "postgres".to_string(),
653 backend_password: None,
654 backend_database: None,
655 auth_token: None,
656 tables: Vec::new(),
657 }
658 }
659}
660
661#[derive(Debug, Clone, Default, Serialize, Deserialize)]
664#[serde(default)]
665pub struct SchemaRoutingToml {
666 pub enabled: bool,
669 pub analytics_node: String,
671}
672
673#[derive(Debug, Clone, Serialize, Deserialize)]
677#[serde(default)]
678pub struct MultiTenancyToml {
679 pub enabled: bool,
681 pub identify_by: String,
684 pub tenant_column: String,
686 pub tenant_tables: Vec<String>,
689 pub tenants: Vec<String>,
691}
692
693impl Default for MultiTenancyToml {
694 fn default() -> Self {
695 Self {
696 enabled: false,
697 identify_by: "application_name".to_string(),
698 tenant_column: "tenant_id".to_string(),
699 tenant_tables: Vec::new(),
700 tenants: Vec::new(),
701 }
702 }
703}
704
705#[derive(Debug, Clone, Serialize, Deserialize, Default)]
709#[serde(default)]
710pub struct RewriteRuleToml {
711 pub match_table: Option<String>,
713 pub match_regex: Option<String>,
715 pub replace_table_with: Option<String>,
717 pub append_where: Option<String>,
719 pub add_limit: Option<u32>,
721}
722
723#[derive(Debug, Clone, Default, Serialize, Deserialize)]
727#[serde(default)]
728pub struct QueryRewriteToml {
729 pub enabled: bool,
731 pub rules: Vec<RewriteRuleToml>,
733}
734
735#[derive(Debug, Clone, Serialize, Deserialize)]
739#[serde(default)]
740pub struct CacheToml {
741 pub enabled: bool,
743 pub ttl_secs: u64,
745 pub max_result_bytes: usize,
747}
748
749impl Default for CacheToml {
750 fn default() -> Self {
751 Self {
752 enabled: false,
753 ttl_secs: 300,
754 max_result_bytes: 1024 * 1024,
755 }
756 }
757}
758
759#[derive(Debug, Clone, Serialize, Deserialize)]
775pub struct LimitsToml {
776 #[serde(default = "default_max_cancel_keys")]
781 pub max_cancel_keys: usize,
782 #[serde(default = "default_startup_timeout_secs")]
787 pub startup_timeout_secs: u64,
788 #[serde(default = "default_backend_write_timeout_secs")]
792 pub backend_write_timeout_secs: u64,
793 #[serde(default = "default_backend_read_timeout_secs")]
799 pub backend_read_timeout_secs: u64,
800 #[serde(default = "default_client_write_timeout_secs")]
804 pub client_write_timeout_secs: u64,
805 #[serde(default = "default_reprepare_timeout_secs")]
809 pub reprepare_timeout_secs: u64,
810 #[serde(default = "default_max_prepared_statements")]
814 pub max_prepared_statements: usize,
815 #[serde(default = "default_max_prepared_bytes")]
819 pub max_prepared_bytes: usize,
820 #[serde(default = "default_max_pending_bytes")]
824 pub max_pending_bytes: usize,
825 #[serde(default = "default_max_total_idle_backend_conns")]
831 pub max_total_idle_backend_conns: usize,
832 #[serde(default = "default_pool_reap_interval_secs")]
835 pub pool_reap_interval_secs: u64,
836}
837
838fn default_max_cancel_keys() -> usize {
839 100_000
840}
841fn default_startup_timeout_secs() -> u64 {
842 30
843}
844fn default_backend_write_timeout_secs() -> u64 {
845 30
846}
847fn default_backend_read_timeout_secs() -> u64 {
848 30
849}
850fn default_client_write_timeout_secs() -> u64 {
851 60
852}
853fn default_reprepare_timeout_secs() -> u64 {
854 15
855}
856fn default_max_prepared_statements() -> usize {
857 8192
858}
859fn default_max_prepared_bytes() -> usize {
860 64 * 1024 * 1024
861}
862fn default_max_pending_bytes() -> usize {
863 64 * 1024 * 1024
864}
865fn default_max_total_idle_backend_conns() -> usize {
866 8192
867}
868fn default_pool_reap_interval_secs() -> u64 {
869 30
870}
871
872const MAX_LIMIT_SECS: u64 = 31_536_000; impl Default for LimitsToml {
881 fn default() -> Self {
882 Self {
883 max_cancel_keys: default_max_cancel_keys(),
884 startup_timeout_secs: default_startup_timeout_secs(),
885 backend_write_timeout_secs: default_backend_write_timeout_secs(),
886 backend_read_timeout_secs: default_backend_read_timeout_secs(),
887 client_write_timeout_secs: default_client_write_timeout_secs(),
888 reprepare_timeout_secs: default_reprepare_timeout_secs(),
889 max_prepared_statements: default_max_prepared_statements(),
890 max_prepared_bytes: default_max_prepared_bytes(),
891 max_pending_bytes: default_max_pending_bytes(),
892 max_total_idle_backend_conns: default_max_total_idle_backend_conns(),
893 pool_reap_interval_secs: default_pool_reap_interval_secs(),
894 }
895 }
896}
897
898#[derive(Debug, Clone, Serialize, Deserialize)]
901#[serde(default)]
902pub struct LagRoutingToml {
903 pub enabled: bool,
905 pub ryw_window_ms: u64,
909 pub max_lag_bytes: u64,
913}
914
915impl Default for LagRoutingToml {
916 fn default() -> Self {
917 Self {
918 enabled: false,
919 ryw_window_ms: 500,
920 max_lag_bytes: 0,
921 }
922 }
923}
924
925#[derive(Debug, Clone, Serialize, Deserialize)]
929#[serde(default)]
930pub struct AnalyticsToml {
931 pub enabled: bool,
934 pub slow_query_ms: u64,
936 pub max_fingerprints: u32,
938}
939
940impl Default for AnalyticsToml {
941 fn default() -> Self {
942 Self {
943 enabled: false,
944 slow_query_ms: 1000,
945 max_fingerprints: 10000,
946 }
947 }
948}
949
950#[derive(Debug, Clone, Serialize, Deserialize)]
957pub struct AnomalyToml {
958 #[serde(default = "default_anomaly_rate_window_secs")]
960 pub rate_window_secs: u64,
961 #[serde(default = "default_anomaly_spike_z_threshold")]
963 pub spike_z_threshold: f64,
964 #[serde(default = "default_anomaly_auth_window_secs")]
967 pub auth_window_secs: u64,
968 #[serde(default = "default_anomaly_auth_critical_count")]
970 pub auth_critical_count: u32,
971 #[serde(default = "default_anomaly_auth_warning_count")]
974 pub auth_warning_count: u32,
975 #[serde(default = "default_anomaly_event_buffer_size")]
977 pub event_buffer_size: usize,
978 #[serde(default = "default_anomaly_emit_novel_queries")]
981 pub emit_novel_queries: bool,
982 #[serde(default = "default_anomaly_max_seen_fingerprints")]
985 pub max_seen_fingerprints: usize,
986}
987
988fn default_anomaly_rate_window_secs() -> u64 {
989 60
990}
991fn default_anomaly_spike_z_threshold() -> f64 {
992 3.0
993}
994fn default_anomaly_auth_window_secs() -> u64 {
995 60
996}
997fn default_anomaly_auth_critical_count() -> u32 {
998 10
999}
1000fn default_anomaly_auth_warning_count() -> u32 {
1001 5
1002}
1003fn default_anomaly_event_buffer_size() -> usize {
1004 1024
1005}
1006fn default_anomaly_emit_novel_queries() -> bool {
1007 true
1008}
1009fn default_anomaly_max_seen_fingerprints() -> usize {
1010 100_000
1011}
1012
1013impl Default for AnomalyToml {
1014 fn default() -> Self {
1015 Self {
1016 rate_window_secs: default_anomaly_rate_window_secs(),
1017 spike_z_threshold: default_anomaly_spike_z_threshold(),
1018 auth_window_secs: default_anomaly_auth_window_secs(),
1019 auth_critical_count: default_anomaly_auth_critical_count(),
1020 auth_warning_count: default_anomaly_auth_warning_count(),
1021 event_buffer_size: default_anomaly_event_buffer_size(),
1022 emit_novel_queries: default_anomaly_emit_novel_queries(),
1023 max_seen_fingerprints: default_anomaly_max_seen_fingerprints(),
1024 }
1025 }
1026}
1027
1028#[cfg(feature = "anomaly-detection")]
1029impl AnomalyToml {
1030 pub fn to_anomaly_config(&self) -> crate::anomaly::AnomalyConfig {
1034 crate::anomaly::AnomalyConfig {
1035 rate_window_secs: self.rate_window_secs,
1036 spike_z_threshold: self.spike_z_threshold,
1037 auth_window_secs: self.auth_window_secs,
1038 auth_critical_count: self.auth_critical_count,
1039 auth_warning_count: self.auth_warning_count,
1040 event_buffer_size: self.event_buffer_size,
1041 emit_novel_queries: self.emit_novel_queries,
1042 max_seen_fingerprints: self.max_seen_fingerprints,
1043 }
1044 }
1045}
1046
1047#[derive(Debug, Clone, Serialize, Deserialize)]
1051#[serde(default)]
1052pub struct CircuitBreakerToml {
1053 pub enabled: bool,
1055 pub failure_threshold: u32,
1058 pub open_secs: u64,
1060 pub success_threshold: u32,
1062}
1063
1064impl Default for CircuitBreakerToml {
1065 fn default() -> Self {
1066 Self {
1067 enabled: false,
1068 failure_threshold: 5,
1069 open_secs: 10,
1070 success_threshold: 3,
1071 }
1072 }
1073}
1074
1075#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1077#[serde(rename_all = "snake_case")]
1078pub enum RateLimitKeyBy {
1079 #[default]
1081 User,
1082 ClientIp,
1084 Database,
1086 Global,
1088}
1089
1090#[derive(Debug, Clone, Serialize, Deserialize)]
1095#[serde(default)]
1096pub struct RateLimitToml {
1097 pub enabled: bool,
1099 pub default_qps: u32,
1101 pub default_burst: u32,
1103 pub max_concurrent: u32,
1105 pub key_by: RateLimitKeyBy,
1107}
1108
1109impl Default for RateLimitToml {
1110 fn default() -> Self {
1111 Self {
1112 enabled: false,
1113 default_qps: 1000,
1114 default_burst: 2000,
1115 max_concurrent: 0,
1116 key_by: RateLimitKeyBy::User,
1117 }
1118 }
1119}
1120
1121#[derive(Debug, Clone, Serialize, Deserialize)]
1127#[serde(default)]
1128pub struct RoutingHintsConfig {
1129 pub enabled: bool,
1132 pub strip_hints: bool,
1136}
1137
1138impl Default for RoutingHintsConfig {
1139 fn default() -> Self {
1140 Self {
1141 enabled: false,
1142 strip_hints: true,
1143 }
1144 }
1145}
1146
1147impl Default for ProxyConfig {
1148 fn default() -> Self {
1149 Self {
1150 listen_address: "0.0.0.0:5432".to_string(),
1151 admin_address: "127.0.0.1:9090".to_string(),
1155 admin_token: None,
1156 admin_allow_insecure: false,
1157 tr_enabled: true,
1158 tr_mode: TrMode::Session,
1159 pool: PoolConfig::default(),
1160 pool_mode: PoolModeConfig::default(),
1161 load_balancer: LoadBalancerConfig::default(),
1162 health: HealthConfig::default(),
1163 nodes: Vec::new(),
1164 tls: None,
1165 write_timeout_secs: default_write_timeout_secs(),
1166 plugins: PluginToml::default(),
1167 hba: Vec::new(),
1168 auth: AuthConfig::default(),
1169 mcp: McpConfig::default(),
1170 agent_contracts: Vec::new(),
1171 http_gateway: HttpGatewayConfig::default(),
1172 mirror: MirrorConfig::default(),
1173 edge: crate::edge::EdgeConfig::default(),
1174 branch: BranchConfig::default(),
1175 routing_hints: RoutingHintsConfig::default(),
1176 rate_limit: RateLimitToml::default(),
1177 circuit_breaker: CircuitBreakerToml::default(),
1178 analytics: AnalyticsToml::default(),
1179 anomaly: AnomalyToml::default(),
1180 lag_routing: LagRoutingToml::default(),
1181 cache: CacheToml::default(),
1182 query_rewrite: QueryRewriteToml::default(),
1183 multi_tenancy: MultiTenancyToml::default(),
1184 schema_routing: SchemaRoutingToml::default(),
1185 graphql_gateway: GraphqlGatewayConfig::default(),
1186 optimize_unnamed_parse: true,
1187 shutdown_drain_timeout_secs: default_drain_timeout_secs(),
1188 limits: LimitsToml::default(),
1189 }
1190 }
1191}
1192
1193#[derive(Debug, Clone, Serialize, Deserialize)]
1207pub struct PluginToml {
1208 #[serde(default)]
1211 pub enabled: bool,
1212 #[serde(default = "default_plugin_dir")]
1214 pub plugin_dir: String,
1215 #[serde(default)]
1217 pub hot_reload: bool,
1218 #[serde(default = "default_plugin_memory_mb")]
1220 pub memory_limit_mb: usize,
1221 #[serde(default = "default_plugin_timeout_ms")]
1223 pub timeout_ms: u64,
1224 #[serde(default = "default_plugin_max")]
1226 pub max_plugins: usize,
1227 #[serde(default = "default_true")]
1229 pub fuel_metering: bool,
1230 #[serde(default = "default_plugin_fuel")]
1232 pub fuel_limit: u64,
1233 #[serde(default)]
1239 pub trust_root: Option<String>,
1240 #[serde(default = "default_plugin_kv_max_value_bytes")]
1243 pub kv_max_value_bytes: usize,
1244 #[serde(default = "default_plugin_kv_max_keys")]
1246 pub kv_max_keys_per_plugin: usize,
1247 #[serde(default = "default_plugin_kv_max_plugins")]
1253 pub kv_max_plugins: usize,
1254 #[serde(default = "default_plugin_kv_max_total_bytes")]
1263 pub kv_max_total_bytes: usize,
1264}
1265
1266fn default_plugin_dir() -> String {
1267 "/etc/heliosproxy/plugins".to_string()
1268}
1269fn default_plugin_memory_mb() -> usize {
1270 64
1271}
1272fn default_plugin_timeout_ms() -> u64 {
1273 100
1274}
1275fn default_plugin_max() -> usize {
1276 20
1277}
1278fn default_true() -> bool {
1279 true
1280}
1281fn default_plugin_fuel() -> u64 {
1282 1_000_000
1283}
1284fn default_plugin_kv_max_value_bytes() -> usize {
1285 65536
1286}
1287fn default_plugin_kv_max_keys() -> usize {
1288 1024
1289}
1290fn default_plugin_kv_max_plugins() -> usize {
1291 256
1292}
1293fn default_plugin_kv_max_total_bytes() -> usize {
1294 64 * 1024 * 1024
1295}
1296
1297impl Default for PluginToml {
1298 fn default() -> Self {
1299 Self {
1300 enabled: false,
1301 plugin_dir: default_plugin_dir(),
1302 hot_reload: false,
1303 memory_limit_mb: default_plugin_memory_mb(),
1304 timeout_ms: default_plugin_timeout_ms(),
1305 max_plugins: default_plugin_max(),
1306 fuel_metering: true,
1307 fuel_limit: default_plugin_fuel(),
1308 trust_root: None,
1309 kv_max_value_bytes: default_plugin_kv_max_value_bytes(),
1310 kv_max_keys_per_plugin: default_plugin_kv_max_keys(),
1311 kv_max_plugins: default_plugin_kv_max_plugins(),
1312 kv_max_total_bytes: default_plugin_kv_max_total_bytes(),
1313 }
1314 }
1315}
1316
1317fn substitute_env(text: &str) -> Result<String> {
1350 let mut out = String::with_capacity(text.len());
1354 for line in text.split_inclusive('\n') {
1355 if line.trim_start().starts_with('#') {
1356 out.push_str(line);
1358 } else {
1359 substitute_line(line, &mut out)?;
1360 }
1361 }
1362 Ok(out)
1363}
1364
1365fn substitute_line(line: &str, out: &mut String) -> Result<()> {
1367 let mut rest = line;
1368 while let Some(idx) = rest.find("${") {
1369 out.push_str(&rest[..idx]);
1370 let body = &rest[idx + 2..]; match parse_placeholder(body)? {
1372 Some((value, consumed)) => {
1373 out.push_str(&value);
1374 rest = &body[consumed..];
1375 }
1376 None => {
1377 out.push_str("${");
1381 rest = body;
1382 }
1383 }
1384 }
1385 out.push_str(rest);
1386 Ok(())
1387}
1388
1389fn parse_placeholder(body: &str) -> Result<Option<(String, usize)>> {
1397 let bytes = body.as_bytes();
1398 let mut n = 0;
1400 while n < bytes.len() {
1401 let b = bytes[n];
1402 let valid = if n == 0 {
1403 b.is_ascii_alphabetic() || b == b'_'
1404 } else {
1405 b.is_ascii_alphanumeric() || b == b'_'
1406 };
1407 if valid {
1408 n += 1;
1409 } else {
1410 break;
1411 }
1412 }
1413 if n == 0 {
1414 return Ok(None); }
1416 let name = &body[..n];
1417 let after = &body[n..];
1418
1419 if after.starts_with('}') {
1420 match std::env::var(name) {
1422 Ok(v) => Ok(Some((v, n + 1))),
1423 Err(_) => Err(ProxyError::Config(format!(
1424 "config env-var substitution: `${{{name}}}` references environment \
1425 variable `{name}`, which is not set (and no `:-default` fallback \
1426 was given)"
1427 ))),
1428 }
1429 } else if let Some(after_op) = after.strip_prefix(":-") {
1430 match after_op.find('}') {
1432 Some(end) => {
1433 let default = &after_op[..end];
1434 let value = std::env::var(name).unwrap_or_else(|_| default.to_string());
1435 Ok(Some((value, n + 2 + end + 1)))
1437 }
1438 None => Ok(None), }
1440 } else {
1441 Ok(None) }
1443}
1444
1445const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
1454 "listen_address",
1455 "admin_address",
1456 "admin_token",
1457 "admin_allow_insecure",
1458 "tr_enabled",
1459 "tr_mode",
1460 "pool",
1461 "pool_mode",
1462 "load_balancer",
1463 "health",
1464 "nodes",
1465 "tls",
1466 "write_timeout_secs",
1467 "plugins",
1468 "hba",
1469 "auth",
1470 "mcp",
1471 "agent_contracts",
1472 "http_gateway",
1473 "mirror",
1474 "edge",
1475 "branch",
1476 "routing_hints",
1477 "rate_limit",
1478 "circuit_breaker",
1479 "analytics",
1480 "anomaly",
1481 "lag_routing",
1482 "cache",
1483 "query_rewrite",
1484 "multi_tenancy",
1485 "schema_routing",
1486 "graphql_gateway",
1487 "optimize_unnamed_parse",
1488 "shutdown_drain_timeout_secs",
1489 "limits",
1490];
1491
1492fn unknown_top_level_keys(text: &str) -> Vec<String> {
1499 let Ok(value) = toml::from_str::<toml::Value>(text) else {
1500 return Vec::new();
1501 };
1502 let Some(table) = value.as_table() else {
1503 return Vec::new();
1504 };
1505 let mut unknown: Vec<String> = table
1506 .keys()
1507 .filter(|k| !KNOWN_TOP_LEVEL_KEYS.contains(&k.as_str()))
1508 .cloned()
1509 .collect();
1510 unknown.sort();
1511 unknown
1512}
1513
1514impl ProxyConfig {
1515 pub fn write_timeout(&self) -> Duration {
1517 Duration::from_secs(self.write_timeout_secs)
1518 }
1519
1520 pub fn from_file(path: &str) -> Result<Self> {
1522 let path = Path::new(path);
1523
1524 if !path.exists() {
1525 return Err(ProxyError::Config(format!(
1526 "Configuration file not found: {}",
1527 path.display()
1528 )));
1529 }
1530
1531 let raw = std::fs::read_to_string(path)
1532 .map_err(|e| ProxyError::Config(format!("Failed to read config: {}", e)))?;
1533
1534 let contents = substitute_env(&raw)?;
1539
1540 let config: Self = toml::from_str(&contents)
1541 .map_err(|e| ProxyError::Config(format!("Failed to parse config: {}", e)))?;
1542
1543 for key in unknown_top_level_keys(&contents) {
1550 tracing::warn!(
1551 "unknown config section/key '{}' ignored (not part of ProxyConfig)",
1552 key
1553 );
1554 }
1555
1556 config.validate()?;
1557
1558 Ok(config)
1559 }
1560
1561 pub fn add_node(&mut self, host_port: &str, role: &str) -> Result<()> {
1563 let parts: Vec<&str> = host_port.rsplitn(2, ':').collect();
1564 if parts.len() != 2 {
1565 return Err(ProxyError::Config(format!(
1566 "Invalid host:port format: {}",
1567 host_port
1568 )));
1569 }
1570
1571 let port: u16 = parts[0]
1572 .parse()
1573 .map_err(|_| ProxyError::Config(format!("Invalid port: {}", parts[0])))?;
1574
1575 let host = parts[1].to_string();
1576
1577 let role = match role {
1578 "primary" => NodeRole::Primary,
1579 "standby" => NodeRole::Standby,
1580 "replica" => NodeRole::ReadReplica,
1581 _ => return Err(ProxyError::Config(format!("Unknown role: {}", role))),
1582 };
1583
1584 self.nodes.push(NodeConfig {
1585 host,
1586 port,
1587 http_port: default_http_port(),
1588 role,
1589 weight: 100,
1590 enabled: true,
1591 name: None,
1592 });
1593
1594 Ok(())
1595 }
1596
1597 pub fn validate(&self) -> Result<()> {
1599 if self.nodes.is_empty() {
1601 return Err(ProxyError::Config(
1602 "No backend nodes configured".to_string(),
1603 ));
1604 }
1605
1606 let has_primary = self.nodes.iter().any(|n| n.role == NodeRole::Primary);
1608 if !has_primary {
1609 return Err(ProxyError::Config("No primary node configured".to_string()));
1610 }
1611
1612 if self.pool.max_connections < self.pool.min_connections {
1614 return Err(ProxyError::Config(
1615 "max_connections must be >= min_connections".to_string(),
1616 ));
1617 }
1618
1619 if self.health.check_interval_secs == 0 {
1624 return Err(ProxyError::Config(
1625 "health.check_interval_secs must be >= 1".to_string(),
1626 ));
1627 }
1628
1629 if self.admin_token.is_none() && !self.admin_allow_insecure {
1637 if let Ok(sa) = self.admin_address.parse::<std::net::SocketAddr>() {
1638 if !sa.ip().is_loopback() {
1639 return Err(ProxyError::Config(format!(
1640 "admin_address '{}' is not loopback but admin_token is unset — the admin \
1641 API runs privileged operations and must not be exposed anonymously. Set \
1642 admin_token, bind admin_address to 127.0.0.1, or set \
1643 admin_allow_insecure = true to override.",
1644 self.admin_address
1645 )));
1646 }
1647 }
1648 }
1649
1650 {
1656 let l = &self.limits;
1657 let zero_checks: [(&str, u64); 7] = [
1658 ("limits.startup_timeout_secs", l.startup_timeout_secs),
1659 (
1660 "limits.backend_write_timeout_secs",
1661 l.backend_write_timeout_secs,
1662 ),
1663 (
1664 "limits.backend_read_timeout_secs",
1665 l.backend_read_timeout_secs,
1666 ),
1667 (
1668 "limits.client_write_timeout_secs",
1669 l.client_write_timeout_secs,
1670 ),
1671 ("limits.reprepare_timeout_secs", l.reprepare_timeout_secs),
1672 ("limits.pool_reap_interval_secs", l.pool_reap_interval_secs),
1673 ("limits.max_cancel_keys", l.max_cancel_keys as u64),
1674 ];
1675 for (name, value) in zero_checks {
1676 if value == 0 {
1677 return Err(ProxyError::Config(format!("{name} must be >= 1")));
1678 }
1679 }
1680 let secs_checks: [(&str, u64); 6] = [
1685 ("limits.startup_timeout_secs", l.startup_timeout_secs),
1686 (
1687 "limits.backend_write_timeout_secs",
1688 l.backend_write_timeout_secs,
1689 ),
1690 (
1691 "limits.backend_read_timeout_secs",
1692 l.backend_read_timeout_secs,
1693 ),
1694 (
1695 "limits.client_write_timeout_secs",
1696 l.client_write_timeout_secs,
1697 ),
1698 ("limits.reprepare_timeout_secs", l.reprepare_timeout_secs),
1699 ("limits.pool_reap_interval_secs", l.pool_reap_interval_secs),
1700 ];
1701 for (name, value) in secs_checks {
1702 if value > MAX_LIMIT_SECS {
1703 return Err(ProxyError::Config(format!(
1704 "{name} must be <= {MAX_LIMIT_SECS} seconds (1 year)"
1705 )));
1706 }
1707 }
1708 if l.max_prepared_statements == 0 {
1709 return Err(ProxyError::Config(
1710 "limits.max_prepared_statements must be >= 1".to_string(),
1711 ));
1712 }
1713 if l.max_prepared_bytes == 0 {
1714 return Err(ProxyError::Config(
1715 "limits.max_prepared_bytes must be >= 1".to_string(),
1716 ));
1717 }
1718 if l.max_pending_bytes == 0 {
1719 return Err(ProxyError::Config(
1720 "limits.max_pending_bytes must be >= 1".to_string(),
1721 ));
1722 }
1723 if l.max_total_idle_backend_conns == 0 {
1724 return Err(ProxyError::Config(
1725 "limits.max_total_idle_backend_conns must be >= 1".to_string(),
1726 ));
1727 }
1728 }
1729
1730 if self.edge.enabled {
1737 if !cfg!(feature = "edge-proxy") {
1738 return Err(ProxyError::Config(
1739 "edge.enabled = true but this binary was built without the 'edge-proxy' \
1740 feature — rebuild with `--features edge-proxy` or remove/disable the \
1741 [edge] section."
1742 .to_string(),
1743 ));
1744 }
1745 if self.edge.subscribe_gc_secs == 0 {
1750 return Err(ProxyError::Config(
1751 "edge.subscribe_gc_secs must be >= 1".to_string(),
1752 ));
1753 }
1754 if self.edge.liveness_window_secs == 0 {
1755 return Err(ProxyError::Config(
1756 "edge.liveness_window_secs must be >= 1".to_string(),
1757 ));
1758 }
1759 if self.edge.default_ttl_secs == 0 {
1762 return Err(ProxyError::Config(
1763 "edge.default_ttl_secs must be >= 1 when edge is enabled".to_string(),
1764 ));
1765 }
1766 if self.edge.role == crate::edge::EdgeRole::Edge {
1767 if self.edge.home_url.trim().is_empty() {
1768 return Err(ProxyError::Config(
1769 "edge.role = 'edge' requires edge.home_url — the home proxy's admin \
1770 base URL (e.g. \"https://home-proxy:9090\") the edge subscribes to \
1771 for cache invalidations."
1772 .to_string(),
1773 ));
1774 }
1775 if !self.edge.auth_token.is_empty()
1783 && !self.edge.allow_insecure_home_url
1784 && !self
1785 .edge
1786 .home_url
1787 .trim()
1788 .to_ascii_lowercase()
1789 .starts_with("https://")
1790 {
1791 return Err(ProxyError::Config(format!(
1792 "edge.home_url '{}' is not https:// but edge.auth_token is set — the \
1793 token is the home's admin bearer and must not cross the network in \
1794 cleartext. Front the home admin port with a TLS terminator and use \
1795 https://, or set edge.allow_insecure_home_url = true for private \
1796 links (VPN/WireGuard/service mesh).",
1797 self.edge.home_url.trim()
1798 )));
1799 }
1800 if cfg!(feature = "query-cache") && self.cache.enabled {
1805 return Err(ProxyError::Config(
1806 "edge.role = 'edge' cannot be combined with [cache] enabled = true — \
1807 the query-result cache does not receive edge invalidations and would \
1808 serve stale rows past the edge coherence bound. Disable [cache] on \
1809 edge-role proxies; the edge cache serves cacheable SELECTs there."
1810 .to_string(),
1811 ));
1812 }
1813 if self.nodes.is_empty() {
1817 return Err(ProxyError::Config(
1818 "edge.role = 'edge' requires at least one [[nodes]] entry pointing \
1819 at the home proxy's PG-wire listener — cache misses and writes \
1820 forward there."
1821 .to_string(),
1822 ));
1823 }
1824 }
1825 }
1826
1827 {
1836 let a = &self.anomaly;
1837 if a.rate_window_secs == 0 {
1838 return Err(ProxyError::Config(
1839 "anomaly.rate_window_secs must be >= 1".to_string(),
1840 ));
1841 }
1842 if a.auth_window_secs == 0 {
1843 return Err(ProxyError::Config(
1844 "anomaly.auth_window_secs must be >= 1".to_string(),
1845 ));
1846 }
1847 if a.event_buffer_size == 0 {
1848 return Err(ProxyError::Config(
1849 "anomaly.event_buffer_size must be >= 1".to_string(),
1850 ));
1851 }
1852 if a.max_seen_fingerprints == 0 {
1853 return Err(ProxyError::Config(
1854 "anomaly.max_seen_fingerprints must be >= 1".to_string(),
1855 ));
1856 }
1857 if !(a.spike_z_threshold.is_finite() && a.spike_z_threshold > 0.0) {
1858 return Err(ProxyError::Config(
1859 "anomaly.spike_z_threshold must be a finite value > 0".to_string(),
1860 ));
1861 }
1862 if a.auth_critical_count == 0 {
1863 return Err(ProxyError::Config(
1867 "anomaly.auth_critical_count must be >= 1".to_string(),
1868 ));
1869 }
1870 if a.auth_warning_count > a.auth_critical_count {
1871 return Err(ProxyError::Config(format!(
1872 "anomaly.auth_warning_count ({}) must be <= anomaly.auth_critical_count ({})",
1873 a.auth_warning_count, a.auth_critical_count
1874 )));
1875 }
1876 }
1877
1878 Ok(())
1879 }
1880
1881 pub fn primary_node(&self) -> Option<&NodeConfig> {
1883 self.nodes
1884 .iter()
1885 .find(|n| n.role == NodeRole::Primary && n.enabled)
1886 }
1887
1888 pub fn standby_nodes(&self) -> Vec<&NodeConfig> {
1890 self.nodes
1891 .iter()
1892 .filter(|n| n.role == NodeRole::Standby && n.enabled)
1893 .collect()
1894 }
1895
1896 pub fn enabled_nodes(&self) -> Vec<&NodeConfig> {
1898 self.nodes.iter().filter(|n| n.enabled).collect()
1899 }
1900}
1901
1902#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1904#[serde(rename_all = "lowercase")]
1905#[derive(Default)]
1906pub enum TrMode {
1907 None,
1909 #[default]
1911 Session,
1912 Select,
1914 Transaction,
1916}
1917
1918#[derive(Debug, Clone, Serialize, Deserialize)]
1920pub struct PoolConfig {
1921 pub min_connections: usize,
1923 pub max_connections: usize,
1925 pub idle_timeout_secs: u64,
1927 pub max_lifetime_secs: u64,
1929 pub acquire_timeout_secs: u64,
1931 pub test_on_acquire: bool,
1933}
1934
1935impl Default for PoolConfig {
1936 fn default() -> Self {
1937 Self {
1938 min_connections: 2,
1939 max_connections: 100,
1940 idle_timeout_secs: 300,
1941 max_lifetime_secs: 1800,
1942 acquire_timeout_secs: 30,
1943 test_on_acquire: true,
1944 }
1945 }
1946}
1947
1948impl PoolConfig {
1949 pub fn idle_timeout(&self) -> Duration {
1951 Duration::from_secs(self.idle_timeout_secs)
1952 }
1953
1954 pub fn max_lifetime(&self) -> Duration {
1956 Duration::from_secs(self.max_lifetime_secs)
1957 }
1958
1959 pub fn acquire_timeout(&self) -> Duration {
1961 Duration::from_secs(self.acquire_timeout_secs)
1962 }
1963}
1964
1965#[derive(Debug, Clone, Serialize, Deserialize)]
1967pub struct LoadBalancerConfig {
1968 pub read_strategy: Strategy,
1970 pub read_write_split: bool,
1972 pub latency_threshold_ms: u64,
1974}
1975
1976impl Default for LoadBalancerConfig {
1977 fn default() -> Self {
1978 Self {
1979 read_strategy: Strategy::RoundRobin,
1980 read_write_split: true,
1981 latency_threshold_ms: 100,
1982 }
1983 }
1984}
1985
1986#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1988#[serde(rename_all = "snake_case")]
1989pub enum Strategy {
1990 RoundRobin,
1992 WeightedRoundRobin,
1994 LeastConnections,
1996 LatencyBased,
1998 Random,
2000}
2001
2002#[derive(Debug, Clone, Serialize, Deserialize)]
2004pub struct HealthConfig {
2005 pub check_interval_secs: u64,
2007 pub check_timeout_secs: u64,
2009 pub failure_threshold: u32,
2011 pub success_threshold: u32,
2013 pub check_query: String,
2015}
2016
2017impl Default for HealthConfig {
2018 fn default() -> Self {
2019 Self {
2020 check_interval_secs: 5,
2021 check_timeout_secs: 3,
2022 failure_threshold: 3,
2023 success_threshold: 2,
2024 check_query: "SELECT 1".to_string(),
2025 }
2026 }
2027}
2028
2029impl HealthConfig {
2030 pub fn check_interval(&self) -> Duration {
2032 Duration::from_secs(self.check_interval_secs)
2033 }
2034
2035 pub fn check_timeout(&self) -> Duration {
2037 Duration::from_secs(self.check_timeout_secs)
2038 }
2039}
2040
2041#[derive(Debug, Clone, Serialize, Deserialize)]
2043pub struct NodeConfig {
2044 pub host: String,
2046 pub port: u16,
2048 #[serde(default = "default_http_port")]
2051 pub http_port: u16,
2052 pub role: NodeRole,
2054 pub weight: u32,
2056 pub enabled: bool,
2058 pub name: Option<String>,
2060}
2061
2062fn default_http_port() -> u16 {
2063 8080
2064}
2065
2066impl NodeConfig {
2067 pub fn address(&self) -> String {
2069 format!("{}:{}", self.host, self.port)
2070 }
2071
2072 pub fn display_name(&self) -> &str {
2074 self.name.as_deref().unwrap_or(&self.host)
2075 }
2076}
2077
2078#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2080#[serde(rename_all = "lowercase")]
2081pub enum NodeRole {
2082 Primary,
2084 Standby,
2086 #[serde(rename = "replica")]
2088 ReadReplica,
2089}
2090
2091#[derive(Debug, Clone, Serialize, Deserialize)]
2093pub struct TlsConfig {
2094 pub enabled: bool,
2096 pub cert_path: String,
2098 pub key_path: String,
2100 pub ca_path: Option<String>,
2102 pub require_client_cert: bool,
2104}
2105
2106#[cfg(test)]
2107mod tests {
2108 use super::*;
2109
2110 #[test]
2111 fn test_default_config() {
2112 let config = ProxyConfig::default();
2113 assert_eq!(config.listen_address, "0.0.0.0:5432");
2114 assert!(config.tr_enabled);
2115 }
2116
2117 #[test]
2118 fn test_add_node() {
2119 let mut config = ProxyConfig::default();
2120 config.add_node("localhost:5432", "primary").unwrap();
2121 config.add_node("localhost:5433", "standby").unwrap();
2122
2123 assert_eq!(config.nodes.len(), 2);
2124 assert!(config.primary_node().is_some());
2125 assert_eq!(config.standby_nodes().len(), 1);
2126 }
2127
2128 #[test]
2137 fn test_substitute_env_set_value_wins() {
2138 std::env::set_var("HELIOS_SUBST_TEST_SET", "hello");
2139 assert_eq!(
2141 substitute_env("x = \"${HELIOS_SUBST_TEST_SET:-fallback}\"").unwrap(),
2142 "x = \"hello\""
2143 );
2144 assert_eq!(
2146 substitute_env("x = \"${HELIOS_SUBST_TEST_SET}\"").unwrap(),
2147 "x = \"hello\""
2148 );
2149 std::env::remove_var("HELIOS_SUBST_TEST_SET");
2150 }
2151
2152 #[test]
2153 fn test_substitute_env_default_fallback() {
2154 std::env::remove_var("HELIOS_SUBST_TEST_UNSET_A");
2155 assert_eq!(
2156 substitute_env("s = \"${HELIOS_SUBST_TEST_UNSET_A:-abc}\"").unwrap(),
2157 "s = \"abc\""
2158 );
2159 }
2160
2161 #[test]
2162 fn test_substitute_env_empty_default() {
2163 std::env::remove_var("HELIOS_SUBST_TEST_UNSET_B");
2164 assert_eq!(
2165 substitute_env("s = \"${HELIOS_SUBST_TEST_UNSET_B:-}\"").unwrap(),
2166 "s = \"\""
2167 );
2168 }
2169
2170 #[test]
2171 fn test_substitute_env_missing_no_default_errors() {
2172 std::env::remove_var("HELIOS_SUBST_TEST_UNSET_C");
2173 let err = substitute_env("s = \"${HELIOS_SUBST_TEST_UNSET_C}\"").unwrap_err();
2174 let msg = err.to_string();
2175 assert!(
2176 msg.contains("HELIOS_SUBST_TEST_UNSET_C"),
2177 "error must name the missing variable, got: {msg}"
2178 );
2179 }
2180
2181 #[test]
2182 fn test_substitute_env_skips_full_line_comments() {
2183 std::env::remove_var("HELIOS_SUBST_TEST_UNSET_D");
2184 let input = " # default_password = \"${HELIOS_SUBST_TEST_UNSET_D}\"\nx = 1\n";
2187 assert_eq!(substitute_env(input).unwrap(), input);
2188 }
2189
2190 #[test]
2191 fn test_substitute_env_multiple_on_one_line() {
2192 std::env::remove_var("HELIOS_SUBST_TEST_UNSET_E");
2193 std::env::remove_var("HELIOS_SUBST_TEST_UNSET_F");
2194 assert_eq!(
2195 substitute_env(
2196 "addr = \"${HELIOS_SUBST_TEST_UNSET_E:-host}:${HELIOS_SUBST_TEST_UNSET_F:-5432}\""
2197 )
2198 .unwrap(),
2199 "addr = \"host:5432\""
2200 );
2201 }
2202
2203 #[test]
2204 fn test_substitute_env_unquoted_numeric_position() {
2205 std::env::remove_var("HELIOS_SUBST_TEST_UNSET_G");
2206 let out = substitute_env("max_connections = ${HELIOS_SUBST_TEST_UNSET_G:-50}").unwrap();
2208 assert_eq!(out, "max_connections = 50");
2209 #[derive(serde::Deserialize)]
2211 struct P {
2212 max_connections: u32,
2213 }
2214 let p: P = toml::from_str(&out).unwrap();
2215 assert_eq!(p.max_connections, 50);
2216 }
2217
2218 #[test]
2219 fn test_substitute_env_leaves_malformed_literal() {
2220 assert_eq!(substitute_env("cost = $5.00\n").unwrap(), "cost = $5.00\n");
2222 std::env::remove_var("HELIOS_SUBST_TEST_UNSET_H");
2223 assert_eq!(
2225 substitute_env("x = \"${HELIOS_SUBST_TEST_UNSET_H:-oops\"").unwrap(),
2226 "x = \"${HELIOS_SUBST_TEST_UNSET_H:-oops\""
2227 );
2228 }
2229
2230 #[test]
2235 fn test_unknown_top_level_keys_detection() {
2236 let text = "listen_address = \"x\"\n\
2237 [pool]\nmin_connections = 1\n\
2238 [ha]\nenabled = true\n\
2239 [logging]\nlevel = \"info\"\n";
2240 assert_eq!(
2242 unknown_top_level_keys(text),
2243 vec!["ha".to_string(), "logging".to_string()]
2244 );
2245 }
2246
2247 #[test]
2248 fn test_unknown_top_level_keys_nested_are_out_of_scope() {
2249 let text = "[cache]\nenabled = true\n[cache.l1]\nsize = 500\n";
2252 assert!(unknown_top_level_keys(text).is_empty());
2253 }
2254
2255 #[test]
2256 fn test_known_top_level_keys_cover_struct_fields() {
2257 let value = toml::Value::try_from(ProxyConfig::default()).unwrap();
2263 let table = value.as_table().unwrap();
2264 for k in table.keys() {
2265 assert!(
2266 KNOWN_TOP_LEVEL_KEYS.contains(&k.as_str()),
2267 "field '{k}' is present in a serialised default ProxyConfig but \
2268 missing from KNOWN_TOP_LEVEL_KEYS"
2269 );
2270 }
2271 }
2272
2273 #[test]
2278 fn test_all_shipped_configs_parse() {
2279 let manifest = env!("CARGO_MANIFEST_DIR");
2297 let config_dir = format!("{manifest}/config");
2298 let regress_dir = format!("{manifest}/scripts/regress");
2299
2300 let mut config_checked = 0usize;
2302 let entries = std::fs::read_dir(&config_dir)
2303 .unwrap_or_else(|e| panic!("config dir {config_dir} unreadable: {e}"));
2304 for entry in entries {
2305 let path = entry.unwrap().path();
2306 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
2307 continue;
2308 }
2309 let path_str = path
2310 .to_str()
2311 .unwrap_or_else(|| panic!("non-UTF-8 config path {}", path.display()));
2312 let loaded = ProxyConfig::from_file(path_str);
2313 assert!(
2314 loaded.is_ok(),
2315 "shipped config {} failed to load via from_file() \
2316 (substitute + parse + validate): {}",
2317 path.display(),
2318 loaded.err().unwrap()
2319 );
2320 config_checked += 1;
2321 }
2322 assert!(
2323 config_checked >= 3,
2324 "expected to load at least the 3 config/*.toml files, checked {config_checked}"
2325 );
2326
2327 if let Ok(entries) = std::fs::read_dir(®ress_dir) {
2330 for entry in entries {
2331 let path = entry.unwrap().path();
2332 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
2333 continue;
2334 }
2335 let raw = std::fs::read_to_string(&path).unwrap();
2336 let substituted = substitute_env(&raw).unwrap_or_else(|e| {
2337 panic!("env substitution failed for {}: {e}", path.display())
2338 });
2339 let parsed = toml::from_str::<ProxyConfig>(&substituted);
2340 assert!(
2341 parsed.is_ok(),
2342 "regress config {} failed to deserialize: {}",
2343 path.display(),
2344 parsed.err().unwrap()
2345 );
2346 }
2347 }
2348 }
2349
2350 #[test]
2351 fn test_validate_no_nodes() {
2352 let config = ProxyConfig::default();
2353 assert!(config.validate().is_err());
2354 }
2355
2356 #[test]
2357 fn test_validate_no_primary() {
2358 let mut config = ProxyConfig::default();
2359 config.add_node("localhost:5432", "standby").unwrap();
2360 assert!(config.validate().is_err());
2361 }
2362
2363 #[test]
2364 fn test_validate_success() {
2365 let mut config = ProxyConfig::default();
2366 config.add_node("localhost:5432", "primary").unwrap();
2367 assert!(config.validate().is_ok());
2368 }
2369
2370 #[test]
2375 fn test_anomaly_toml_defaults_match_historical_values() {
2376 let a = AnomalyToml::default();
2380 assert_eq!(a.rate_window_secs, 60);
2381 assert_eq!(a.spike_z_threshold, 3.0);
2382 assert_eq!(a.auth_window_secs, 60);
2383 assert_eq!(a.auth_critical_count, 10);
2384 assert_eq!(a.auth_warning_count, 5);
2385 assert_eq!(a.event_buffer_size, 1024);
2386 assert!(a.emit_novel_queries);
2387 assert_eq!(a.max_seen_fingerprints, 100_000);
2388 }
2389
2390 #[test]
2391 fn test_anomaly_toml_absent_section_uses_defaults() {
2392 let mut base = ProxyConfig::default();
2396 base.add_node("localhost:5432", "primary").unwrap();
2397 let mut val = toml::Value::try_from(&base).unwrap();
2398 val.as_table_mut().unwrap().remove("anomaly");
2399 assert!(
2400 val.get("anomaly").is_none(),
2401 "anomaly section should be absent for this test"
2402 );
2403 let s = toml::to_string(&val).unwrap();
2404 let cfg: ProxyConfig = toml::from_str(&s).unwrap();
2405 let a = AnomalyToml::default();
2406 assert_eq!(cfg.anomaly.rate_window_secs, a.rate_window_secs);
2407 assert_eq!(cfg.anomaly.max_seen_fingerprints, a.max_seen_fingerprints);
2408 assert_eq!(cfg.anomaly.emit_novel_queries, a.emit_novel_queries);
2409 }
2410
2411 #[test]
2412 fn test_anomaly_toml_block_parses_and_overrides() {
2413 let mut base = ProxyConfig::default();
2416 base.add_node("localhost:5432", "primary").unwrap();
2417 base.anomaly = AnomalyToml {
2418 rate_window_secs: 30,
2419 spike_z_threshold: 4.5,
2420 auth_window_secs: 120,
2421 auth_critical_count: 20,
2422 auth_warning_count: 8,
2423 event_buffer_size: 4096,
2424 emit_novel_queries: false,
2425 max_seen_fingerprints: 250_000,
2426 };
2427 let s = toml::to_string(&base).unwrap();
2428 assert!(s.contains("[anomaly]"), "serialized config: {s}");
2429 let cfg: ProxyConfig = toml::from_str(&s).unwrap();
2430 assert_eq!(cfg.anomaly.rate_window_secs, 30);
2431 assert_eq!(cfg.anomaly.spike_z_threshold, 4.5);
2432 assert_eq!(cfg.anomaly.auth_window_secs, 120);
2433 assert_eq!(cfg.anomaly.auth_critical_count, 20);
2434 assert_eq!(cfg.anomaly.auth_warning_count, 8);
2435 assert_eq!(cfg.anomaly.event_buffer_size, 4096);
2436 assert!(!cfg.anomaly.emit_novel_queries);
2437 assert_eq!(cfg.anomaly.max_seen_fingerprints, 250_000);
2438 }
2439
2440 #[test]
2441 fn test_anomaly_toml_partial_block_fills_rest_from_defaults() {
2442 let a: AnomalyToml = toml::from_str("spike_z_threshold = 5.0\n").unwrap();
2446 assert_eq!(a.spike_z_threshold, 5.0);
2447 assert_eq!(a.rate_window_secs, 60);
2448 assert_eq!(a.event_buffer_size, 1024);
2449 assert_eq!(a.max_seen_fingerprints, 100_000);
2450 }
2451
2452 #[test]
2453 fn test_validate_rejects_degenerate_anomaly_values() {
2454 let base = || {
2455 let mut c = ProxyConfig::default();
2456 c.add_node("localhost:5432", "primary").unwrap();
2457 c
2458 };
2459 assert!(base().validate().is_ok());
2461
2462 let mut c = base();
2464 c.anomaly.rate_window_secs = 0;
2465 let err = c.validate().unwrap_err().to_string();
2466 assert!(
2467 err.contains("anomaly.rate_window_secs"),
2468 "unexpected error: {err}"
2469 );
2470
2471 let mut c = base();
2473 c.anomaly.auth_window_secs = 0;
2474 assert!(c.validate().is_err());
2475
2476 let mut c = base();
2478 c.anomaly.event_buffer_size = 0;
2479 assert!(c.validate().is_err());
2480
2481 let mut c = base();
2483 c.anomaly.max_seen_fingerprints = 0;
2484 assert!(c.validate().is_err());
2485
2486 let mut c = base();
2488 c.anomaly.spike_z_threshold = 0.0;
2489 assert!(c.validate().is_err());
2490 let mut c = base();
2491 c.anomaly.spike_z_threshold = f64::NAN;
2492 assert!(c.validate().is_err());
2493
2494 let mut c = base();
2496 c.anomaly.auth_warning_count = 11;
2497 c.anomaly.auth_critical_count = 10;
2498 assert!(c.validate().is_err());
2499
2500 let mut c = base();
2502 c.anomaly.auth_critical_count = 0;
2503 c.anomaly.auth_warning_count = 0; let err = c.validate().unwrap_err().to_string();
2505 assert!(
2506 err.contains("anomaly.auth_critical_count"),
2507 "unexpected error: {err}"
2508 );
2509 }
2510
2511 #[cfg(feature = "anomaly-detection")]
2512 #[test]
2513 fn test_anomaly_toml_to_anomaly_config_roundtrip() {
2514 let default_rt = AnomalyToml::default().to_anomaly_config();
2517 let expected = crate::anomaly::AnomalyConfig::default();
2518 assert_eq!(default_rt.rate_window_secs, expected.rate_window_secs);
2519 assert_eq!(default_rt.spike_z_threshold, expected.spike_z_threshold);
2520 assert_eq!(default_rt.auth_window_secs, expected.auth_window_secs);
2521 assert_eq!(default_rt.auth_critical_count, expected.auth_critical_count);
2522 assert_eq!(default_rt.auth_warning_count, expected.auth_warning_count);
2523 assert_eq!(default_rt.event_buffer_size, expected.event_buffer_size);
2524 assert_eq!(default_rt.emit_novel_queries, expected.emit_novel_queries);
2525 assert_eq!(
2526 default_rt.max_seen_fingerprints,
2527 expected.max_seen_fingerprints
2528 );
2529
2530 let toml = AnomalyToml {
2531 rate_window_secs: 15,
2532 spike_z_threshold: 2.5,
2533 auth_window_secs: 90,
2534 auth_critical_count: 12,
2535 auth_warning_count: 6,
2536 event_buffer_size: 2048,
2537 emit_novel_queries: false,
2538 max_seen_fingerprints: 500_000,
2539 };
2540 let rt = toml.to_anomaly_config();
2541 assert_eq!(rt.rate_window_secs, 15);
2542 assert_eq!(rt.spike_z_threshold, 2.5);
2543 assert_eq!(rt.auth_window_secs, 90);
2544 assert_eq!(rt.auth_critical_count, 12);
2545 assert_eq!(rt.auth_warning_count, 6);
2546 assert_eq!(rt.event_buffer_size, 2048);
2547 assert!(!rt.emit_novel_queries);
2548 assert_eq!(rt.max_seen_fingerprints, 500_000);
2549 }
2550
2551 #[test]
2552 fn test_validate_refuses_anonymous_nonloopback_admin() {
2553 let base = || {
2554 let mut c = ProxyConfig::default();
2555 c.add_node("localhost:5432", "primary").unwrap();
2556 c
2557 };
2558 let mut c = base();
2560 c.admin_address = "127.0.0.1:9090".to_string();
2561 assert!(c.validate().is_ok());
2562 let mut c = base();
2564 c.admin_address = "0.0.0.0:9090".to_string();
2565 assert!(
2566 c.validate().is_err(),
2567 "anonymous 0.0.0.0 admin must be refused"
2568 );
2569 let mut c = base();
2571 c.admin_address = "0.0.0.0:9090".to_string();
2572 c.admin_token = Some("secret".to_string());
2573 assert!(c.validate().is_ok());
2574 let mut c = base();
2576 c.admin_address = "0.0.0.0:9090".to_string();
2577 c.admin_allow_insecure = true;
2578 assert!(c.validate().is_ok());
2579 }
2580
2581 #[test]
2582 fn test_validate_rejects_zero_health_interval() {
2583 let mut config = ProxyConfig::default();
2586 config.add_node("localhost:5432", "primary").unwrap();
2587 config.health.check_interval_secs = 0;
2588 assert!(config.validate().is_err());
2589 config.health.check_interval_secs = 1;
2590 assert!(config.validate().is_ok());
2591 }
2592
2593 #[test]
2598 fn test_limits_defaults_equal_prior_constants() {
2599 let l = LimitsToml::default();
2603 assert_eq!(l.max_cancel_keys, 100_000);
2604 assert_eq!(l.startup_timeout_secs, 30);
2605 assert_eq!(l.backend_write_timeout_secs, 30);
2606 assert_eq!(l.backend_read_timeout_secs, 30);
2607 assert_eq!(l.client_write_timeout_secs, 60);
2608 assert_eq!(l.reprepare_timeout_secs, 15);
2609 assert_eq!(l.max_prepared_statements, 8192);
2610 assert_eq!(l.max_prepared_bytes, 64 * 1024 * 1024);
2611 assert_eq!(l.max_pending_bytes, 64 * 1024 * 1024);
2612 assert_eq!(l.max_total_idle_backend_conns, 8192);
2613 assert_eq!(l.pool_reap_interval_secs, 30);
2614 assert_eq!(
2616 ProxyConfig::default().limits.max_prepared_bytes,
2617 64 * 1024 * 1024
2618 );
2619 }
2620
2621 #[test]
2622 fn test_limits_toml_partial_overrides_and_fills_defaults() {
2623 let limits: LimitsToml = toml::from_str(
2626 "startup_timeout_secs = 5\nmax_prepared_statements = 100\nmax_cancel_keys = 42\n",
2627 )
2628 .expect("parse partial LimitsToml");
2629 assert_eq!(limits.startup_timeout_secs, 5);
2631 assert_eq!(limits.max_prepared_statements, 100);
2632 assert_eq!(limits.max_cancel_keys, 42);
2633 assert_eq!(limits.client_write_timeout_secs, 60);
2635 assert_eq!(limits.max_pending_bytes, 64 * 1024 * 1024);
2636 assert_eq!(limits.pool_reap_interval_secs, 30);
2637 }
2638
2639 #[test]
2640 fn test_proxyconfig_partial_limits_section_overrides() {
2641 let mut val = toml::Value::try_from(ProxyConfig::default()).unwrap();
2645 let mut partial = toml::value::Table::new();
2646 partial.insert("startup_timeout_secs".into(), toml::Value::Integer(5));
2647 partial.insert("max_cancel_keys".into(), toml::Value::Integer(42));
2648 val.as_table_mut()
2649 .unwrap()
2650 .insert("limits".into(), toml::Value::Table(partial));
2651 let text = toml::to_string(&val).unwrap();
2652 let cfg: ProxyConfig = toml::from_str(&text).expect("parse config with partial [limits]");
2653 assert_eq!(cfg.limits.startup_timeout_secs, 5);
2654 assert_eq!(cfg.limits.max_cancel_keys, 42);
2655 assert_eq!(cfg.limits.client_write_timeout_secs, 60);
2657 }
2658
2659 #[test]
2660 fn test_proxyconfig_absent_limits_section_is_default() {
2661 let mut val = toml::Value::try_from(ProxyConfig::default()).unwrap();
2665 val.as_table_mut().unwrap().remove("limits");
2666 assert!(val.as_table().unwrap().get("limits").is_none());
2667 let text = toml::to_string(&val).unwrap();
2668 let cfg: ProxyConfig = toml::from_str(&text).expect("parse config without [limits]");
2669 assert_eq!(cfg.limits.startup_timeout_secs, 30);
2670 assert_eq!(cfg.limits.max_total_idle_backend_conns, 8192);
2671 assert_eq!(cfg.limits.pool_reap_interval_secs, 30);
2672 }
2673
2674 #[test]
2675 fn test_validate_rejects_zero_limits() {
2676 let base = || {
2677 let mut c = ProxyConfig::default();
2678 c.add_node("localhost:5432", "primary").unwrap();
2679 c
2680 };
2681 assert!(base().validate().is_ok());
2683
2684 let mut c = base();
2686 c.limits.startup_timeout_secs = 0;
2687 assert!(
2688 c.validate().is_err(),
2689 "zero startup_timeout must be rejected"
2690 );
2691
2692 let mut c = base();
2693 c.limits.backend_write_timeout_secs = 0;
2694 assert!(c.validate().is_err());
2695
2696 let mut c = base();
2697 c.limits.client_write_timeout_secs = 0;
2698 assert!(c.validate().is_err());
2699
2700 let mut c = base();
2701 c.limits.reprepare_timeout_secs = 0;
2702 assert!(c.validate().is_err());
2703
2704 let mut c = base();
2705 c.limits.pool_reap_interval_secs = 0;
2706 assert!(c.validate().is_err());
2707
2708 let mut c = base();
2710 c.limits.max_cancel_keys = 0;
2711 assert!(c.validate().is_err());
2712
2713 let mut c = base();
2714 c.limits.max_prepared_statements = 0;
2715 assert!(c.validate().is_err());
2716
2717 let mut c = base();
2718 c.limits.max_prepared_bytes = 0;
2719 assert!(c.validate().is_err());
2720
2721 let mut c = base();
2722 c.limits.max_pending_bytes = 0;
2723 assert!(c.validate().is_err());
2724
2725 let mut c = base();
2726 c.limits.max_total_idle_backend_conns = 0;
2727 assert!(c.validate().is_err());
2728 }
2729
2730 #[test]
2731 fn test_validate_rejects_over_bound_limit_secs() {
2732 let base = || {
2733 let mut c = ProxyConfig::default();
2734 c.add_node("localhost:5432", "primary").unwrap();
2735 c
2736 };
2737 let mut c = base();
2741 c.limits.startup_timeout_secs = MAX_LIMIT_SECS;
2742 assert!(
2743 c.validate().is_ok(),
2744 "startup_timeout_secs at MAX_LIMIT_SECS must be accepted"
2745 );
2746
2747 let mut c = base();
2749 c.limits.startup_timeout_secs = u64::MAX;
2750 assert!(
2751 c.validate().is_err(),
2752 "over-bound startup_timeout_secs must be rejected"
2753 );
2754
2755 let mut c = base();
2756 c.limits.backend_write_timeout_secs = MAX_LIMIT_SECS + 1;
2757 assert!(c.validate().is_err());
2758
2759 let mut c = base();
2760 c.limits.backend_read_timeout_secs = u64::MAX;
2761 assert!(c.validate().is_err());
2762
2763 let mut c = base();
2764 c.limits.client_write_timeout_secs = u64::MAX;
2765 assert!(c.validate().is_err());
2766
2767 let mut c = base();
2768 c.limits.reprepare_timeout_secs = u64::MAX;
2769 assert!(c.validate().is_err());
2770
2771 let mut c = base();
2772 c.limits.pool_reap_interval_secs = u64::MAX;
2773 assert!(c.validate().is_err());
2774 }
2775
2776 #[test]
2777 fn test_validate_edge_disabled_section_is_inert() {
2778 let mut config = ProxyConfig::default();
2781 config.add_node("localhost:5432", "primary").unwrap();
2782 assert!(!config.edge.enabled);
2783 assert!(config.validate().is_ok());
2784 }
2785
2786 #[test]
2787 fn test_validate_edge_enabled_requires_feature() {
2788 let mut config = ProxyConfig::default();
2789 config.add_node("localhost:5432", "primary").unwrap();
2790 config.edge.enabled = true;
2791 if cfg!(feature = "edge-proxy") {
2793 assert!(config.validate().is_ok());
2794 } else {
2795 assert!(
2796 config.validate().is_err(),
2797 "edge.enabled on a build without the edge-proxy feature must be refused"
2798 );
2799 }
2800 }
2801
2802 #[cfg(feature = "edge-proxy")]
2803 #[test]
2804 fn test_validate_edge_rejects_zero_intervals() {
2805 let base = || {
2808 let mut c = ProxyConfig::default();
2809 c.add_node("localhost:5432", "primary").unwrap();
2810 c.edge.enabled = true;
2811 c
2812 };
2813 let mut c = base();
2814 c.edge.subscribe_gc_secs = 0;
2815 assert!(c.validate().is_err());
2816 let mut c = base();
2817 c.edge.liveness_window_secs = 0;
2818 assert!(c.validate().is_err());
2819 let mut c = base();
2822 c.edge.enabled = false;
2823 c.edge.subscribe_gc_secs = 0;
2824 assert!(c.validate().is_ok());
2825 }
2826
2827 #[cfg(feature = "edge-proxy")]
2828 #[test]
2829 fn test_validate_edge_role_requires_home_url() {
2830 let base = || {
2831 let mut c = ProxyConfig::default();
2832 c.add_node("localhost:5432", "primary").unwrap();
2833 c.edge.enabled = true;
2834 c.edge.role = crate::edge::EdgeRole::Edge;
2835 c
2836 };
2837 let c = base();
2839 let err = c.validate().unwrap_err().to_string();
2840 assert!(err.contains("home_url"), "unexpected error: {}", err);
2841 let mut c = base();
2843 c.edge.home_url = "http://home-proxy:9090".to_string();
2844 assert!(c.validate().is_ok());
2845 }
2846
2847 #[cfg(feature = "edge-proxy")]
2848 #[test]
2849 fn test_validate_edge_zero_ttl_refused_when_enabled() {
2850 let mut c = ProxyConfig::default();
2851 c.add_node("localhost:5432", "primary").unwrap();
2852 c.edge.enabled = true;
2853 c.edge.default_ttl_secs = 0;
2854 let err = c.validate().unwrap_err().to_string();
2855 assert!(
2856 err.contains("default_ttl_secs"),
2857 "unexpected error: {}",
2858 err
2859 );
2860 c.edge.enabled = false;
2862 assert!(c.validate().is_ok());
2863 }
2864
2865 #[cfg(feature = "edge-proxy")]
2866 #[test]
2867 fn test_validate_edge_token_requires_https_home_url() {
2868 let base = || {
2869 let mut c = ProxyConfig::default();
2870 c.add_node("localhost:5432", "primary").unwrap();
2871 c.edge.enabled = true;
2872 c.edge.role = crate::edge::EdgeRole::Edge;
2873 c.edge.home_url = "http://home-proxy:9090".to_string();
2874 c
2875 };
2876 assert!(base().validate().is_ok());
2878 let mut c = base();
2880 c.edge.auth_token = "secret".to_string();
2881 let err = c.validate().unwrap_err().to_string();
2882 assert!(err.contains("https"), "unexpected error: {}", err);
2883 assert!(err.contains("allow_insecure_home_url"), "{}", err);
2884 let mut c = base();
2886 c.edge.auth_token = "secret".to_string();
2887 c.edge.allow_insecure_home_url = true;
2888 assert!(c.validate().is_ok());
2889 let mut c = base();
2891 c.edge.auth_token = "secret".to_string();
2892 c.edge.home_url = "https://home-proxy:9090".to_string();
2893 assert!(c.validate().is_ok());
2894 }
2895
2896 #[cfg(all(feature = "edge-proxy", feature = "query-cache"))]
2897 #[test]
2898 fn test_validate_edge_role_rejects_query_cache_combo() {
2899 let mut c = ProxyConfig::default();
2902 c.add_node("localhost:5432", "primary").unwrap();
2903 c.edge.enabled = true;
2904 c.edge.role = crate::edge::EdgeRole::Edge;
2905 c.edge.home_url = "https://home-proxy:9090".to_string();
2906 c.cache.enabled = true;
2907 let err = c.validate().unwrap_err().to_string();
2908 assert!(err.contains("[cache]"), "unexpected error: {}", err);
2909 c.edge.role = crate::edge::EdgeRole::Home;
2912 c.edge.home_url.clear();
2913 assert!(c.validate().is_ok());
2914 c.edge.role = crate::edge::EdgeRole::Edge;
2916 c.edge.home_url = "https://home-proxy:9090".to_string();
2917 c.cache.enabled = false;
2918 assert!(c.validate().is_ok());
2919 }
2920
2921 #[test]
2922 fn test_pool_config_durations() {
2923 let config = PoolConfig::default();
2924 assert_eq!(config.idle_timeout(), Duration::from_secs(300));
2925 assert_eq!(config.max_lifetime(), Duration::from_secs(1800));
2926 }
2927
2928 #[test]
2929 fn test_pool_mode_default() {
2930 let config = PoolModeConfig::default();
2931 assert_eq!(config.mode, PoolingMode::Session);
2932 assert_eq!(config.max_pool_size, 100);
2933 assert_eq!(config.min_idle, 10);
2934 assert_eq!(config.reset_query, "DISCARD ALL");
2935 }
2936
2937 #[test]
2938 fn test_pool_mode_session() {
2939 let config = PoolModeConfig::session_mode();
2940 assert_eq!(config.mode, PoolingMode::Session);
2941 assert_eq!(config.prepared_statement_mode, PreparedStatementMode::Named);
2942 }
2943
2944 #[test]
2945 fn test_pool_mode_transaction() {
2946 let config = PoolModeConfig::transaction_mode();
2947 assert_eq!(config.mode, PoolingMode::Transaction);
2948 assert_eq!(config.prepared_statement_mode, PreparedStatementMode::Track);
2949 }
2950
2951 #[test]
2952 fn test_pool_mode_statement() {
2953 let config = PoolModeConfig::statement_mode();
2954 assert_eq!(config.mode, PoolingMode::Statement);
2955 assert_eq!(
2956 config.prepared_statement_mode,
2957 PreparedStatementMode::Disable
2958 );
2959 }
2960
2961 #[test]
2962 fn test_pool_mode_durations() {
2963 let config = PoolModeConfig::default();
2964 assert_eq!(config.idle_timeout(), Duration::from_secs(600));
2965 assert_eq!(config.max_lifetime(), Duration::from_secs(3600));
2966 assert_eq!(config.acquire_timeout(), Duration::from_secs(5));
2967 }
2968
2969 #[test]
2970 fn test_proxy_config_has_pool_mode() {
2971 let config = ProxyConfig::default();
2972 assert_eq!(config.pool_mode.mode, PoolingMode::Session);
2973 }
2974
2975 #[test]
2979 fn test_plugin_toml_default_is_disabled() {
2980 let config = ProxyConfig::default();
2981 assert!(!config.plugins.enabled);
2982 assert_eq!(config.plugins.plugin_dir, "/etc/heliosproxy/plugins");
2983 assert_eq!(config.plugins.memory_limit_mb, 64);
2984 assert_eq!(config.plugins.timeout_ms, 100);
2985 }
2986
2987 #[test]
2991 fn test_proxy_config_toml_without_plugins_section_still_parses() {
2992 let toml_text = r#"
2993 listen_address = "0.0.0.0:5432"
2994 admin_address = "0.0.0.0:9090"
2995 tr_enabled = true
2996 tr_mode = "session"
2997 nodes = []
2998
2999 [pool]
3000 min_connections = 2
3001 max_connections = 10
3002 idle_timeout_secs = 300
3003 max_lifetime_secs = 1800
3004 acquire_timeout_secs = 30
3005 test_on_acquire = true
3006
3007 [load_balancer]
3008 read_strategy = "round_robin"
3009 read_write_split = true
3010 latency_threshold_ms = 100
3011
3012 [health]
3013 check_interval_secs = 5
3014 check_timeout_secs = 3
3015 failure_threshold = 3
3016 success_threshold = 2
3017 check_query = "SELECT 1"
3018 "#;
3019 let config: ProxyConfig = toml::from_str(toml_text).expect("parse");
3020 assert!(!config.plugins.enabled);
3021 }
3022
3023 #[test]
3026 fn test_plugin_toml_overrides_parse() {
3027 let toml_text = r#"
3028 listen_address = "0.0.0.0:5432"
3029 admin_address = "0.0.0.0:9090"
3030 tr_enabled = true
3031 tr_mode = "session"
3032 nodes = []
3033
3034 [pool]
3035 min_connections = 2
3036 max_connections = 10
3037 idle_timeout_secs = 300
3038 max_lifetime_secs = 1800
3039 acquire_timeout_secs = 30
3040 test_on_acquire = true
3041
3042 [load_balancer]
3043 read_strategy = "round_robin"
3044 read_write_split = true
3045 latency_threshold_ms = 100
3046
3047 [health]
3048 check_interval_secs = 5
3049 check_timeout_secs = 3
3050 failure_threshold = 3
3051 success_threshold = 2
3052 check_query = "SELECT 1"
3053
3054 [plugins]
3055 enabled = true
3056 plugin_dir = "/tmp/helios-plugins"
3057 hot_reload = true
3058 memory_limit_mb = 128
3059 timeout_ms = 250
3060 "#;
3061 let config: ProxyConfig = toml::from_str(toml_text).expect("parse");
3062 assert!(config.plugins.enabled);
3063 assert_eq!(config.plugins.plugin_dir, "/tmp/helios-plugins");
3064 assert!(config.plugins.hot_reload);
3065 assert_eq!(config.plugins.memory_limit_mb, 128);
3066 assert_eq!(config.plugins.timeout_ms, 250);
3067 assert_eq!(config.plugins.max_plugins, 20);
3069 assert!(config.plugins.fuel_metering);
3070 }
3071}