stargazer/config.rs
1//! Configuration for the stargazer service.
2//!
3//! Configuration is loaded from a TOML file and can be overridden by environment
4//! variables. The TOML file is divided into sections matching the service tiers:
5//!
6//! - `[postgres]` — Database connection pool settings.
7//! - `[rdio]` — Rdio API upload endpoint and retry policy.
8//! - `[tier1]` — Discovery sweep intervals for Pi-Star, XLX API, and ircDDB.
9//! - `[tier2]` — XLX UDP JSON monitor concurrency and idle thresholds.
10//! - `[tier3]` — Deep D-STAR protocol connections for voice capture.
11//! - `[audio]` — MP3 encoding parameters.
12//! - `[server]` — HTTP API bind address.
13//!
14//! Environment variable overrides follow the `STARGAZER_SECTION_FIELD` pattern:
15//!
16//! | Variable | Overrides |
17//! |----------|-----------|
18//! | `STARGAZER_POSTGRES_URL` | `postgres.url` |
19//! | `STARGAZER_RDIO_ENDPOINT` | `rdio.endpoint` |
20//! | `STARGAZER_RDIO_API_KEY` | `rdio.api_key` |
21//! | `STARGAZER_TIER3_DPLUS_CALLSIGN` | `tier3.dplus_callsign` |
22//! | `STARGAZER_SERVER_LISTEN` | `server.listen` |
23
24use std::net::SocketAddr;
25use std::path::Path;
26
27use serde::Deserialize;
28
29/// Top-level configuration for the stargazer service.
30///
31/// Loaded from a TOML file via [`load`], with selective environment variable
32/// overrides applied afterwards.
33#[derive(Debug, Deserialize)]
34pub(crate) struct Config {
35 /// `PostgreSQL` connection pool configuration.
36 #[serde(default)]
37 pub(crate) postgres: PostgresConfig,
38
39 /// Rdio API upload endpoint configuration.
40 #[serde(default)]
41 pub(crate) rdio: RdioConfig,
42
43 /// Tier 1: discovery and sweep configuration.
44 #[serde(default)]
45 pub(crate) tier1: Tier1Config,
46
47 /// Tier 2: XLX live monitoring configuration.
48 #[serde(default)]
49 pub(crate) tier2: Tier2Config,
50
51 /// Tier 3: deep connect and voice recording configuration.
52 #[serde(default)]
53 pub(crate) tier3: Tier3Config,
54
55 /// Audio encoding configuration.
56 #[serde(default)]
57 pub(crate) audio: AudioConfig,
58
59 /// HTTP API server configuration.
60 #[serde(default)]
61 pub(crate) server: ServerConfig,
62}
63
64/// `PostgreSQL` connection pool settings.
65///
66/// The `url` field is the `libpq`-style connection string. The pool size
67/// controls how many connections sqlx keeps open concurrently.
68#[derive(Debug, Deserialize)]
69pub(crate) struct PostgresConfig {
70 /// `PostgreSQL` connection URL.
71 ///
72 /// Default: `"postgres://stargazer:pass@localhost/stargazer"`
73 #[serde(default = "default_postgres_url")]
74 pub(crate) url: String,
75
76 /// Maximum number of connections in the pool.
77 ///
78 /// Default: `10`
79 #[serde(default = "default_max_connections")]
80 pub(crate) max_connections: u32,
81}
82
83impl Default for PostgresConfig {
84 fn default() -> Self {
85 Self {
86 url: default_postgres_url(),
87 max_connections: default_max_connections(),
88 }
89 }
90}
91
92/// Rdio API upload configuration.
93///
94/// Stargazer uploads completed voice streams to an `SDRTrunk`-compatible Rdio
95/// API server using the `POST /api/call-upload` multipart protocol.
96#[derive(Debug, Deserialize)]
97pub(crate) struct RdioConfig {
98 /// Full URL of the Rdio API call-upload endpoint.
99 ///
100 /// Default: `"http://rdio-api:8080/api/call-upload"`
101 #[serde(default = "default_rdio_endpoint")]
102 pub(crate) endpoint: String,
103
104 /// API key sent with each upload as the `key` form field.
105 ///
106 /// Default: `"stargazer-key"`
107 #[serde(default = "default_rdio_api_key")]
108 pub(crate) api_key: String,
109
110 /// Seconds between upload retry attempts for failed streams.
111 ///
112 /// Default: `30`
113 #[serde(default = "default_retry_interval_secs")]
114 pub(crate) retry_interval_secs: u64,
115
116 /// Maximum number of upload attempts before marking a stream as failed.
117 ///
118 /// Default: `10`
119 #[serde(default = "default_max_retries")]
120 pub(crate) max_retries: u32,
121}
122
123impl Default for RdioConfig {
124 fn default() -> Self {
125 Self {
126 endpoint: default_rdio_endpoint(),
127 api_key: default_rdio_api_key(),
128 retry_interval_secs: default_retry_interval_secs(),
129 max_retries: default_max_retries(),
130 }
131 }
132}
133
134/// Tier 1: discovery sweep configuration.
135///
136/// Controls how often each external data source is polled to build the
137/// reflector registry.
138#[derive(Debug, Deserialize)]
139pub(crate) struct Tier1Config {
140 /// Poll interval for Pi-Star host files, in seconds.
141 ///
142 /// The Pi-Star host file changes rarely; daily polling is sufficient.
143 ///
144 /// Default: `86400` (24 hours)
145 #[serde(default = "default_pistar")]
146 pub(crate) pistar: u64,
147
148 /// Poll interval for XLX API reflector-list, in seconds.
149 ///
150 /// Default: `600` (10 minutes)
151 #[serde(default = "default_xlx_api")]
152 pub(crate) xlx_api: u64,
153
154 /// Poll interval for ircDDB last-heard page scrapes, in seconds.
155 ///
156 /// Default: `60` (1 minute)
157 #[serde(default = "default_ircddb")]
158 pub(crate) ircddb: u64,
159}
160
161impl Default for Tier1Config {
162 fn default() -> Self {
163 Self {
164 pistar: default_pistar(),
165 xlx_api: default_xlx_api(),
166 ircddb: default_ircddb(),
167 }
168 }
169}
170
171/// Tier 2: XLX live monitoring configuration.
172///
173/// Controls how many XLX reflectors are monitored concurrently via the UDP
174/// JSON monitor protocol (port 10001) and when idle monitors are disconnected.
175#[derive(Debug, Deserialize)]
176pub(crate) struct Tier2Config {
177 /// Maximum number of concurrent UDP JSON monitor connections.
178 ///
179 /// Default: `100`
180 #[serde(default = "default_max_concurrent_monitors")]
181 pub(crate) max_concurrent_monitors: usize,
182
183 /// Seconds of inactivity before disconnecting a Tier 2 monitor.
184 ///
185 /// Default: `600` (10 minutes)
186 #[serde(default = "default_tier2_idle_disconnect_secs")]
187 pub(crate) idle_disconnect_secs: u64,
188
189 /// Seconds of recent activity required to consider a reflector "active"
190 /// and eligible for Tier 2 monitoring.
191 ///
192 /// Default: `1800` (30 minutes)
193 #[serde(default = "default_activity_threshold_secs")]
194 pub(crate) activity_threshold_secs: u64,
195}
196
197impl Default for Tier2Config {
198 fn default() -> Self {
199 Self {
200 max_concurrent_monitors: default_max_concurrent_monitors(),
201 idle_disconnect_secs: default_tier2_idle_disconnect_secs(),
202 activity_threshold_secs: default_activity_threshold_secs(),
203 }
204 }
205}
206
207/// Tier 3: deep D-STAR protocol connection configuration.
208///
209/// Controls how many reflectors are simultaneously connected at the D-STAR
210/// protocol level for voice capture, and the callsign used for `DPlus`
211/// authentication.
212#[derive(Debug, Deserialize)]
213pub(crate) struct Tier3Config {
214 /// Maximum number of concurrent D-STAR protocol connections.
215 ///
216 /// Default: `20`
217 #[serde(default = "default_max_concurrent_connections")]
218 pub(crate) max_concurrent_connections: usize,
219
220 /// Seconds of silence before disconnecting a Tier 3 session.
221 ///
222 /// Default: `300` (5 minutes)
223 #[serde(default = "default_tier3_idle_disconnect_secs")]
224 pub(crate) idle_disconnect_secs: u64,
225
226 /// Whether Tier 2 activity automatically promotes reflectors to Tier 3.
227 ///
228 /// Default: `true`
229 #[serde(default = "default_auto_promote")]
230 pub(crate) auto_promote: bool,
231
232 /// Callsign used for `DPlus` authentication with `auth.dstargateway.org`.
233 ///
234 /// Must be a valid amateur radio callsign registered with the `DPlus`
235 /// gateway trust system.
236 ///
237 /// Default: `"N0CALL"`
238 #[serde(default = "default_dplus_callsign")]
239 pub(crate) dplus_callsign: String,
240}
241
242impl Default for Tier3Config {
243 fn default() -> Self {
244 Self {
245 max_concurrent_connections: default_max_concurrent_connections(),
246 idle_disconnect_secs: default_tier3_idle_disconnect_secs(),
247 auto_promote: default_auto_promote(),
248 dplus_callsign: default_dplus_callsign(),
249 }
250 }
251}
252
253/// Audio encoding configuration.
254///
255/// Controls the output format and quality of decoded voice streams.
256#[derive(Debug, Deserialize)]
257pub(crate) struct AudioConfig {
258 /// Audio output format. Currently only `"mp3"` is supported.
259 ///
260 /// Default: `"mp3"`
261 #[serde(default = "default_audio_format")]
262 pub(crate) format: String,
263
264 /// MP3 constant bitrate in kbps.
265 ///
266 /// D-STAR voice is narrow-band (8 kHz sample rate); 64 kbps provides
267 /// good quality without excessive file size.
268 ///
269 /// Default: `64`
270 #[serde(default = "default_mp3_bitrate")]
271 pub(crate) mp3_bitrate: u32,
272}
273
274impl Default for AudioConfig {
275 fn default() -> Self {
276 Self {
277 format: default_audio_format(),
278 mp3_bitrate: default_mp3_bitrate(),
279 }
280 }
281}
282
283/// HTTP API server configuration.
284#[derive(Debug, Deserialize)]
285pub(crate) struct ServerConfig {
286 /// Socket address to bind the HTTP API server to.
287 ///
288 /// Default: `"0.0.0.0:8080"`
289 #[serde(default = "default_listen")]
290 pub(crate) listen: String,
291}
292
293impl Default for ServerConfig {
294 fn default() -> Self {
295 Self {
296 listen: default_listen(),
297 }
298 }
299}
300
301// ---------------------------------------------------------------------------
302// Default value functions for serde
303// ---------------------------------------------------------------------------
304
305fn default_postgres_url() -> String {
306 String::from("postgres://stargazer:pass@localhost/stargazer")
307}
308
309const fn default_max_connections() -> u32 {
310 10
311}
312
313fn default_rdio_endpoint() -> String {
314 String::from("http://rdio-api:8080/api/call-upload")
315}
316
317fn default_rdio_api_key() -> String {
318 String::from("stargazer-key")
319}
320
321const fn default_retry_interval_secs() -> u64 {
322 30
323}
324
325const fn default_max_retries() -> u32 {
326 10
327}
328
329const fn default_pistar() -> u64 {
330 86400
331}
332
333const fn default_xlx_api() -> u64 {
334 600
335}
336
337const fn default_ircddb() -> u64 {
338 60
339}
340
341const fn default_max_concurrent_monitors() -> usize {
342 100
343}
344
345const fn default_tier2_idle_disconnect_secs() -> u64 {
346 600
347}
348
349const fn default_activity_threshold_secs() -> u64 {
350 1800
351}
352
353const fn default_max_concurrent_connections() -> usize {
354 20
355}
356
357const fn default_tier3_idle_disconnect_secs() -> u64 {
358 300
359}
360
361const fn default_auto_promote() -> bool {
362 true
363}
364
365fn default_dplus_callsign() -> String {
366 String::from("N0CALL")
367}
368
369fn default_audio_format() -> String {
370 String::from("mp3")
371}
372
373const fn default_mp3_bitrate() -> u32 {
374 64
375}
376
377fn default_listen() -> String {
378 String::from("0.0.0.0:8080")
379}
380
381/// Loads configuration from a TOML file, then applies environment variable
382/// overrides.
383///
384/// # Environment variable overrides
385///
386/// The following environment variables, when set, override the corresponding
387/// TOML fields:
388///
389/// - `STARGAZER_POSTGRES_URL` overrides `postgres.url`
390/// - `STARGAZER_RDIO_ENDPOINT` overrides `rdio.endpoint`
391/// - `STARGAZER_RDIO_API_KEY` overrides `rdio.api_key`
392/// - `STARGAZER_TIER3_DPLUS_CALLSIGN` overrides `tier3.dplus_callsign`
393/// - `STARGAZER_SERVER_LISTEN` overrides `server.listen`
394///
395/// # Errors
396///
397/// Returns an error if the file cannot be read or contains invalid TOML, or
398/// if the `STARGAZER_SERVER_LISTEN` environment variable contains an invalid
399/// socket address.
400pub(crate) fn load(path: &Path) -> Result<Config, Box<dyn std::error::Error>> {
401 let contents = std::fs::read_to_string(path)?;
402 let mut config: Config = toml::from_str(&contents)?;
403
404 // Apply environment variable overrides.
405 if let Ok(val) = std::env::var("STARGAZER_POSTGRES_URL") {
406 config.postgres.url = val;
407 }
408 if let Ok(val) = std::env::var("STARGAZER_RDIO_ENDPOINT") {
409 config.rdio.endpoint = val;
410 }
411 if let Ok(val) = std::env::var("STARGAZER_RDIO_API_KEY") {
412 config.rdio.api_key = val;
413 }
414 if let Ok(val) = std::env::var("STARGAZER_TIER3_DPLUS_CALLSIGN") {
415 config.tier3.dplus_callsign = val;
416 }
417 if let Ok(val) = std::env::var("STARGAZER_SERVER_LISTEN") {
418 // Validate the override is a valid socket address early.
419 let _: SocketAddr = val.parse()?;
420 config.server.listen = val;
421 }
422
423 Ok(config)
424}