Skip to main content

http_srv/
config.rs

1#![allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
2
3use core::fmt;
4use std::{
5    env, fs,
6    path::{Path, PathBuf},
7    process,
8    str::FromStr,
9    sync::Arc,
10    time::Duration,
11};
12
13use jsonrs::Json;
14pub use pool::PoolConfig;
15
16use crate::{
17    Result,
18    log::{self},
19    log_info, log_warn,
20};
21
22#[derive(Clone, Debug)]
23pub enum Preset {
24    Read,
25    ReadWrite,
26    Post,
27}
28
29#[derive(Clone)]
30pub struct ServerConfig {
31    pub port: u16,
32    pub pool_conf: PoolConfig,
33    pub keep_alive_timeout: Duration,
34    pub keep_alive_requests: u16,
35    pub log_file: Option<String>,
36    pub setup_lib: Option<String>,
37    pub preset: Preset,
38
39    #[cfg(feature = "tls")]
40    pub tls_config: Option<Arc<rustls::ServerConfig>>,
41}
42
43impl fmt::Debug for ServerConfig {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        let mut deb = f.debug_struct("ServerConfig");
46        deb.field("port", &self.port)
47            .field("pool_conf", &self.pool_conf)
48            .field("keep_alive_timeout", &self.keep_alive_timeout)
49            .field("keep_alive_requests", &self.keep_alive_requests)
50            .field("setup_lib", &self.setup_lib)
51            .field("preset", &self.preset)
52            .field("log_file", &self.log_file);
53
54        #[cfg(feature = "tls")]
55        deb.field("tls", &self.tls_config.is_some());
56
57        deb.finish()
58    }
59}
60
61#[cfg(not(test))]
62fn get_default_conf_file() -> Option<PathBuf> {
63    if let Ok(path) = env::var("XDG_CONFIG_HOME") {
64        let mut p = PathBuf::new();
65        p.push(path);
66        p.push("http-srv");
67        p.push("config.json");
68        Some(p)
69    } else if let Ok(path) = env::var("HOME") {
70        let mut p = PathBuf::new();
71        p.push(path);
72        p.push(".config");
73        p.push("http-srv");
74        p.push("config.json");
75        Some(p)
76    } else {
77        None
78    }
79}
80
81#[cfg(test)]
82fn get_default_conf_file() -> Option<PathBuf> {
83    None
84}
85
86#[cfg(feature = "tls")]
87#[allow(clippy::unwrap_used)]
88fn get_tls_config(cert: Option<String>, pkey: Option<String>) -> Result<Arc<rustls::ServerConfig>> {
89    use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject};
90
91    let Some(cert) = cert else {
92        return Err("Missing certificate file".into());
93    };
94    let Some(pkey) = pkey else {
95        return Err("Missing private key file".into());
96    };
97
98    let certs = CertificateDer::pem_file_iter(cert)
99        .unwrap()
100        .map(|cert| cert.unwrap())
101        .collect();
102    let private_key = PrivateKeyDer::from_pem_file(pkey).unwrap();
103    let config = rustls::ServerConfig::builder()
104        .with_no_client_auth()
105        .with_single_cert(certs, private_key)
106        .map_err(|err| format!("rustls: {err}"))?;
107
108    Ok(Arc::new(config))
109}
110
111fn parse_preset(arg: &str, is_from_cli: bool) -> Result<Preset> {
112    Ok(match arg {
113        "read" => Preset::Read,
114        "readwrite" => Preset::ReadWrite,
115        "post" => Preset::Post,
116        p => {
117            return Err(format!(
118                "Unknown argument for \"{}preset\": {p}.\
119                Valid values: read, readwrite, post",
120                if is_from_cli { "--" } else { "" }
121            )
122            .into());
123        }
124    })
125}
126
127/// [`crate::HttpServer`] configuration
128///
129/// # Example
130/// ```
131/// use http_srv::ServerConfig;
132/// use pool::PoolConfig;
133///
134/// let pool_conf = PoolConfig::builder()
135///                 .n_workers(120_u16)
136///                 .build();
137/// let conf =
138/// ServerConfig::default()
139///     .port(8080)
140///     .pool_config(pool_conf);
141/// ```
142impl ServerConfig {
143    /// Parse the configuration from the command line args
144    pub fn parse<S: AsRef<str>>(args: &[S]) -> Result<Self> {
145        const CONFIG_FILE_ARG: &str = "--conf";
146
147        let mut conf = Self::default();
148
149        let mut conf_file = get_default_conf_file();
150
151        /* Parse the --config-file before the rest */
152        let mut first_pass = args.iter();
153        while let Some(arg) = first_pass.next() {
154            if arg.as_ref() == CONFIG_FILE_ARG {
155                let fname = first_pass
156                    .next()
157                    .ok_or_else(|| format!("Missing argument for \"{CONFIG_FILE_ARG}\""))?;
158                let filename = PathBuf::from(fname.as_ref());
159                if filename.exists() {
160                    conf_file = Some(filename);
161                } else {
162                    log_warn!(
163                        "Config path: {} doesn't exist",
164                        filename.as_os_str().to_str().unwrap_or("[??]")
165                    );
166                }
167            }
168        }
169
170        if let Some(cfile) = conf_file {
171            conf.parse_conf_file(&cfile)?;
172        }
173
174        let mut pool_conf_builder = PoolConfig::builder();
175
176        #[cfg(feature = "tls")]
177        let mut tls = false;
178        #[cfg(feature = "tls")]
179        let mut cert: Option<String> = None;
180        #[cfg(feature = "tls")]
181        let mut privkey: Option<String> = None;
182
183        let mut args = args.iter();
184        while let Some(arg) = args.next() {
185            macro_rules! parse_next {
186                () => {
187                    args.next_parse().ok_or_else(|| {
188                        format!("Missing or incorrect argument for \"{}\"", arg.as_ref())
189                    })?
190                };
191                (as $t:ty) => {{
192                    let _next: $t = parse_next!();
193                    _next
194                }};
195            }
196
197            match arg.as_ref() {
198                "-p" | "--port" => conf.port = parse_next!(),
199                "-n" | "-n-workers" => {
200                    pool_conf_builder.set_n_workers(parse_next!(as u16));
201                }
202                "-d" | "--dir" => {
203                    let path: String = parse_next!();
204                    env::set_current_dir(Path::new(&path))?;
205                }
206                "-k" | "--keep-alive" => {
207                    let timeout = parse_next!();
208                    conf.keep_alive_timeout = Duration::from_secs_f32(timeout);
209                }
210                "-r" | "--keep-alive-requests" => conf.keep_alive_requests = parse_next!(),
211                "-l" | "--log" => conf.log_file = Some(parse_next!()),
212                "--license" => license(),
213                "--log-level" => {
214                    let n: u8 = parse_next!();
215                    log::set_level(n.try_into()?);
216                }
217                "--preset" => {
218                    let arg = args.next().ok_or("Missing argument for \"--preset\"")?;
219                    conf.preset = parse_preset(arg.as_ref(), true)?;
220                }
221
222                "--setup-lib" => conf.setup_lib = Some(parse_next!()),
223
224                #[cfg(feature = "tls")]
225                "--tls" => tls = true,
226
227                #[cfg(feature = "tls")]
228                "--cert-file" => cert = Some(parse_next!()),
229
230                #[cfg(feature = "tls")]
231                "--private-key" => privkey = Some(parse_next!()),
232
233                CONFIG_FILE_ARG => {
234                    let _ = args.next();
235                }
236                "-h" | "--help" => help(),
237                unknown => return Err(format!("Unknow argument: {unknown}").into()),
238            }
239        }
240
241        conf.pool_conf = pool_conf_builder.build();
242
243        #[cfg(feature = "tls")]
244        if conf.tls_config.is_none() && tls {
245            conf.tls_config = Some(get_tls_config(cert, privkey)?);
246        }
247
248        log_info!("{conf:#?}");
249        Ok(conf)
250    }
251    #[allow(clippy::too_many_lines)]
252    fn parse_conf_file(&mut self, conf_file: &Path) -> crate::Result<()> {
253        if !conf_file.exists() {
254            return Ok(());
255        }
256        let conf_str = conf_file.as_os_str().to_str().unwrap_or("");
257        let f = fs::read_to_string(conf_file).unwrap_or_else(|err| {
258            eprintln!("Error reading config file \"{conf_str}\": {err}");
259            std::process::exit(1);
260        });
261        let json = Json::deserialize(&f).unwrap_or_else(|err| {
262            eprintln!("Error parsing config file: {err}");
263            std::process::exit(1);
264        });
265        log_info!("Parsing config file: {conf_str}");
266        let Json::Object(obj) = json else {
267            return Err("Expected json object".into());
268        };
269
270        #[cfg(feature = "tls")]
271        let mut tls = false;
272
273        #[cfg(feature = "tls")]
274        let mut cert: Option<String> = None;
275
276        #[cfg(feature = "tls")]
277        let mut privkey: Option<String> = None;
278
279        for (k, v) in obj {
280            macro_rules! num {
281                () => {
282                    num!(v)
283                };
284                ($v:ident) => {
285                    $v.number().ok_or_else(|| {
286                        format!("Parsing config file ({conf_str}): Expected number for \"{k}\"")
287                    })?
288                };
289                ($v:ident as $t:ty) => {{
290                    let _n = num!($v);
291                    _n as $t
292                }};
293            }
294            macro_rules! bool {
295                ($v:ident) => {
296                    $v.boolean().ok_or_else(|| {
297                        format!("Parsing config file ({conf_str}): Expected boolean for \"{k}\"")
298                    })?
299                };
300            }
301            macro_rules! string {
302                ($v:ident) => {
303                    $v.string()
304                        .ok_or_else(|| {
305                            format!("Parsing config file ({conf_str}): Expected string for \"{k}\"")
306                        })?
307                        .to_string()
308                };
309                () => {
310                    string!(v)
311                };
312            }
313            macro_rules! obj {
314                () => {
315                    v.object().ok_or_else(|| {
316                        format!("Parsing config file ({conf_str}): Expected object for \"{k}\"")
317                    })?
318                };
319            }
320
321            macro_rules! path {
322                ($v:ident) => {{
323                    let path: String = string!($v);
324                    path.replacen(
325                        '~',
326                        env::var("HOME").as_ref().map(String::as_str).unwrap_or("~"),
327                        1,
328                    )
329                }};
330
331                () => {
332                    path!(v)
333                };
334            }
335
336            match &*k {
337                "port" => self.port = num!() as u16,
338                "root_dir" => {
339                    let path = path!();
340                    env::set_current_dir(Path::new(&path))?;
341                }
342                "keep_alive_timeout" => self.keep_alive_timeout = Duration::from_secs_f64(num!()),
343                "keep_alive_requests" => self.keep_alive_requests = num!() as u16,
344                "log_file" => self.log_file = Some(string!()),
345                "log_level" => {
346                    let n = num!(v as u8);
347                    log::set_level(n.try_into()?);
348                }
349                "preset" => {
350                    let arg = string!(v);
351                    self.preset = parse_preset(arg.as_str(), false)?;
352                }
353                #[cfg(feature = "tls")]
354                "tls" => {
355                    for (k, v) in obj!() {
356                        match &**k {
357                            "enabled" => tls = bool!(v),
358                            "cert_file" => cert = Some(path!(v)),
359                            "private_key" => privkey = Some(path!(v)),
360                            _ => log_warn!(
361                                "Parsing config file ({conf_str}): Unexpected key: \"{k}\""
362                            ),
363                        }
364                    }
365                }
366                "setup_lib" if self.setup_lib.is_none() => {
367                    self.setup_lib = Some(path!(v));
368                }
369                "pool_config" => {
370                    for (k, v) in obj!() {
371                        match &**k {
372                            "n_workers" => self.pool_conf.n_workers = num!(v as u16),
373                            "pending_buffer_size" => {
374                                let n = v.number().map(|n| n as u16);
375                                self.pool_conf.incoming_buf_size = n;
376                            }
377                            _ => log_warn!(
378                                "Parsing config file ({conf_str}): Unexpected key: \"{k}\""
379                            ),
380                        }
381                    }
382                }
383                _ => log_warn!("Parsing config file ({conf_str}): Unexpected key: \"{k}\""),
384            }
385        }
386
387        #[cfg(feature = "tls")]
388        if tls {
389            self.tls_config = Some(get_tls_config(cert, privkey)?);
390        }
391
392        Ok(())
393    }
394    #[inline]
395    #[must_use]
396    pub fn pool_config(mut self, conf: PoolConfig) -> Self {
397        self.pool_conf = conf;
398        self
399    }
400    #[inline]
401    #[must_use]
402    pub fn port(mut self, port: u16) -> Self {
403        self.port = port;
404        self
405    }
406    #[inline]
407    #[must_use]
408    pub fn keep_alive_timeout(mut self, timeout: Duration) -> Self {
409        self.keep_alive_timeout = timeout;
410        self
411    }
412    #[inline]
413    #[must_use]
414    pub fn keep_alive_requests(mut self, n: u16) -> Self {
415        self.keep_alive_requests = n;
416        self
417    }
418
419    #[inline]
420    #[must_use]
421    pub fn preset(mut self, p: Preset) -> Self {
422        self.preset = p;
423        self
424    }
425}
426
427fn help() -> ! {
428    /* FIXME: Don't output tls options if the tls feature is disabled */
429    println!(
430        "\
431http-srv: Copyright (C) 2025 Saúl Valdelvira
432
433This program is free software: you can redistribute it and/or modify it
434under the terms of the GNU General Public License as published by the
435Free Software Foundation, version 3.
436Use http-srv --license to read a copy of the GPL v3
437
438USAGE: http-srv [-p <port>] [-n <n-workers>] [-d <working-dir>]
439PARAMETERS:
440    -p, --port <port>    TCP Port to listen for requests
441    -n, --n-workers <n>  Number of concurrent workers
442    -d, --dir <working-dir>  Root directory of the server
443    -k, --keep-alive <sec>   Keep alive seconds
444    -r, --keep-alive-requests <num> Keep alive max requests
445    -l, --log <file>   Set log file
446    -h, --help      Display this help message
447    --log-level <n> Set log level
448    --setup-lib <file> Load the given file to setup the server
449    --conf <file>   Use the given config file instead of the default one
450    --license       Output the license of this program
451    --preset <read|readwrite|post>  Sets a default handler preset
452        read: Only GET and HEAD methods are allowed
453        readwrite: GET, HEAD, POST and DELETE are allowed
454        post: Only POST is allowed
455
456    --tls           Enable TLS
457    --cert-file     Certificate file for TLS
458    --private-key   Private key for TLS
459EXAMPLES:
460  http-srv -p 8080 -d /var/html
461  http-srv -d ~/desktop -n 1024 --keep-alive 120
462  http-srv --log /var/log/http-srv.log"
463    );
464    process::exit(0);
465}
466
467fn license() -> ! {
468    println!(include_str!("../COPYING"));
469    process::exit(0);
470}
471
472trait ParseIterator {
473    fn next_parse<T: FromStr>(&mut self) -> Option<T>;
474}
475
476impl<I, R: AsRef<str>> ParseIterator for I
477where
478    I: Iterator<Item = R>,
479{
480    fn next_parse<T: FromStr>(&mut self) -> Option<T> {
481        self.next()?.as_ref().parse().ok()
482    }
483}
484
485impl Default for ServerConfig {
486    /// Default configuration
487    ///
488    /// - Port: 80
489    /// - Preset: Read
490    /// - Nº Workers: 1024
491    /// - Keep Alive Timeout: 0s (Disabled)
492    /// - Keep Alove Requests: 10000
493    #[inline]
494    fn default() -> Self {
495        Self {
496            port: 80,
497            pool_conf: PoolConfig::default(),
498            keep_alive_timeout: Duration::from_secs(0),
499            keep_alive_requests: 10000,
500            log_file: None,
501            setup_lib: None,
502            preset: Preset::Read,
503            #[cfg(feature = "tls")]
504            tls_config: None,
505        }
506    }
507}
508
509#[cfg(test)]
510mod test {
511    #![allow(clippy::unwrap_used)]
512
513    use crate::ServerConfig;
514
515    #[test]
516    fn valid_args() {
517        let conf = vec!["-p".to_string(), "80".to_string()];
518        ServerConfig::parse(&conf).unwrap();
519    }
520
521    macro_rules! expect_err {
522        ($conf:expr , $msg:literal) => {
523            match ServerConfig::parse(&$conf) {
524                Ok(c) => panic!("Didn't panic: {c:#?}"),
525                Err(msg) => assert_eq!(msg.get_message(), $msg),
526            }
527        };
528    }
529
530    #[test]
531    fn unknown() {
532        let conf = vec!["?"];
533        expect_err!(conf, "Unknow argument: ?");
534    }
535
536    #[test]
537    fn missing() {
538        let conf = vec!["-n"];
539        expect_err!(conf, "Missing or incorrect argument for \"-n\"");
540    }
541
542    #[test]
543    fn parse_error() {
544        let conf = vec!["-p", "abc"];
545        expect_err!(conf, "Missing or incorrect argument for \"-p\"");
546    }
547}