letmein_conf/
lib.rs

1// -*- coding: utf-8 -*-
2//
3// Copyright (C) 2024 - 2026 Michael Büsch <m@bues.ch>
4//
5// Licensed under the Apache License version 2.0
6// or the MIT license, at your option.
7// SPDX-License-Identifier: Apache-2.0 OR MIT
8
9//! This crate implements the server and client configuration
10//! file parsing of `letmein`.
11//!
12//! Defaults for missing configuration files
13//! or missing individual configuration entries are implemented here.
14
15#![forbid(unsafe_code)]
16
17mod ini;
18mod parse;
19mod parse_items;
20
21use crate::{
22    parse::{is_number, parse_bool, parse_duration, parse_hex, parse_u16},
23    parse_items::{Map, MapItem},
24};
25use anyhow::{self as ah, format_err as err, Context as _};
26use letmein_proto::{Key, ResourceId, UserId, PORT};
27use sha3::{Digest, Sha3_256};
28use std::{
29    collections::HashMap,
30    path::{Path, PathBuf},
31    time::Duration,
32};
33use subtle::ConstantTimeEq as _;
34
35pub use crate::ini::{Ini, IniSectionIter};
36
37/// The default server configuration path, relative to the install prefix.
38#[cfg(not(target_os = "windows"))]
39const SERVER_CONF_PATH: &str = "etc/letmeind.conf";
40#[cfg(target_os = "windows")]
41const SERVER_CONF_PATH: &str = "letmeind.conf";
42
43/// The default client configuration path, relative to the install prefix.
44#[cfg(not(target_os = "windows"))]
45const CLIENT_CONF_PATH: &str = "etc/letmein.conf";
46#[cfg(target_os = "windows")]
47const CLIENT_CONF_PATH: &str = "letmein.conf";
48
49const DEFAULT_CONTROL_TIMEOUT: Duration = Duration::from_millis(5_000);
50const DEFAULT_NFT_TIMEOUT: Duration = Duration::from_millis(600_000);
51
52const MAX_CHAIN_LEN: usize = 64;
53
54/// Configuration content checksum.
55#[derive(Clone, Debug, Default, Eq)]
56pub struct ConfigChecksum([u8; ConfigChecksum::SIZE]);
57
58impl ConfigChecksum {
59    /// Digest size, in bytes.
60    pub const SIZE: usize = 32;
61
62    /// Calculate the checksum from a raw byte stream.
63    pub fn calculate(content: &[u8]) -> Self {
64        let mut hash = Sha3_256::new();
65        hash.update((content.len() as u64).to_be_bytes());
66        hash.update(content);
67        let digest = hash.finalize();
68        Self((*digest).try_into().expect("Unwrap sha digest"))
69    }
70
71    /// Get the calculated checksum digest.
72    pub fn as_bytes(&self) -> &[u8; ConfigChecksum::SIZE] {
73        &self.0
74    }
75}
76
77impl PartialEq for ConfigChecksum {
78    fn eq(&self, other: &ConfigChecksum) -> bool {
79        // Constant-time compare.
80        self.0.ct_eq(&other.0).into()
81    }
82}
83
84impl TryFrom<&[u8]> for ConfigChecksum {
85    type Error = ah::Error;
86
87    /// Convert a raw checksum digest into `ConfigChecksum`.
88    fn try_from(data: &[u8]) -> ah::Result<Self> {
89        Ok(ConfigChecksum(data.try_into()?))
90    }
91}
92
93/// Configured control port.
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub struct ControlPort {
96    pub port: u16,
97    pub tcp: bool,
98    pub udp: bool,
99}
100
101impl Default for ControlPort {
102    fn default() -> Self {
103        Self {
104            port: PORT,
105            tcp: true,
106            udp: false,
107        }
108    }
109}
110
111impl std::fmt::Display for ControlPort {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
113        write!(f, "{}(", self.port)?;
114        if self.tcp {
115            write!(f, "TCP")?;
116            if self.udp {
117                write!(f, "/")?;
118            }
119        }
120        if self.udp {
121            write!(f, "UDP")?;
122        }
123        write!(f, ")")
124    }
125}
126
127/// Configured resource.
128#[derive(Clone, Debug, PartialEq, Eq)]
129pub enum Resource {
130    /// Port resource.
131    Port {
132        id: ResourceId,
133        port: u16,
134        tcp: bool,
135        udp: bool,
136        timeout: Option<Duration>,
137        users: Vec<UserId>,
138    },
139    Jump {
140        id: ResourceId,
141        input: Option<String>,
142        input_match_saddr: bool,
143        forward: Option<String>,
144        forward_match_saddr: bool,
145        output: Option<String>,
146        output_match_saddr: bool,
147        timeout: Option<Duration>,
148        users: Vec<UserId>,
149    },
150}
151
152impl Resource {
153    pub fn id(&self) -> ResourceId {
154        match self {
155            Self::Port { id, .. } => *id,
156            Self::Jump { id, .. } => *id,
157        }
158    }
159
160    pub fn contains_user(&self, id: UserId) -> bool {
161        let users = match self {
162            Self::Port { users, .. } => users,
163            Self::Jump { users, .. } => users,
164        };
165        if users.is_empty() {
166            // This resource is unrestricted.
167            true
168        } else {
169            users.contains(&id)
170        }
171    }
172
173    pub fn timeout(&self) -> Option<Duration> {
174        match self {
175            Self::Port { timeout, .. } => *timeout,
176            Self::Jump { timeout, .. } => *timeout,
177        }
178    }
179}
180
181impl std::fmt::Display for Resource {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
183        fn write_users(
184            f: &mut std::fmt::Formatter<'_>,
185            users: &[UserId],
186        ) -> Result<(), std::fmt::Error> {
187            if !users.is_empty() {
188                write!(f, "  Users: ")?;
189                for (i, user) in users.iter().enumerate() {
190                    if i != 0 {
191                        write!(f, ", ")?;
192                    }
193                    write!(f, "{user}")?;
194                }
195                writeln!(f)?;
196            }
197            Ok(())
198        }
199
200        fn write_timeout(
201            f: &mut std::fmt::Formatter<'_>,
202            timeout: &Option<Duration>,
203        ) -> Result<(), std::fmt::Error> {
204            if let Some(timeout) = timeout {
205                writeln!(f, "  timeout: {} s", timeout.as_secs())?;
206            }
207            Ok(())
208        }
209
210        fn write_chain(
211            f: &mut std::fmt::Formatter<'_>,
212            chain: &Option<String>,
213            match_saddr: bool,
214            name: &str,
215        ) -> Result<(), std::fmt::Error> {
216            if let Some(chain) = chain {
217                let match_saddr = if match_saddr { " match-saddr" } else { "" };
218                writeln!(f, "  {name}-chain: {chain}{match_saddr}")?;
219            }
220            Ok(())
221        }
222
223        match self {
224            Self::Port {
225                id,
226                port,
227                tcp,
228                udp,
229                timeout,
230                users,
231            } => {
232                let tcpudp = if *tcp && *udp {
233                    "TCP/UDP"
234                } else if *tcp {
235                    "TCP"
236                } else if *udp {
237                    "UDP"
238                } else {
239                    ""
240                };
241                writeln!(f, "Port resource:")?;
242                writeln!(f, "  id: {id}")?;
243                writeln!(f, "  port: {port} {tcpudp}")?;
244                write_timeout(f, timeout)?;
245                write_users(f, users)?;
246            }
247            Self::Jump {
248                id,
249                input,
250                input_match_saddr,
251                forward,
252                forward_match_saddr,
253                output,
254                output_match_saddr,
255                timeout,
256                users,
257            } => {
258                writeln!(f, "Jump resource:")?;
259                writeln!(f, "  id: {id}")?;
260                write_chain(f, input, *input_match_saddr, "input")?;
261                write_chain(f, forward, *forward_match_saddr, "forward")?;
262                write_chain(f, output, *output_match_saddr, "output")?;
263                write_timeout(f, timeout)?;
264                write_users(f, users)?;
265            }
266        }
267        Ok(())
268    }
269}
270
271/// Error reporting policy.
272#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
273pub enum ErrorPolicy {
274    /// Always report errors.
275    #[default]
276    Always,
277
278    /// Only report errors if basic authentication passed.
279    BasicAuth,
280
281    /// Only report errors if full authentication passed.
282    FullAuth,
283}
284
285impl std::fmt::Display for ErrorPolicy {
286    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
287        match self {
288            Self::Always => write!(f, "Always"),
289            Self::BasicAuth => write!(f, "Basic authentication"),
290            Self::FullAuth => write!(f, "Full challenge-response authentication"),
291        }
292    }
293}
294
295impl std::str::FromStr for ErrorPolicy {
296    type Err = ah::Error;
297
298    fn from_str(s: &str) -> Result<Self, Self::Err> {
299        match s.to_lowercase().trim() {
300            "always" => Ok(ErrorPolicy::Always),
301            "basic-auth" => Ok(ErrorPolicy::BasicAuth),
302            "full-auth" => Ok(ErrorPolicy::FullAuth),
303            other => Err(err!(
304                "Config option 'control-error-policy = {other}' is not valid. \
305                Valid values are: always, basic-auth, full-auth."
306            )),
307        }
308    }
309}
310
311/// Seccomp setting.
312#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
313pub enum Seccomp {
314    /// Seccomp is disabled (default).
315    #[default]
316    Off,
317
318    /// Seccomp is enabled with logging only.
319    ///
320    /// The event will be logged, if a syscall is called that is not allowed.
321    /// See the Linux kernel logs for seccomp audit messages.
322    Log,
323
324    /// Seccomp is enabled with killing (recommended).
325    ///
326    /// The process will be killed, if a syscall is called that is not allowed.
327    Kill,
328}
329
330impl std::fmt::Display for Seccomp {
331    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
332        match self {
333            Self::Off => write!(f, "Off"),
334            Self::Log => write!(f, "Logging only"),
335            Self::Kill => write!(f, "Process killing"),
336        }
337    }
338}
339
340impl std::str::FromStr for Seccomp {
341    type Err = ah::Error;
342
343    fn from_str(s: &str) -> Result<Self, Self::Err> {
344        match s.to_lowercase().trim() {
345            "off" => Ok(Self::Off),
346            "log" => Ok(Self::Log),
347            "kill" => Ok(Self::Kill),
348            other => Err(err!(
349                "Config option 'seccomp = {other}' is not valid. \
350                Valid values are: off, log, kill."
351            )),
352        }
353    }
354}
355
356fn get_debug(ini: &Ini) -> ah::Result<bool> {
357    if let Some(debug) = ini.get("GENERAL", "debug") {
358        return parse_bool(debug);
359    }
360    Ok(false)
361}
362
363fn get_port(ini: &Ini) -> ah::Result<ControlPort> {
364    if let Some(port) = ini.get("GENERAL", "port") {
365        let mut control_port = ControlPort {
366            port: PORT,
367            tcp: false,
368            udp: false,
369        };
370        let map = port.parse::<Map>().context("[GENERAL] port")?;
371        for item in map.items() {
372            match item {
373                MapItem::KeyValues(k, _) => {
374                    return Err(err!("[GENERAL] port: Unknown option: {k}"));
375                }
376                MapItem::Values(vs) => {
377                    if vs.len() == 1 && is_number(&vs[0]) {
378                        control_port.port = parse_u16(&vs[0])?;
379                    } else {
380                        for v in vs {
381                            match &v.to_lowercase()[..] {
382                                "tcp" => control_port.tcp = true,
383                                "udp" => control_port.udp = true,
384                                v => {
385                                    return Err(err!("[GENERAL] port: Unknown option: {v}"));
386                                }
387                            }
388                        }
389                    }
390                }
391            }
392        }
393        if !control_port.tcp && !control_port.udp {
394            // Default, if no tcp/udp option is given.
395            control_port.tcp = true;
396        }
397        return Ok(control_port);
398    }
399    Ok(Default::default())
400}
401
402fn get_control_timeout(ini: &Ini) -> ah::Result<Duration> {
403    if let Some(timeout) = ini.get("GENERAL", "control-timeout") {
404        return parse_duration(timeout);
405    }
406    Ok(DEFAULT_CONTROL_TIMEOUT)
407}
408
409fn get_control_error_policy(ini: &Ini) -> ah::Result<ErrorPolicy> {
410    if let Some(policy) = ini.get("GENERAL", "control-error-policy") {
411        return policy.parse();
412    }
413    Ok(Default::default())
414}
415
416fn get_seccomp(ini: &Ini) -> ah::Result<Seccomp> {
417    if let Some(seccomp) = ini.get("GENERAL", "seccomp") {
418        return seccomp.parse();
419    }
420    Ok(Default::default())
421}
422
423fn get_keys(ini: &Ini) -> ah::Result<HashMap<UserId, Key>> {
424    let mut keys = HashMap::new();
425    if let Some(options) = ini.options_iter("KEYS") {
426        for (id, key) in options {
427            let id = id.parse().context("[KEYS]")?;
428            let key = parse_hex(key).context("[KEYS]")?;
429            if key == [0; std::mem::size_of::<Key>()] {
430                return Err(err!("Invalid key {id}: Key is all zeros (00)"));
431            }
432            if key == [0xFF; std::mem::size_of::<Key>()] {
433                return Err(err!("Invalid key {id}: Key is all ones (FF)"));
434            }
435            if keys.contains_key(&id) {
436                return Err(err!("[KEYS] Multiple definitions of key '{id}'"));
437            }
438            keys.insert(id, key);
439        }
440    }
441    Ok(keys)
442}
443
444fn extract_users(id: ResourceId, users: &[String]) -> ah::Result<Vec<UserId>> {
445    let mut ret = Vec::with_capacity(users.len());
446    for user in users {
447        if let Ok(user) = user.parse() {
448            ret.push(user);
449        } else {
450            return Err(err!("[RESOURCE] '{id}': 'user' id is invalid"));
451        }
452    }
453    Ok(ret)
454}
455
456fn extract_resource_port(
457    id: ResourceId,
458    resources: &mut HashMap<ResourceId, Resource>,
459    map: &Map,
460) -> ah::Result<()> {
461    let mut port: Option<u16> = None;
462    let mut timeout: Option<Duration> = None;
463    let mut users: Vec<String> = vec![];
464    let mut tcp = false;
465    let mut udp = false;
466
467    for item in map.items() {
468        match item {
469            MapItem::KeyValues(k, vs) => {
470                if k == "port" {
471                    if vs.len() == 1 {
472                        if port.is_some() {
473                            return Err(err!("[RESOURCE] multiple 'port' values"));
474                        }
475                        port = Some(parse_u16(&vs[0]).context("[RESOURCES] port")?);
476                    } else {
477                        return Err(err!("[RESOURCE] invalid 'port' option"));
478                    }
479                } else if k == "timeout" {
480                    if vs.len() == 1 {
481                        if timeout.is_some() {
482                            return Err(err!("[RESOURCE] multiple 'timeout' values"));
483                        }
484                        timeout = Some(parse_duration(&vs[0]).context("[RESOURCES] timeout")?);
485                    } else {
486                        return Err(err!("[RESOURCE] invalid 'timeout' option"));
487                    }
488                } else if k == "users" {
489                    if !users.is_empty() {
490                        return Err(err!("[RESOURCE] multiple 'users' values"));
491                    }
492                    users = vs.clone();
493                } else {
494                    return Err(err!("[RESOURCE] unknown option: {k}"));
495                }
496            }
497            MapItem::Values(vs) => {
498                for v in vs {
499                    match &v.to_lowercase()[..] {
500                        "tcp" => {
501                            tcp = true;
502                        }
503                        "udp" => {
504                            udp = true;
505                        }
506                        v => {
507                            return Err(err!("[RESOURCE] unknown option: {v}"));
508                        }
509                    }
510                }
511            }
512        }
513    }
514    if !tcp && !udp {
515        // Default, if no tcp/udp option is given.
516        tcp = true;
517    }
518    let Some(port) = port else {
519        return Err(err!("[RESOURCE] '{id}': No 'port' value present"));
520    };
521
522    let users = extract_users(id, &users)?;
523
524    for (_, res) in resources.iter() {
525        match res {
526            Resource::Port { port: res_port, .. } => {
527                if *res_port == port {
528                    return Err(err!(
529                        "[RESOURCE] Multiple definitions of resource port '{port}'"
530                    ));
531                }
532            }
533            Resource::Jump { .. } => (),
534        }
535    }
536
537    resources.insert(
538        id,
539        Resource::Port {
540            id,
541            port,
542            tcp,
543            udp,
544            timeout,
545            users,
546        },
547    );
548    Ok(())
549}
550
551fn extract_resource_jump(
552    id: ResourceId,
553    resources: &mut HashMap<ResourceId, Resource>,
554    map: &Map,
555) -> ah::Result<()> {
556    let mut input: Option<String> = None;
557    let mut input_match_saddr = false;
558    let mut forward: Option<String> = None;
559    let mut forward_match_saddr = false;
560    let mut output: Option<String> = None;
561    let mut output_match_saddr = false;
562    let mut timeout: Option<Duration> = None;
563    let mut users: Vec<String> = vec![];
564
565    for item in map.items() {
566        match item {
567            MapItem::KeyValues(k, vs) => {
568                if k == "jump" {
569                    return Err(err!("[RESOURCE] invalid 'jump' option"));
570                } else if k == "input" {
571                    if vs.len() == 1 {
572                        input = Some(vs[0].trim().to_string());
573                    } else {
574                        return Err(err!("[RESOURCE] invalid 'input' option"));
575                    }
576                } else if k == "input-match" {
577                    if vs.len() == 1 && vs[0].trim() == "saddr" {
578                        input_match_saddr = true;
579                    } else {
580                        return Err(err!("[RESOURCE] invalid 'input-match' option"));
581                    }
582                } else if k == "forward" {
583                    if vs.len() == 1 {
584                        forward = Some(vs[0].trim().to_string());
585                    } else {
586                        return Err(err!("[RESOURCE] invalid 'forward' option"));
587                    }
588                } else if k == "forward-match" {
589                    if vs.len() == 1 && vs[0].trim() == "saddr" {
590                        forward_match_saddr = true;
591                    } else {
592                        return Err(err!("[RESOURCE] invalid 'forward-match' option"));
593                    }
594                } else if k == "output" {
595                    if vs.len() == 1 {
596                        output = Some(vs[0].trim().to_string());
597                    } else {
598                        return Err(err!("[RESOURCE] invalid 'output' option"));
599                    }
600                } else if k == "output-match" {
601                    if vs.len() == 1 && vs[0].trim() == "saddr" {
602                        output_match_saddr = true;
603                    } else {
604                        return Err(err!("[RESOURCE] invalid 'output-match' option"));
605                    }
606                } else if k == "timeout" {
607                    if vs.len() == 1 {
608                        if timeout.is_some() {
609                            return Err(err!("[RESOURCE] multiple 'timeout' values"));
610                        }
611                        timeout = Some(parse_duration(&vs[0]).context("[RESOURCES] timeout")?);
612                    } else {
613                        return Err(err!("[RESOURCE] invalid 'timeout' option"));
614                    }
615                } else if k == "users" {
616                    if !users.is_empty() {
617                        return Err(err!("[RESOURCE] multiple 'users' values"));
618                    }
619                    users = vs.clone();
620                } else {
621                    return Err(err!("[RESOURCE] unknown option: {k}"));
622                }
623            }
624            MapItem::Values(vs) => {
625                if vs.len() == 1 && vs[0].trim() == "jump" {
626                    // jump resource.
627                } else {
628                    return Err(err!("[RESOURCE] unknown values: {vs:?}"));
629                }
630            }
631        }
632    }
633
634    if input.is_none() && forward.is_none() && output.is_none() {
635        return Err(err!(
636            "[RESOURCE] '{id}': 'jump' resource has no 'input', 'forward' or 'output' target."
637        ));
638    }
639
640    let users = extract_users(id, &users)?;
641
642    resources.insert(
643        id,
644        Resource::Jump {
645            id,
646            input,
647            input_match_saddr,
648            forward,
649            forward_match_saddr,
650            output,
651            output_match_saddr,
652            timeout,
653            users,
654        },
655    );
656    Ok(())
657}
658
659fn get_resources(ini: &Ini) -> ah::Result<HashMap<ResourceId, Resource>> {
660    let mut resources = HashMap::new();
661    if let Some(options) = ini.options_iter("RESOURCES") {
662        for (id, resource) in options {
663            let id: ResourceId = id.parse().context("[RESOURCES]")?;
664
665            for res_id in resources.keys() {
666                if *res_id == id {
667                    return Err(err!(
668                        "[RESOURCE] Multiple definitions of resource ID '{id}'"
669                    ));
670                }
671            }
672
673            let map = resource.parse::<Map>().context("[RESOURCES]")?;
674            let mut is_port = false;
675            let mut is_jump = false;
676
677            for item in map.items() {
678                match item.key() {
679                    Some("port") => is_port = true,
680                    Some("jump") => is_jump = true,
681                    _ => (),
682                }
683            }
684
685            if is_port && !is_jump {
686                extract_resource_port(id, &mut resources, &map)?;
687            } else if !is_port && is_jump {
688                extract_resource_jump(id, &mut resources, &map)?;
689            } else {
690                return Err(err!(
691                    "[RESOURCE] Resource ID '{id}' is not a 'port' resource."
692                ));
693            };
694        }
695    }
696    Ok(resources)
697}
698
699fn get_default_user(ini: &Ini) -> ah::Result<UserId> {
700    if let Some(default_user) = ini.get("CLIENT", "default-user") {
701        return default_user.parse();
702    }
703    Ok(Default::default())
704}
705
706fn get_nft_exe(ini: &Ini) -> ah::Result<PathBuf> {
707    if let Some(nft_exe) = ini.get("NFTABLES", "exe") {
708        return Ok(nft_exe.trim().into());
709    }
710    Ok("nft".into())
711}
712
713fn get_nft_family(ini: &Ini) -> ah::Result<String> {
714    if let Some(nft_family) = ini.get("NFTABLES", "family") {
715        let nft_family = nft_family.trim();
716        Ok(match nft_family {
717            "inet" | "ip" | "ip6" => nft_family,
718            nft_family => {
719                return Err(err!("[NFTABLES] family={nft_family} is invalid"));
720            }
721        }
722        .to_string())
723    } else {
724        Ok("".to_string())
725    }
726}
727
728fn get_nft_table(ini: &Ini) -> ah::Result<String> {
729    if let Some(nft_table) = ini.get("NFTABLES", "table") {
730        Ok(nft_table.trim().to_string())
731    } else {
732        Ok("".to_string())
733    }
734}
735
736fn get_nft_chain(ini: &Ini, field: &str) -> ah::Result<String> {
737    if let Some(chain) = ini.get("NFTABLES", field) {
738        let chain = chain.trim().to_string();
739        if chain.len() > MAX_CHAIN_LEN {
740            Err(err!(
741                "[NFTABLES] {} is {} bytes long. \
742                Which exceeds the maximum of {} bytes. \
743                Please choose a smaller chain name.",
744                field,
745                chain.len(),
746                MAX_CHAIN_LEN
747            ))
748        } else {
749            Ok(chain)
750        }
751    } else {
752        Ok("".to_string())
753    }
754}
755
756fn get_nft_chain_input(ini: &Ini) -> ah::Result<String> {
757    get_nft_chain(ini, "chain-input")
758}
759
760fn get_nft_chain_forward(ini: &Ini) -> ah::Result<String> {
761    get_nft_chain(ini, "chain-forward")
762}
763
764fn get_nft_chain_output(ini: &Ini) -> ah::Result<String> {
765    get_nft_chain(ini, "chain-output")
766}
767
768fn get_nft_timeout(ini: &Ini) -> ah::Result<Duration> {
769    if let Some(nft_timeout) = ini.get("NFTABLES", "timeout") {
770        parse_duration(nft_timeout)
771    } else {
772        Ok(DEFAULT_NFT_TIMEOUT)
773    }
774}
775
776/// Configuration variant.
777#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
778pub enum ConfigVariant {
779    /// Parse the configuration as a server configuration (letmeind.conf).
780    #[default]
781    Server,
782    /// Parse the configuration as a client configuration (letmein.conf).
783    Client,
784}
785
786/// Parsed letmein.conf or letmeind.conf. (See [ConfigVariant]).
787#[derive(Clone, Default, Debug)]
788pub struct Config {
789    checksum: ConfigChecksum,
790    variant: ConfigVariant,
791    path: Option<PathBuf>,
792    debug: bool,
793    port: ControlPort,
794    control_timeout: Duration,
795    control_error_policy: ErrorPolicy,
796    seccomp: Seccomp,
797    keys: HashMap<UserId, Key>,
798    resources: HashMap<ResourceId, Resource>,
799    default_user: UserId,
800    nft_exe: PathBuf,
801    nft_family: String,
802    nft_table: String,
803    nft_chain_input: String,
804    nft_chain_forward: String,
805    nft_chain_output: String,
806    nft_timeout: Duration,
807}
808
809impl Config {
810    /// Create a new configuration instance with all-default values.
811    pub fn new(variant: ConfigVariant) -> Self {
812        Self {
813            checksum: Default::default(),
814            variant,
815            control_timeout: DEFAULT_CONTROL_TIMEOUT,
816            nft_timeout: DEFAULT_NFT_TIMEOUT,
817            ..Default::default()
818        }
819    }
820
821    /// Get the default configuration file path.
822    pub fn get_default_path(variant: ConfigVariant) -> PathBuf {
823        // The build-time environment variable LETMEIN_CONF_PREFIX can be
824        // used to give an additional prefix.
825        let prefix = match option_env!("LETMEIN_CONF_PREFIX") {
826            Some(env_prefix) => env_prefix,
827            None => {
828                #[cfg(not(target_os = "windows"))]
829                let prefix = "/";
830                #[cfg(target_os = "windows")]
831                let prefix = "";
832                prefix
833            }
834        };
835
836        let mut path = PathBuf::new();
837        path.push(prefix);
838        match variant {
839            ConfigVariant::Client => {
840                path.push(CLIENT_CONF_PATH);
841            }
842            ConfigVariant::Server => {
843                path.push(SERVER_CONF_PATH);
844            }
845        }
846        path
847    }
848
849    /// Get the actual path the configuration was read from.
850    pub fn get_path(&self) -> Option<&Path> {
851        self.path.as_deref()
852    }
853
854    /// (Re-)load a configuration from a file.
855    pub fn load(&mut self, path: &Path) -> ah::Result<()> {
856        if let Ok(ini) = Ini::new_from_file(path) {
857            self.load_ini(&ini)?;
858        } else if self.variant == ConfigVariant::Server {
859            return Err(err!("Failed to load configuration {path:?}"));
860        }
861        self.path = Some(path.to_path_buf());
862        Ok(())
863    }
864
865    /// (Re-)load a configuration from a parsed [Ini] instance.
866    pub fn load_ini(&mut self, ini: &Ini) -> ah::Result<()> {
867        let mut default_user = Default::default();
868        let mut nft_exe = Default::default();
869        let mut nft_family = Default::default();
870        let mut nft_table = Default::default();
871        let mut nft_chain_input = Default::default();
872        let mut nft_chain_forward = Default::default();
873        let mut nft_chain_output = Default::default();
874        let mut nft_timeout = DEFAULT_NFT_TIMEOUT;
875
876        let debug = get_debug(ini)?;
877        let port = get_port(ini)?;
878        let control_timeout = get_control_timeout(ini)?;
879        let control_error_policy = get_control_error_policy(ini)?;
880        let seccomp = get_seccomp(ini)?;
881        let keys = get_keys(ini)?;
882        let resources = get_resources(ini)?;
883        if self.variant == ConfigVariant::Client {
884            default_user = get_default_user(ini)?;
885        }
886        if self.variant == ConfigVariant::Server {
887            nft_exe = get_nft_exe(ini)?;
888            nft_family = get_nft_family(ini)?;
889            nft_table = get_nft_table(ini)?;
890            nft_chain_input = get_nft_chain_input(ini)?;
891            nft_chain_forward = get_nft_chain_forward(ini)?;
892            nft_chain_output = get_nft_chain_output(ini)?;
893            nft_timeout = get_nft_timeout(ini)?;
894        }
895
896        self.checksum = ini.checksum().clone();
897        self.debug = debug;
898        self.port = port;
899        self.control_timeout = control_timeout;
900        self.control_error_policy = control_error_policy;
901        self.seccomp = seccomp;
902        self.keys = keys;
903        self.resources = resources;
904        self.default_user = default_user;
905        self.nft_exe = nft_exe;
906        self.nft_family = nft_family;
907        self.nft_table = nft_table;
908        self.nft_chain_input = nft_chain_input;
909        self.nft_chain_forward = nft_chain_forward;
910        self.nft_chain_output = nft_chain_output;
911        self.nft_timeout = nft_timeout;
912        Ok(())
913    }
914
915    /// Calculate a checksum that represents the content.
916    pub fn checksum(&self) -> &ConfigChecksum {
917        &self.checksum
918    }
919
920    /// Get the `debug` option from `[GENERAL]` section.
921    pub fn debug(&self) -> bool {
922        self.debug
923    }
924
925    /// Get the `port` option from `[GENERAL]` section.
926    pub fn port(&self) -> ControlPort {
927        self.port
928    }
929
930    /// Get the `control-timeout` option from `[GENERAL]` section.
931    pub fn control_timeout(&self) -> Duration {
932        self.control_timeout
933    }
934
935    /// Get the `control-error-policy` option from `[GENERAL]` section.
936    pub fn control_error_policy(&self) -> ErrorPolicy {
937        self.control_error_policy
938    }
939
940    /// Get the `seccomp` option from `[GENERAL]` section.
941    pub fn seccomp(&self) -> Seccomp {
942        self.seccomp
943    }
944
945    /// Get a list of all configured users.
946    pub fn users(&self) -> Vec<UserId> {
947        let mut users: Vec<UserId> = self.keys.keys().cloned().collect();
948        users.sort();
949        users
950    }
951
952    /// Get a key value by key identifier from the `[KEYS]` section.
953    pub fn key(&self, id: UserId) -> Option<&Key> {
954        self.keys.get(&id)
955    }
956
957    /// Get a list of all configured resources.
958    pub fn resources(&self) -> Vec<Resource> {
959        let mut resources: Vec<Resource> = self.resources.values().cloned().collect();
960        resources.sort_by_key(|r| r.id());
961        resources
962    }
963
964    /// Get a resource value by resource identifier from the `[RESOURCES]` section.
965    pub fn resource(&self, id: ResourceId) -> Option<&Resource> {
966        self.resources.get(&id)
967    }
968
969    /// Lookup a resource id by a port number in the `[RESOURCES]` section.
970    pub fn resource_id_by_port(&self, port: u16, user_id: Option<UserId>) -> Option<ResourceId> {
971        for (k, v) in &self.resources {
972            match v {
973                Resource::Port { port: p, .. } => {
974                    if *p == port {
975                        if let Some(user_id) = user_id {
976                            if v.contains_user(user_id) {
977                                return Some(*k);
978                            }
979                        } else {
980                            return Some(*k);
981                        }
982                    }
983                }
984                Resource::Jump { .. } => (),
985            }
986        }
987        None
988    }
989
990    /// Get the `default-user` option from `[CLIENT]` section.
991    pub fn default_user(&self) -> UserId {
992        self.default_user
993    }
994
995    /// Get the `exe` option from `[NFTABLES]` section.
996    pub fn nft_exe(&self) -> &Path {
997        &self.nft_exe
998    }
999
1000    /// Get the `family` option from `[NFTABLES]` section.
1001    pub fn nft_family(&self) -> &str {
1002        &self.nft_family
1003    }
1004
1005    /// Get the `table` option from `[NFTABLES]` section.
1006    pub fn nft_table(&self) -> &str {
1007        &self.nft_table
1008    }
1009
1010    /// Get the `chain-input` option from `[NFTABLES]` section.
1011    pub fn nft_chain_input(&self) -> &str {
1012        &self.nft_chain_input
1013    }
1014
1015    /// Get the `chain-forward` option from `[NFTABLES]` section.
1016    pub fn nft_chain_forward(&self) -> &str {
1017        &self.nft_chain_forward
1018    }
1019
1020    /// Get the `chain-output` option from `[NFTABLES]` section.
1021    pub fn nft_chain_output(&self) -> &str {
1022        &self.nft_chain_output
1023    }
1024
1025    /// Get the `timeout` option from `[NFTABLES]` section.
1026    pub fn nft_timeout(&self) -> Duration {
1027        self.nft_timeout
1028    }
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033    use super::*;
1034
1035    #[test]
1036    fn test_general() {
1037        let mut ini = Ini::new();
1038        let cs_empty = ini.checksum().clone();
1039        ini.parse_str(
1040            "[GENERAL]\ndebug = true\nport = 1234\ncontrol-timeout=1.5\n\
1041            control-error-policy= basic-auth \nseccomp = kill",
1042        )
1043        .unwrap();
1044        let cs_parsed = ini.checksum().clone();
1045        assert_ne!(cs_empty, cs_parsed);
1046        assert!(get_debug(&ini).unwrap());
1047        assert_eq!(
1048            get_port(&ini).unwrap(),
1049            ControlPort {
1050                port: 1234,
1051                tcp: true,
1052                udp: false
1053            }
1054        );
1055        assert_eq!(
1056            get_control_timeout(&ini).unwrap(),
1057            Duration::from_millis(1500)
1058        );
1059        assert_eq!(
1060            get_control_error_policy(&ini).unwrap(),
1061            ErrorPolicy::BasicAuth
1062        );
1063        assert_eq!(get_seccomp(&ini).unwrap(), Seccomp::Kill);
1064    }
1065
1066    #[test]
1067    fn test_port() {
1068        let mut ini = Ini::new();
1069        ini.parse_str("[GENERAL]\nport=1234").unwrap();
1070        let cs_a = ini.checksum().clone();
1071        assert_eq!(
1072            get_port(&ini).unwrap(),
1073            ControlPort {
1074                port: 1234,
1075                tcp: true,
1076                udp: false
1077            }
1078        );
1079        ini.parse_str("[GENERAL]\nport=1234 / TCP ").unwrap();
1080        let cs_b = ini.checksum().clone();
1081        assert_eq!(
1082            get_port(&ini).unwrap(),
1083            ControlPort {
1084                port: 1234,
1085                tcp: true,
1086                udp: false
1087            }
1088        );
1089        ini.parse_str("[GENERAL]\nport=1234/UDP").unwrap();
1090        let cs_c = ini.checksum().clone();
1091        assert_eq!(
1092            get_port(&ini).unwrap(),
1093            ControlPort {
1094                port: 1234,
1095                tcp: false,
1096                udp: true
1097            }
1098        );
1099        ini.parse_str("[GENERAL]\nport=1234/ UDP , TCP").unwrap();
1100        assert_eq!(
1101            get_port(&ini).unwrap(),
1102            ControlPort {
1103                port: 1234,
1104                tcp: true,
1105                udp: true
1106            }
1107        );
1108        ini.parse_str("[GENERAL]\nport=udp, tcp / 1234").unwrap();
1109        assert_eq!(
1110            get_port(&ini).unwrap(),
1111            ControlPort {
1112                port: 1234,
1113                tcp: true,
1114                udp: true
1115            }
1116        );
1117        assert_ne!(cs_a, cs_b);
1118        assert_ne!(cs_b, cs_c);
1119    }
1120
1121    #[test]
1122    fn test_keys() {
1123        let mut ini = Ini::new();
1124        ini.parse_str(
1125            "[KEYS]\nABCD1234 = 998877665544332211009988776655443322110099887766554433221100CDEF\n",
1126        )
1127        .unwrap();
1128        let keys = get_keys(&ini).unwrap();
1129        assert_eq!(
1130            keys.get(&0xABCD1234.into()).unwrap(),
1131            &[
1132                0x99, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, 0x00, 0x99, 0x88, 0x77, 0x66,
1133                0x55, 0x44, 0x33, 0x22, 0x11, 0x00, 0x99, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22,
1134                0x11, 0x00, 0xCD, 0xEF
1135            ]
1136        );
1137    }
1138
1139    #[test]
1140    fn test_resources_port() {
1141        let mut ini = Ini::new();
1142        ini.parse_str("[RESOURCES]\n9876ABCD = port : 4096\n")
1143            .unwrap();
1144        let resources = get_resources(&ini).unwrap();
1145        assert_eq!(
1146            resources.get(&0x9876ABCD.into()).unwrap(),
1147            &Resource::Port {
1148                id: 0x9876ABCD.into(),
1149                port: 4096,
1150                tcp: true,
1151                udp: false,
1152                timeout: None,
1153                users: vec![]
1154            }
1155        );
1156
1157        let mut ini = Ini::new();
1158        ini.parse_str("[RESOURCES]\n9876ABCD = port : 4096 / TCP\n")
1159            .unwrap();
1160        let resources = get_resources(&ini).unwrap();
1161        assert_eq!(
1162            resources.get(&0x9876ABCD.into()).unwrap(),
1163            &Resource::Port {
1164                id: 0x9876ABCD.into(),
1165                port: 4096,
1166                tcp: true,
1167                udp: false,
1168                timeout: None,
1169                users: vec![]
1170            }
1171        );
1172
1173        let mut ini = Ini::new();
1174        ini.parse_str("[RESOURCES]\n9876ABCD = port : 4096 / udp / users: 1, 2 ,3  \n")
1175            .unwrap();
1176        let resources = get_resources(&ini).unwrap();
1177        assert_eq!(
1178            resources.get(&0x9876ABCD.into()).unwrap(),
1179            &Resource::Port {
1180                id: 0x9876ABCD.into(),
1181                port: 4096,
1182                tcp: false,
1183                udp: true,
1184                timeout: None,
1185                users: vec![1.into(), 2.into(), 3.into()]
1186            }
1187        );
1188
1189        let mut ini = Ini::new();
1190        ini.parse_str("[RESOURCES]\n9876ABCD = port : 4096 / udp, tcp / users: 4 / timeout:42\n")
1191            .unwrap();
1192        let resources = get_resources(&ini).unwrap();
1193        assert_eq!(
1194            resources.get(&0x9876ABCD.into()).unwrap(),
1195            &Resource::Port {
1196                id: 0x9876ABCD.into(),
1197                port: 4096,
1198                tcp: true,
1199                udp: true,
1200                timeout: Some(Duration::from_secs(42)),
1201                users: vec![4.into()]
1202            }
1203        );
1204    }
1205
1206    #[test]
1207    fn test_resources_jump() {
1208        let mut ini = Ini::new();
1209        ini.parse_str("[RESOURCES]\n1234FEDC = jump / input: FOO\n")
1210            .unwrap();
1211        let resources = get_resources(&ini).unwrap();
1212        assert_eq!(
1213            resources.get(&0x1234FEDC.into()).unwrap(),
1214            &Resource::Jump {
1215                id: 0x1234FEDC.into(),
1216                input: Some("FOO".to_string()),
1217                input_match_saddr: false,
1218                forward: None,
1219                forward_match_saddr: false,
1220                output: None,
1221                output_match_saddr: false,
1222                timeout: None,
1223                users: vec![]
1224            }
1225        );
1226
1227        let mut ini = Ini::new();
1228        ini.parse_str(
1229            "[RESOURCES]\n1234FEDC = jump / input: FOO / forward:BAR / input-match: saddr\n",
1230        )
1231        .unwrap();
1232        let resources = get_resources(&ini).unwrap();
1233        assert_eq!(
1234            resources.get(&0x1234FEDC.into()).unwrap(),
1235            &Resource::Jump {
1236                id: 0x1234FEDC.into(),
1237                input: Some("FOO".to_string()),
1238                input_match_saddr: true,
1239                forward: Some("BAR".to_string()),
1240                forward_match_saddr: false,
1241                output: None,
1242                output_match_saddr: false,
1243                timeout: None,
1244                users: vec![]
1245            }
1246        );
1247
1248        let mut ini = Ini::new();
1249        ini.parse_str("[RESOURCES]\n1234FEDC = jump / input: FOO / forward:BAR / output: BIZ / users: 1, 10, 100 / output-match: saddr\n")
1250            .unwrap();
1251        let resources = get_resources(&ini).unwrap();
1252        assert_eq!(
1253            resources.get(&0x1234FEDC.into()).unwrap(),
1254            &Resource::Jump {
1255                id: 0x1234FEDC.into(),
1256                input: Some("FOO".to_string()),
1257                input_match_saddr: false,
1258                forward: Some("BAR".to_string()),
1259                forward_match_saddr: false,
1260                output: Some("BIZ".to_string()),
1261                output_match_saddr: true,
1262                timeout: None,
1263                users: vec![0x1.into(), 0x10.into(), 0x100.into()]
1264            }
1265        );
1266
1267        let mut ini = Ini::new();
1268        ini.parse_str(
1269            "[RESOURCES]\n1234FEDC = jump / forward:BAR / forward-match: saddr / timeout: 3\n",
1270        )
1271        .unwrap();
1272        let resources = get_resources(&ini).unwrap();
1273        assert_eq!(
1274            resources.get(&0x1234FEDC.into()).unwrap(),
1275            &Resource::Jump {
1276                id: 0x1234FEDC.into(),
1277                input: None,
1278                input_match_saddr: false,
1279                forward: Some("BAR".to_string()),
1280                forward_match_saddr: true,
1281                output: None,
1282                output_match_saddr: false,
1283                timeout: Some(Duration::from_secs(3)),
1284                users: vec![]
1285            }
1286        );
1287    }
1288
1289    #[test]
1290    fn test_client() {
1291        let mut ini = Ini::new();
1292        ini.parse_str("[CLIENT]\ndefault-user = 123\n").unwrap();
1293        let default_user = get_default_user(&ini).unwrap();
1294        assert_eq!(default_user, 0x123.into());
1295    }
1296
1297    #[test]
1298    fn test_nft() {
1299        let mut ini = Ini::new();
1300        ini.parse_str(
1301            "[NFTABLES]\nexe = mynft \nfamily = ip6\ntable = myfilter\nchain-input = myLETMEIN-INPUT\ntimeout = 50\n",
1302        )
1303        .unwrap();
1304        let nft_exe = get_nft_exe(&ini).unwrap();
1305        let nft_family = get_nft_family(&ini).unwrap();
1306        let nft_table = get_nft_table(&ini).unwrap();
1307        let nft_chain_input = get_nft_chain_input(&ini).unwrap();
1308        let nft_timeout = get_nft_timeout(&ini).unwrap();
1309        assert_eq!(nft_exe, Path::new("mynft"));
1310        assert_eq!(nft_family, "ip6");
1311        assert_eq!(nft_table, "myfilter");
1312        assert_eq!(nft_chain_input, "myLETMEIN-INPUT");
1313        assert_eq!(nft_timeout, Duration::from_secs(50));
1314    }
1315
1316    #[test]
1317    fn test_checksum() {
1318        let checksum = ConfigChecksum::calculate(b"foo");
1319        assert_eq!(
1320            checksum.as_bytes(),
1321            &[
1322                169, 20, 32, 235, 39, 155, 209, 150, 21, 4, 157, 0, 214, 7, 7, 53, 175, 241, 233,
1323                40, 193, 191, 156, 101, 63, 41, 34, 51, 17, 221, 76, 170
1324            ]
1325        );
1326    }
1327}
1328
1329// vim: ts=4 sw=4 expandtab