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)]
279 pub lag_routing: LagRoutingToml,
280 #[serde(default)]
283 pub cache: CacheToml,
284 #[serde(default)]
287 pub query_rewrite: QueryRewriteToml,
288 #[serde(default)]
292 pub multi_tenancy: MultiTenancyToml,
293 #[serde(default)]
296 pub schema_routing: SchemaRoutingToml,
297 #[serde(default)]
300 pub graphql_gateway: GraphqlGatewayConfig,
301 #[serde(default = "default_true")]
308 pub optimize_unnamed_parse: bool,
309 #[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#[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 #[serde(default = "default_pg_user")]
334 pub admin_user: String,
335 pub admin_password: Option<String>,
336 #[serde(default = "default_admin_db")]
339 pub admin_database: String,
340 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct MirrorConfig {
367 #[serde(default)]
368 pub enabled: bool,
369 #[serde(default = "default_sample_rate")]
371 pub sample_rate: f64,
372 #[serde(default = "default_true_bool")]
375 pub writes_only: bool,
376 #[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 #[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#[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
475pub struct McpConfig {
476 #[serde(default)]
477 pub enabled: bool,
478 #[serde(default = "default_mcp_listen")]
480 pub listen_address: String,
481 #[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 #[serde(default = "default_true_bool")]
493 pub read_only: bool,
494 #[serde(default)]
497 pub contract: Option<String>,
498 #[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
541pub struct AuthConfig {
542 #[serde(default)]
546 pub mode: AuthMode,
547 #[serde(default)]
550 pub auth_file: Option<String>,
551}
552
553#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
555#[serde(rename_all = "lowercase")]
556pub enum AuthMode {
557 #[default]
559 Passthrough,
560 Scram,
562}
563
564#[derive(Debug, Clone, Serialize, Deserialize)]
571pub struct HbaRule {
572 pub action: HbaAction,
574 #[serde(default = "hba_all")]
576 pub user: String,
577 #[serde(default = "hba_all")]
579 pub database: String,
580 #[serde(default = "hba_all")]
583 pub address: String,
584}
585
586fn hba_all() -> String {
587 "all".to_string()
588}
589
590#[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 }
601
602#[derive(Debug, Clone, Default, Serialize, Deserialize)]
604#[serde(default)]
605pub struct GqlTableToml {
606 pub name: String,
607 pub columns: Vec<String>,
608}
609
610#[derive(Debug, Clone, Serialize, Deserialize)]
613#[serde(default)]
614pub struct GraphqlGatewayConfig {
615 pub enabled: bool,
617 pub listen_address: String,
619 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 pub auth_token: Option<String>,
627 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
650#[serde(default)]
651pub struct SchemaRoutingToml {
652 pub enabled: bool,
655 pub analytics_node: String,
657}
658
659#[derive(Debug, Clone, Serialize, Deserialize)]
663#[serde(default)]
664pub struct MultiTenancyToml {
665 pub enabled: bool,
667 pub identify_by: String,
670 pub tenant_column: String,
672 pub tenant_tables: Vec<String>,
675 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
695#[serde(default)]
696pub struct RewriteRuleToml {
697 pub match_table: Option<String>,
699 pub match_regex: Option<String>,
701 pub replace_table_with: Option<String>,
703 pub append_where: Option<String>,
705 pub add_limit: Option<u32>,
707}
708
709#[derive(Debug, Clone, Default, Serialize, Deserialize)]
713#[serde(default)]
714pub struct QueryRewriteToml {
715 pub enabled: bool,
717 pub rules: Vec<RewriteRuleToml>,
719}
720
721#[derive(Debug, Clone, Serialize, Deserialize)]
725#[serde(default)]
726pub struct CacheToml {
727 pub enabled: bool,
729 pub ttl_secs: u64,
731 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#[derive(Debug, Clone, Serialize, Deserialize)]
748#[serde(default)]
749pub struct LagRoutingToml {
750 pub enabled: bool,
752 pub ryw_window_ms: u64,
756 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#[derive(Debug, Clone, Serialize, Deserialize)]
776#[serde(default)]
777pub struct AnalyticsToml {
778 pub enabled: bool,
781 pub slow_query_ms: u64,
783 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#[derive(Debug, Clone, Serialize, Deserialize)]
801#[serde(default)]
802pub struct CircuitBreakerToml {
803 pub enabled: bool,
805 pub failure_threshold: u32,
808 pub open_secs: u64,
810 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
827#[serde(rename_all = "snake_case")]
828pub enum RateLimitKeyBy {
829 #[default]
831 User,
832 ClientIp,
834 Database,
836 Global,
838}
839
840#[derive(Debug, Clone, Serialize, Deserialize)]
845#[serde(default)]
846pub struct RateLimitToml {
847 pub enabled: bool,
849 pub default_qps: u32,
851 pub default_burst: u32,
853 pub max_concurrent: u32,
855 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#[derive(Debug, Clone, Serialize, Deserialize)]
877#[serde(default)]
878pub struct RoutingHintsConfig {
879 pub enabled: bool,
882 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
955pub struct PluginToml {
956 #[serde(default)]
959 pub enabled: bool,
960 #[serde(default = "default_plugin_dir")]
962 pub plugin_dir: String,
963 #[serde(default)]
965 pub hot_reload: bool,
966 #[serde(default = "default_plugin_memory_mb")]
968 pub memory_limit_mb: usize,
969 #[serde(default = "default_plugin_timeout_ms")]
971 pub timeout_ms: u64,
972 #[serde(default = "default_plugin_max")]
974 pub max_plugins: usize,
975 #[serde(default = "default_true")]
977 pub fuel_metering: bool,
978 #[serde(default = "default_plugin_fuel")]
980 pub fuel_limit: u64,
981 #[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
1025fn substitute_env(text: &str) -> Result<String> {
1058 let mut out = String::with_capacity(text.len());
1062 for line in text.split_inclusive('\n') {
1063 if line.trim_start().starts_with('#') {
1064 out.push_str(line);
1066 } else {
1067 substitute_line(line, &mut out)?;
1068 }
1069 }
1070 Ok(out)
1071}
1072
1073fn 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..]; match parse_placeholder(body)? {
1080 Some((value, consumed)) => {
1081 out.push_str(&value);
1082 rest = &body[consumed..];
1083 }
1084 None => {
1085 out.push_str("${");
1089 rest = body;
1090 }
1091 }
1092 }
1093 out.push_str(rest);
1094 Ok(())
1095}
1096
1097fn parse_placeholder(body: &str) -> Result<Option<(String, usize)>> {
1105 let bytes = body.as_bytes();
1106 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); }
1124 let name = &body[..n];
1125 let after = &body[n..];
1126
1127 if after.starts_with('}') {
1128 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 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 Ok(Some((value, n + 2 + end + 1)))
1145 }
1146 None => Ok(None), }
1148 } else {
1149 Ok(None) }
1151}
1152
1153const 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
1198fn 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 pub fn write_timeout(&self) -> Duration {
1223 Duration::from_secs(self.write_timeout_secs)
1224 }
1225
1226 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 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 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 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 pub fn validate(&self) -> Result<()> {
1305 if self.nodes.is_empty() {
1307 return Err(ProxyError::Config(
1308 "No backend nodes configured".to_string(),
1309 ));
1310 }
1311
1312 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 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 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 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 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 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 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 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 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 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 pub fn primary_node(&self) -> Option<&NodeConfig> {
1458 self.nodes
1459 .iter()
1460 .find(|n| n.role == NodeRole::Primary && n.enabled)
1461 }
1462
1463 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 pub fn enabled_nodes(&self) -> Vec<&NodeConfig> {
1473 self.nodes.iter().filter(|n| n.enabled).collect()
1474 }
1475}
1476
1477#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1479#[serde(rename_all = "lowercase")]
1480#[derive(Default)]
1481pub enum TrMode {
1482 None,
1484 #[default]
1486 Session,
1487 Select,
1489 Transaction,
1491}
1492
1493#[derive(Debug, Clone, Serialize, Deserialize)]
1495pub struct PoolConfig {
1496 pub min_connections: usize,
1498 pub max_connections: usize,
1500 pub idle_timeout_secs: u64,
1502 pub max_lifetime_secs: u64,
1504 pub acquire_timeout_secs: u64,
1506 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 pub fn idle_timeout(&self) -> Duration {
1526 Duration::from_secs(self.idle_timeout_secs)
1527 }
1528
1529 pub fn max_lifetime(&self) -> Duration {
1531 Duration::from_secs(self.max_lifetime_secs)
1532 }
1533
1534 pub fn acquire_timeout(&self) -> Duration {
1536 Duration::from_secs(self.acquire_timeout_secs)
1537 }
1538}
1539
1540#[derive(Debug, Clone, Serialize, Deserialize)]
1542pub struct LoadBalancerConfig {
1543 pub read_strategy: Strategy,
1545 pub read_write_split: bool,
1547 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1563#[serde(rename_all = "snake_case")]
1564pub enum Strategy {
1565 RoundRobin,
1567 WeightedRoundRobin,
1569 LeastConnections,
1571 LatencyBased,
1573 Random,
1575}
1576
1577#[derive(Debug, Clone, Serialize, Deserialize)]
1579pub struct HealthConfig {
1580 pub check_interval_secs: u64,
1582 pub check_timeout_secs: u64,
1584 pub failure_threshold: u32,
1586 pub success_threshold: u32,
1588 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 pub fn check_interval(&self) -> Duration {
1607 Duration::from_secs(self.check_interval_secs)
1608 }
1609
1610 pub fn check_timeout(&self) -> Duration {
1612 Duration::from_secs(self.check_timeout_secs)
1613 }
1614}
1615
1616#[derive(Debug, Clone, Serialize, Deserialize)]
1618pub struct NodeConfig {
1619 pub host: String,
1621 pub port: u16,
1623 #[serde(default = "default_http_port")]
1626 pub http_port: u16,
1627 pub role: NodeRole,
1629 pub weight: u32,
1631 pub enabled: bool,
1633 pub name: Option<String>,
1635}
1636
1637fn default_http_port() -> u16 {
1638 8080
1639}
1640
1641impl NodeConfig {
1642 pub fn address(&self) -> String {
1644 format!("{}:{}", self.host, self.port)
1645 }
1646
1647 pub fn display_name(&self) -> &str {
1649 self.name.as_deref().unwrap_or(&self.host)
1650 }
1651}
1652
1653#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1655#[serde(rename_all = "lowercase")]
1656pub enum NodeRole {
1657 Primary,
1659 Standby,
1661 #[serde(rename = "replica")]
1663 ReadReplica,
1664}
1665
1666#[derive(Debug, Clone, Serialize, Deserialize)]
1668pub struct TlsConfig {
1669 pub enabled: bool,
1671 pub cert_path: String,
1673 pub key_path: String,
1675 pub ca_path: Option<String>,
1677 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 #[test]
1712 fn test_substitute_env_set_value_wins() {
1713 std::env::set_var("HELIOS_SUBST_TEST_SET", "hello");
1714 assert_eq!(
1716 substitute_env("x = \"${HELIOS_SUBST_TEST_SET:-fallback}\"").unwrap(),
1717 "x = \"hello\""
1718 );
1719 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 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 let out = substitute_env("max_connections = ${HELIOS_SUBST_TEST_UNSET_G:-50}").unwrap();
1783 assert_eq!(out, "max_connections = 50");
1784 #[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 assert_eq!(substitute_env("cost = $5.00\n").unwrap(), "cost = $5.00\n");
1797 std::env::remove_var("HELIOS_SUBST_TEST_UNSET_H");
1798 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 #[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 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 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 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 #[test]
1853 fn test_all_shipped_configs_parse() {
1854 let manifest = env!("CARGO_MANIFEST_DIR");
1872 let config_dir = format!("{manifest}/config");
1873 let regress_dir = format!("{manifest}/scripts/regress");
1874
1875 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 if let Ok(entries) = std::fs::read_dir(®ress_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 let mut c = base();
1954 c.admin_address = "127.0.0.1:9090".to_string();
1955 assert!(c.validate().is_ok());
1956 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 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 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 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 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 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 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 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 let c = base();
2050 let err = c.validate().unwrap_err().to_string();
2051 assert!(err.contains("home_url"), "unexpected error: {}", err);
2052 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 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 assert!(base().validate().is_ok());
2089 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 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 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 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 c.edge.role = crate::edge::EdgeRole::Home;
2123 c.edge.home_url.clear();
2124 assert!(c.validate().is_ok());
2125 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 #[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 #[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 #[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 assert_eq!(config.plugins.max_plugins, 20);
2280 assert!(config.plugins.fuel_metering);
2281 }
2282}