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}