Skip to main content

erbium/
acl.rs

1/*   Copyright 2024 Perry Lorier
2 *
3 *  Licensed under the Apache License, Version 2.0 (the "License");
4 *  you may not use this file except in compliance with the License.
5 *  You may obtain a copy of the License at
6 *
7 *      http://www.apache.org/licenses/LICENSE-2.0
8 *
9 *  Unless required by applicable law or agreed to in writing, software
10 *  distributed under the License is distributed on an "AS IS" BASIS,
11 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 *  See the License for the specific language governing permissions and
13 *  limitations under the License.
14 *
15 *  SPDX-License-Identifier: Apache-2.0
16 *
17 *  ACL configuration
18 *
19 *  ACL checks undergo two phases, the first phase checks if you are "authenticated", which means
20 *  "does this client match _any_ of the rules." If you do not match _any_ acl rules you are denied
21 *  with a NotAuthenticated error.
22 *
23 *  The second phase is checking if the _first_ rule you matched contains the permission the client
24 *  is being checked against.  If the client does not have this permission then they get a
25 *  NotAuthorised error, if they do have this permission then they are permitted.
26 */
27
28use crate::config::*;
29use erbium_net::addr::{NetAddr, NetAddrExt as _, UNSPECIFIED6, WithPort as _};
30use yaml_rust::yaml;
31
32#[derive(Debug)]
33pub struct Permission {
34    pub allow_dns_recursion: bool,
35    pub allow_http: bool,
36    pub allow_http_metrics: bool,
37    pub allow_http_leases: bool,
38}
39
40pub struct Attributes {
41    pub addr: NetAddr,
42}
43
44impl std::fmt::Display for Attributes {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        write!(f, "{}", self.addr)
47    }
48}
49
50// We implement Default so that when people define an Attributes, they can ..default() all the
51// parameters they don't care about.
52impl Default for Attributes {
53    fn default() -> Self {
54        Self {
55            addr: UNSPECIFIED6.with_port(0),
56        }
57    }
58}
59
60#[derive(Debug)]
61pub struct Acl {
62    pub subnet: Option<Vec<Prefix>>,
63    pub unix: Option<bool>,
64    pub permission: Permission,
65}
66
67fn check_subnet(attr: &Attributes, prefix: &Prefix) -> bool {
68    match attr.addr.ip() {
69        Some(sockaddr) => prefix.contains(sockaddr),
70        _ => false,
71    }
72}
73
74impl Acl {
75    fn check(&self, attr: &Attributes) -> Option<&'_ Permission> {
76        let mut ok = true;
77        /* Check that the addr is contained within any of the subnets */
78        ok = ok
79            && self
80                .subnet
81                .as_ref()
82                .map(|ss| ss.iter().any(|s| check_subnet(attr, s)))
83                .unwrap_or(true);
84        /* Check that the addr is unix */
85        if let Some(unix) = self.unix {
86            ok = ok && attr.addr.as_unix_addr().is_some() == unix;
87        }
88
89        if ok { Some(&self.permission) } else { None }
90    }
91}
92
93pub enum PermissionType {
94    DnsRecursion,
95    Http,
96    HttpLeases,
97    HttpMetrics,
98}
99
100impl std::fmt::Display for PermissionType {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        use PermissionType::*;
103        match self {
104            DnsRecursion => write!(f, "DNS Recursion"),
105            Http => write!(f, "HTTP"),
106            HttpLeases => write!(f, "HTTP Leases"),
107            HttpMetrics => write!(f, "HTTP Metrics"),
108        }
109    }
110}
111
112#[cfg_attr(test, derive(Debug))]
113#[derive(Eq, PartialEq)]
114pub enum AclError {
115    NotAuthenticated,
116    NotAuthorised(String),
117}
118
119impl std::fmt::Display for AclError {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        use AclError::*;
122        match self {
123            NotAuthenticated => write!(f, "Failed to match any ACLs"),
124            NotAuthorised(perm) => write!(f, "Matched ACL does not have permission {}", perm),
125        }
126    }
127}
128
129/* This finds the first permission that matches, if no permissions match it returns
130 * AclError::NotAuthenticated.
131 */
132fn check_authenticated<'v>(acl: &'v [Acl], attr: &Attributes) -> Result<&'v Permission, AclError> {
133    acl.iter()
134        .find_map(|a| a.check(attr))
135        .ok_or(AclError::NotAuthenticated)
136}
137
138/* This checks that the client matches an ACL ("NotAuthenticated"), and that the ACL has the
139 * required permission ("NotAuthorised").
140 */
141pub fn require_permission(
142    acl: &[Acl],
143    client: &Attributes,
144    perm: PermissionType,
145) -> Result<(), AclError> {
146    fn check_permission(perm: bool, name: &str) -> Result<(), AclError> {
147        if perm {
148            Ok(())
149        } else {
150            Err(AclError::NotAuthorised(name.into()))
151        }
152    }
153    use PermissionType::*;
154    match (check_authenticated(acl, client), perm) {
155        (Ok(perms), DnsRecursion) => check_permission(perms.allow_dns_recursion, "dns-recursion"),
156        (Ok(perms), Http) => check_permission(perms.allow_http, "http"),
157        (Ok(perms), HttpLeases) => check_permission(perms.allow_http_leases, "http-leases"),
158        (Ok(perms), HttpMetrics) => check_permission(perms.allow_http_metrics, "http-metrics"),
159        (Err(err), perm) => {
160            log::warn!("{}: {}: {}", client, perm, err);
161            Err(err)
162        }
163    }
164}
165
166pub fn default_acls(addresses: &[Prefix]) -> Vec<Acl> {
167    vec![
168        Acl {
169            /* Any address we hand out by DHCP we should also accept DNS requests from */
170            subnet: Some(addresses.to_vec()),
171            unix: None,
172            permission: Permission {
173                allow_dns_recursion: true,
174                allow_http_leases: true,
175                allow_http_metrics: true,
176                allow_http: true,
177            },
178        },
179        Acl {
180            /* Any v4/v6 localhost should also be able to accept DNS requests. */
181            subnet: Some(vec![
182                Prefix::V4(Prefix4 {
183                    addr: "127.0.0.0".parse().unwrap(),
184                    prefixlen: 8,
185                }),
186                Prefix::V6(Prefix6 {
187                    addr: "::1".parse().unwrap(),
188                    prefixlen: 128,
189                }),
190            ]),
191            unix: None,
192            permission: Permission {
193                allow_dns_recursion: true,
194                allow_http_leases: true,
195                allow_http_metrics: true,
196                allow_http: true,
197            },
198        },
199        Acl {
200            /* Allow API access over the unix domain socket */
201            subnet: None,
202            unix: Some(true),
203            permission: Permission {
204                allow_dns_recursion: false,
205                allow_http_leases: true,
206                allow_http_metrics: true,
207                allow_http: true,
208            },
209        },
210    ]
211}
212
213pub(crate) fn parse_acl(name: &str, fragment: &yaml::Yaml) -> Result<Option<Acl>, Error> {
214    match fragment {
215        yaml::Yaml::Hash(h) => {
216            let mut subnet = None;
217            let mut unix = None;
218            let mut accesses = vec![];
219            for (k, v) in h {
220                match (k.as_str(), v) {
221                    (Some("match-subnets"), s) => {
222                        subnet = parse_array("match-subnets", s, parse_string_prefix)?;
223                    }
224                    (Some("match-unix"), s) => {
225                        unix = parse_boolean("match-unix", s)?;
226                    }
227                    (Some("apply-access"), a) => {
228                        accesses =
229                            parse_array("apply-access", a, parse_string)?.ok_or_else(|| {
230                                Error::InvalidConfig("apply-access cannot be null".into())
231                            })?;
232                    }
233                    (Some(m), _) => {
234                        return Err(Error::InvalidConfig(format!("Unknown {} key {}", name, m)));
235                    }
236                    (None, _) => {
237                        return Err(Error::InvalidConfig(format!(
238                            "{} keys are expected to be strings",
239                            name
240                        )));
241                    }
242                }
243            }
244            let mut allow_dns_recursion = false;
245            let mut allow_http = false;
246            let mut allow_http_metrics = false;
247            let mut allow_http_leases = false;
248            for access in accesses {
249                match access.as_str() {
250                    "dhcp-client" => {
251                        allow_dns_recursion = true;
252                    }
253                    "dns-recursion" => allow_dns_recursion = true,
254                    "http" => allow_http = true,
255                    "http-metrics" => allow_http_metrics = true,
256                    "http-leases" => allow_http_leases = true,
257                    "http-ro" => {
258                        allow_http = true;
259                        allow_http_metrics = true;
260                        allow_http_leases = true;
261                    }
262                    e => return Err(Error::InvalidConfig(format!("Unknown access {}", e))),
263                }
264            }
265            Ok(Some(Acl {
266                subnet,
267                unix,
268                permission: Permission {
269                    allow_dns_recursion,
270                    allow_http,
271                    allow_http_metrics,
272                    allow_http_leases,
273                },
274            }))
275        }
276        e => Err(Error::InvalidConfig(format!(
277            "Expected hash for {}, got {}",
278            name,
279            type_to_name(e)
280        ))),
281    }
282}
283
284#[test]
285fn acl_not_authenticated() {
286    use erbium_net::addr::{Ipv4Addr, ToNetAddr as _, WithPort as _};
287    let test_acls = vec![Acl {
288        subnet: Some(vec![Prefix::V4(Prefix4 {
289            addr: "192.0.2.0".parse().unwrap(),
290            prefixlen: 24,
291        })]),
292        unix: None,
293        permission: Permission {
294            allow_dns_recursion: true,
295            allow_http: false,
296            allow_http_leases: false,
297            allow_http_metrics: false,
298        },
299    }];
300
301    let ip = "192.168.0.1".parse::<Ipv4Addr>().unwrap().with_port(0);
302
303    let client = Attributes {
304        addr: ip.to_net_addr(),
305    };
306
307    assert_eq!(
308        require_permission(&test_acls, &client, PermissionType::DnsRecursion)
309            .expect_err("Unexpected succeeded")
310            .to_string(),
311        "Failed to match any ACLs"
312    );
313}
314
315#[test]
316fn acl_not_authorized() {
317    use erbium_net::addr::{Ipv4Addr, ToNetAddr as _, WithPort as _};
318    let test_acls = vec![Acl {
319        subnet: Some(vec![Prefix::V4(Prefix4 {
320            addr: "192.0.2.0".parse().unwrap(),
321            prefixlen: 24,
322        })]),
323        unix: None,
324        permission: Permission {
325            allow_dns_recursion: false,
326            allow_http: false,
327            allow_http_leases: false,
328            allow_http_metrics: false,
329        },
330    }];
331
332    let ip = "192.0.2.1".parse::<Ipv4Addr>().unwrap().with_port(0);
333
334    let client = Attributes {
335        addr: ip.to_net_addr(),
336    };
337
338    assert_eq!(
339        require_permission(&test_acls, &client, PermissionType::DnsRecursion)
340            .expect_err("Unexpected success")
341            .to_string(),
342        "Matched ACL does not have permission dns-recursion"
343    );
344}
345
346#[test]
347fn acl_allowed() {
348    use erbium_net::addr::{Ipv4Addr, ToNetAddr as _, WithPort as _};
349    let test_acls = vec![Acl {
350        subnet: Some(vec![Prefix::V4(Prefix4 {
351            addr: "192.0.2.0".parse().unwrap(),
352            prefixlen: 24,
353        })]),
354        unix: None,
355        permission: Permission {
356            allow_dns_recursion: true,
357            allow_http: false,
358            allow_http_leases: false,
359            allow_http_metrics: false,
360        },
361    }];
362
363    let ip = "192.0.2.1".parse::<Ipv4Addr>().unwrap().with_port(0);
364
365    let client = Attributes {
366        addr: ip.to_net_addr(),
367    };
368
369    assert_eq!(
370        require_permission(&test_acls, &client, PermissionType::DnsRecursion),
371        Ok(())
372    );
373}
374
375#[test]
376fn acl_parse() {
377    load_config_from_string_for_test(
378        "---
379      acls:
380       - match-subnets: [192.0.2.0/24]
381         apply-access: ['dns-recursion']
382    ",
383    )
384    .expect("Failed to parse ACL configuration");
385}
386
387#[test]
388fn acl_parse_fail() {
389    assert_eq!(
390        load_config_from_string_for_test(
391            "---
392      acls:
393       - match-subnets: [192.0.2.0/24]
394         apply-access: ['not-a-permission']
395    ",
396        )
397        .expect_err("Bad config unexpectedly successfully parsed")
398        .to_string(),
399        "Invalid Configuration: Unknown access not-a-permission"
400    );
401
402    assert_eq!(
403        load_config_from_string_for_test(
404            "---
405      acls:
406       - not-a-valid-key: 192.0.2.0/24
407    ",
408        )
409        .expect_err("Bad config unexpectedly successfully parsed")
410        .to_string(),
411        "Invalid Configuration: Unknown acls key not-a-valid-key"
412    );
413
414    assert_eq!(
415        load_config_from_string_for_test(
416            "---
417      acls:
418       - match-subnets: [192.0.2.0/24]
419         apply-access: null
420    ",
421        )
422        .expect_err("Bad config unexpectedly successfully parsed")
423        .to_string(),
424        "Invalid Configuration: apply-access cannot be null"
425    );
426}