1use anyhow::{bail, Context, Result};
2use serde::{Deserialize, Serialize};
3use std::env;
4use url::Url;
5
6const DEFAULT_DMS_BASE_URL: &str = "https://dms.auki.network/v1";
7const DEFAULT_DDS_BASE_URL: &str = "https://dds.auki.network";
8const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 60;
9const DEFAULT_REGISTER_INTERVAL_SECS: u64 = 120;
10const DEFAULT_REGISTER_MAX_RETRY: i32 = -1;
11const DEFAULT_HEARTBEAT_MIN_RATIO: f64 = 0.25;
12const DEFAULT_HEARTBEAT_MAX_RATIO: f64 = 0.35;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
16#[serde(rename_all = "lowercase")]
17pub enum LogFormat {
18 #[default]
19 Json,
20 Text,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25pub struct NodeConfig {
26 pub dms_base_url: Url,
28 pub node_version: String,
29 pub request_timeout_secs: u64,
30
31 pub dds_base_url: Option<Url>,
33 pub reg_secret: Option<String>,
34 pub secp256k1_privhex: Option<String>,
35
36 pub heartbeat_jitter_ms: u64,
38 pub heartbeat_min_ratio: f64,
39 pub heartbeat_max_ratio: f64,
40 pub poll_backoff_ms_min: u64,
41 pub poll_backoff_ms_max: u64,
42 pub token_safety_ratio: f32,
43 pub token_reauth_max_retries: u32,
44 pub token_reauth_jitter_ms: u64,
45 pub register_interval_secs: Option<u64>,
46 pub register_max_retry: Option<i32>,
47 pub max_concurrency: u32,
48 pub log_format: LogFormat,
49 pub enable_noop: bool,
50 pub noop_sleep_secs: u64,
51}
52
53impl NodeConfig {
54 pub fn from_env() -> Result<Self> {
56 let dms_base_url = parse_url_default("DMS_BASE_URL", DEFAULT_DMS_BASE_URL)?;
58 let request_timeout_secs =
59 parse_u64_default("REQUEST_TIMEOUT_SECS", DEFAULT_REQUEST_TIMEOUT_SECS)?;
60 let node_version = env::var("NODE_VERSION")
61 .ok()
62 .filter(|s| !s.trim().is_empty())
63 .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string());
64
65 let dds_base_url = parse_url_default("DDS_BASE_URL", DEFAULT_DDS_BASE_URL)?;
67 let reg_secret = env::var("REG_SECRET")
68 .with_context(|| "REG_SECRET required for DDS SIWE authentication")?
69 .trim()
70 .to_string();
71 if reg_secret.is_empty() {
72 bail!("REG_SECRET required for DDS SIWE authentication");
73 }
74 let secp256k1_privhex = env::var("SECP256K1_PRIVHEX")
75 .with_context(|| "SECP256K1_PRIVHEX required for DDS SIWE authentication")?
76 .trim()
77 .to_string();
78 if secp256k1_privhex.is_empty() {
79 bail!("SECP256K1_PRIVHEX required for DDS SIWE authentication");
80 }
81
82 let heartbeat_jitter_ms = parse_u64_opt("HEARTBEAT_JITTER_MS", 250)?;
84 let heartbeat_min_ratio =
85 parse_f64_opt("HEARTBEAT_MIN_RATIO", DEFAULT_HEARTBEAT_MIN_RATIO)?;
86 let heartbeat_max_ratio =
87 parse_f64_opt("HEARTBEAT_MAX_RATIO", DEFAULT_HEARTBEAT_MAX_RATIO)?;
88 let poll_backoff_ms_min = parse_u64_opt("POLL_BACKOFF_MS_MIN", 1000)?;
89 let poll_backoff_ms_max = parse_u64_opt("POLL_BACKOFF_MS_MAX", 30000)?;
90 let token_safety_ratio = parse_f32_opt("TOKEN_SAFETY_RATIO", 0.75)?;
91 let token_reauth_max_retries = parse_u32_opt("TOKEN_REAUTH_MAX_RETRIES", 3)?;
92 let token_reauth_jitter_ms = parse_u64_opt("TOKEN_REAUTH_JITTER_MS", 500)?;
93 let register_interval_secs = Some(parse_u64_default(
94 "REGISTER_INTERVAL_SECS",
95 DEFAULT_REGISTER_INTERVAL_SECS,
96 )?);
97 let register_max_retry = Some(parse_i32_default(
98 "REGISTER_MAX_RETRY",
99 DEFAULT_REGISTER_MAX_RETRY,
100 )?);
101 let max_concurrency = parse_u32_opt("MAX_CONCURRENCY", 1)?;
102 let log_format = parse_log_format("LOG_FORMAT").unwrap_or_default();
103 let enable_noop = parse_bool_opt("ENABLE_NOOP", false)?;
104 let noop_sleep_secs = parse_u64_opt("NOOP_SLEEP_SECS", 5)?;
105
106 Ok(Self {
107 dms_base_url,
108 node_version,
109 request_timeout_secs,
110 dds_base_url: Some(dds_base_url),
111 reg_secret: Some(reg_secret),
112 secp256k1_privhex: Some(secp256k1_privhex),
113 heartbeat_jitter_ms,
114 heartbeat_min_ratio,
115 heartbeat_max_ratio,
116 poll_backoff_ms_min,
117 poll_backoff_ms_max,
118 token_safety_ratio,
119 token_reauth_max_retries,
120 token_reauth_jitter_ms,
121 register_interval_secs,
122 register_max_retry,
123 max_concurrency,
124 log_format,
125 enable_noop,
126 noop_sleep_secs,
127 })
128 }
129}
130
131fn env_var_trimmed(key: &str) -> Option<String> {
132 env::var(key).ok().and_then(|value| {
133 let trimmed = value.trim();
134 if trimmed.is_empty() {
135 None
136 } else {
137 Some(trimmed.to_string())
138 }
139 })
140}
141
142fn parse_url_default(key: &str, default: &str) -> Result<Url> {
143 let raw = env_var_trimmed(key).unwrap_or_else(|| default.to_string());
144 Url::parse(&raw).with_context(|| format!("invalid URL in {key}"))
145}
146
147fn parse_u64_default(key: &str, default: u64) -> Result<u64> {
148 match env_var_trimmed(key) {
149 Some(value) => value
150 .parse()
151 .with_context(|| format!("invalid integer in {key}")),
152 None => Ok(default),
153 }
154}
155
156fn parse_u64_opt(key: &str, default: u64) -> Result<u64> {
157 match env::var(key) {
158 Ok(v) => v
159 .parse()
160 .with_context(|| format!("invalid integer in {key}")),
161 Err(_) => Ok(default),
162 }
163}
164
165fn parse_u32_opt(key: &str, default: u32) -> Result<u32> {
166 match env::var(key) {
167 Ok(v) => v
168 .parse()
169 .with_context(|| format!("invalid integer in {key}")),
170 Err(_) => Ok(default),
171 }
172}
173
174fn parse_i32_default(key: &str, default: i32) -> Result<i32> {
175 match env_var_trimmed(key) {
176 Some(value) => {
177 let parsed: i32 = value
178 .parse()
179 .with_context(|| format!("invalid integer in {key}"))?;
180 if parsed < -1 {
181 bail!("{key} must be -1 or a non-negative integer, got {parsed}");
182 }
183 Ok(parsed)
184 }
185 None => Ok(default),
186 }
187}
188
189fn parse_f32_opt(key: &str, default: f32) -> Result<f32> {
190 match env::var(key) {
191 Ok(v) => v.parse().with_context(|| format!("invalid float in {key}")),
192 Err(_) => Ok(default),
193 }
194}
195
196fn parse_f64_opt(key: &str, default: f64) -> Result<f64> {
197 match env::var(key) {
198 Ok(v) => v.parse().with_context(|| format!("invalid float in {key}")),
199 Err(_) => Ok(default),
200 }
201}
202
203fn parse_bool_opt(key: &str, default: bool) -> Result<bool> {
204 match env::var(key) {
205 Ok(v) => v
206 .parse::<bool>()
207 .with_context(|| format!("invalid bool in {key}; expected true/false")),
208 Err(_) => Ok(default),
209 }
210}
211
212fn parse_log_format(key: &str) -> Option<LogFormat> {
213 match env::var(key).ok()?.to_lowercase().as_str() {
214 "json" => Some(LogFormat::Json),
215 "text" => Some(LogFormat::Text),
216 _ => None,
217 }
218}