massa_bootstrap/
white_black_list.rs

1use std::{
2    borrow::Cow,
3    collections::HashSet,
4    net::{IpAddr, SocketAddr},
5    path::{Path, PathBuf},
6    sync::Arc,
7};
8
9use crate::error::BootstrapError;
10use massa_logging::massa_trace;
11use parking_lot::RwLock;
12use tracing::{info, warn};
13
14use crate::tools::to_canonical;
15
16/// A wrapper around the white/black lists that allows efficient sharing between threads
17// TODO: don't clone the path-bufs...
18#[derive(Clone, Debug)]
19pub struct SharedWhiteBlackList<'a> {
20    inner: Arc<RwLock<WhiteBlackListInner>>,
21    white_path: Cow<'a, Path>,
22    black_path: Cow<'a, Path>,
23}
24
25impl SharedWhiteBlackList<'_> {
26    pub(crate) fn new(white_path: PathBuf, black_path: PathBuf) -> Result<Self, BootstrapError> {
27        let (white_list, black_list) = WhiteBlackListInner::init_list(&white_path, &black_path)?;
28        Ok(Self {
29            inner: Arc::new(RwLock::new(WhiteBlackListInner {
30                white_list,
31                black_list,
32            })),
33            white_path: Cow::from(white_path),
34            black_path: Cow::from(black_path),
35        })
36    }
37
38    /// get the white list
39    pub fn get_white_list(&self) -> Option<HashSet<IpAddr>> {
40        self.inner.read().white_list.clone()
41    }
42
43    /// get the black list
44    pub fn get_black_list(&self) -> Option<HashSet<IpAddr>> {
45        self.inner.read().black_list.clone()
46    }
47
48    /// Add IP address to the black list
49    pub fn add_ips_to_blacklist(&self, ips: Vec<IpAddr>) -> Result<(), BootstrapError> {
50        // Canonicalize on insert so the in-memory list matches the canonical
51        // form used by `is_ip_allowed` (and by `load_list` on reload). Otherwise
52        // a non-canonical entry such as `::ffff:a.b.c.d` would fail to block the
53        // equivalent IPv4 peer until the next file reload.
54        let ips = ips.into_iter().map(to_canonical).collect::<Vec<_>>();
55        let mut write_lock = self.inner.write();
56        if let Some(black_list) = &mut write_lock.black_list {
57            black_list.extend(ips);
58        } else {
59            write_lock.black_list = Some(HashSet::from_iter(ips));
60        };
61        self.write_to_file(&self.black_path, write_lock.black_list.as_ref().unwrap())?;
62        Ok(())
63    }
64
65    /// Remove IPs address from the black list
66    pub fn remove_ips_from_blacklist(&self, ips: Vec<IpAddr>) -> Result<(), BootstrapError> {
67        let ips = ips.into_iter().map(to_canonical).collect::<Vec<_>>();
68        let mut write_lock = self.inner.write();
69        if let Some(black_list) = &mut write_lock.black_list {
70            for ip in ips {
71                black_list.remove(&ip);
72            }
73            self.write_to_file(&self.black_path, black_list)?;
74        }
75        Ok(())
76    }
77
78    /// Add IP address to the white list
79    pub fn add_ips_to_whitelist(&self, ips: Vec<IpAddr>) -> Result<(), BootstrapError> {
80        // See `add_ips_to_blacklist`: canonicalize on insert for consistency
81        // with the canonicalized membership check in `is_ip_allowed`.
82        let ips = ips.into_iter().map(to_canonical).collect::<Vec<_>>();
83        let mut write_lock = self.inner.write();
84        if let Some(white_list) = &mut write_lock.white_list {
85            white_list.extend(ips);
86        } else {
87            write_lock.white_list = Some(HashSet::from_iter(ips));
88        };
89        self.write_to_file(&self.white_path, write_lock.white_list.as_ref().unwrap())?;
90        Ok(())
91    }
92
93    /// Remove IPs address from the white list
94    pub fn remove_ips_from_whitelist(&self, ips: Vec<IpAddr>) -> Result<(), BootstrapError> {
95        let ips = ips.into_iter().map(to_canonical).collect::<Vec<_>>();
96        let mut write_lock = self.inner.write();
97        if let Some(white_list) = &mut write_lock.white_list {
98            for ip in ips {
99                white_list.remove(&ip);
100            }
101            self.write_to_file(&self.white_path, white_list)?;
102        }
103        Ok(())
104    }
105
106    /// write list to file
107    fn write_to_file(
108        &self,
109        file_path: &Path,
110        data: &HashSet<IpAddr>,
111    ) -> Result<(), BootstrapError> {
112        let list = serde_json::to_string(data).map_err(|e| {
113            warn!(error = ?e, "failed to serialize list");
114            BootstrapError::SerializationError(e.to_string())
115        })?;
116        std::fs::write(file_path, list).map_err(|e| {
117            warn!(error = ?e, "failed to write list to file");
118            BootstrapError::IoError(e)
119        })?;
120        Ok(())
121    }
122
123    /// Checks if the white/black list is up to date with a read-lock
124    /// Creates a new list, and replaces the old one in a write-lock
125    pub(crate) fn update(&mut self) -> Result<(), BootstrapError> {
126        // Read the files before taking the lock, so a slow or hung filesystem
127        // read cannot stall `is_ip_allowed` callers.
128        let white_file = WhiteBlackListInner::read_list_file(&self.white_path);
129        let black_file = WhiteBlackListInner::read_list_file(&self.black_path);
130        let read_lock = self.inner.read();
131        let new_white_file =
132            WhiteBlackListInner::refresh_list(white_file, &read_lock.white_list, "whitelist")?;
133        let new_black_file =
134            WhiteBlackListInner::refresh_list(black_file, &read_lock.black_list, "blacklist")?;
135        let white_delta = new_white_file != read_lock.white_list;
136        let black_delta = new_black_file != read_lock.black_list;
137        if white_delta || black_delta {
138            // Ideally this scope would be atomic
139            let mut mut_inner = {
140                drop(read_lock);
141                self.inner.write()
142            };
143
144            if white_delta {
145                info!("whitelist has updated !");
146                mut_inner.white_list = new_white_file;
147            }
148            if black_delta {
149                info!("blacklist has updated !");
150                mut_inner.black_list = new_black_file;
151            }
152        }
153        Ok(())
154    }
155
156    pub(crate) fn is_ip_allowed(&self, remote_addr: &SocketAddr) -> Result<(), BootstrapError> {
157        let ip = to_canonical(remote_addr.ip());
158        // whether the peer IP address is blacklisted
159        let read = self.inner.read();
160        if let Some(ip_list) = &read.black_list {
161            if ip_list.contains(&ip) {
162                massa_trace!("bootstrap.lib.run.select.accept.refuse_blacklisted", {"remote_addr": remote_addr});
163                return Err(BootstrapError::BlackListed(ip.to_string()));
164            }
165            // whether the peer IP address is not present in the whitelist
166        }
167        if let Some(ip_list) = &read.white_list {
168            if !ip_list.contains(&ip) {
169                massa_trace!("bootstrap.lib.run.select.accept.refuse_not_whitelisted", {"remote_addr": remote_addr});
170                return Err(BootstrapError::WhiteListed(ip.to_string()));
171            }
172        }
173        Ok(())
174    }
175}
176
177/// Outcome of reading a list file, distinguishing an explicitly absent file
178/// from one that exists but cannot be read.
179enum ListFileRead {
180    /// The file does not exist: the list is deliberately not configured.
181    Missing,
182    /// The file exists but could not be read.
183    Unreadable(std::io::Error),
184    /// The file content, still to be parsed.
185    Content(String),
186}
187
188impl WhiteBlackListInner {
189    #[allow(clippy::type_complexity)]
190    fn init_list(
191        whitelist_path: &Path,
192        blacklist_path: &Path,
193    ) -> Result<(Option<HashSet<IpAddr>>, Option<HashSet<IpAddr>>), BootstrapError> {
194        Ok((
195            Self::load_list(whitelist_path, "whitelist")?,
196            Self::load_list(blacklist_path, "blacklist")?,
197        ))
198    }
199
200    /// Load a list at startup. A missing or unreadable file means the list is
201    /// not configured: the feature is disabled with a warning.
202    fn load_list(
203        list_path: &Path,
204        list_kind: &str,
205    ) -> Result<Option<HashSet<IpAddr>>, BootstrapError> {
206        match std::fs::read_to_string(list_path) {
207            Err(e) => {
208                warn!(
209                    "error on load whitelist/blacklist file : {} | {}",
210                    list_path.to_str().unwrap_or(" "),
211                    e
212                );
213                Ok(None)
214            }
215            Ok(list) => Ok(Some(Self::parse_list(&list, list_kind)?)),
216        }
217    }
218
219    /// Read a list file, keeping the distinction between "the file does not
220    /// exist" and "the file exists but cannot be read".
221    fn read_list_file(list_path: &Path) -> ListFileRead {
222        match std::fs::read_to_string(list_path) {
223            Ok(content) => ListFileRead::Content(content),
224            Err(e) if e.kind() == std::io::ErrorKind::NotFound => ListFileRead::Missing,
225            Err(e) => ListFileRead::Unreadable(e),
226        }
227    }
228
229    /// Compute the new value of a list for a periodic refresh.
230    ///
231    /// A missing file is an explicit operator choice: the list is disabled. A
232    /// file that exists but cannot be read must NOT disable a configured list:
233    /// bootstrap access control would silently fail open if the whitelist file
234    /// became unreadable at runtime, so the previously loaded list is kept.
235    fn refresh_list(
236        file: ListFileRead,
237        current: &Option<HashSet<IpAddr>>,
238        list_kind: &str,
239    ) -> Result<Option<HashSet<IpAddr>>, BootstrapError> {
240        match file {
241            ListFileRead::Content(list) => Ok(Some(Self::parse_list(&list, list_kind)?)),
242            ListFileRead::Missing => {
243                if current.is_some() {
244                    warn!(
245                        "bootstrap {} file no longer exists: disabling the list",
246                        list_kind
247                    );
248                }
249                Ok(None)
250            }
251            ListFileRead::Unreadable(e) => {
252                warn!(
253                    "failed to read bootstrap {} file: {} | keeping the previously loaded list",
254                    list_kind, e
255                );
256                Ok(current.clone())
257            }
258        }
259    }
260
261    /// Parse the JSON content of a list file into a canonicalized IP set.
262    fn parse_list(list: &str, list_kind: &str) -> Result<HashSet<IpAddr>, BootstrapError> {
263        Ok(serde_json::from_str::<HashSet<IpAddr>>(list)
264            .map_err(|e| {
265                BootstrapError::InitListError(format!(
266                    "Failed to parse bootstrap {} : {}",
267                    list_kind, e
268                ))
269            })?
270            .into_iter()
271            .map(to_canonical)
272            .collect())
273    }
274}
275
276#[derive(Default, Debug)]
277pub(crate) struct WhiteBlackListInner {
278    white_list: Option<HashSet<IpAddr>>,
279    black_list: Option<HashSet<IpAddr>>,
280}
281
282#[cfg(test)]
283mod tests {
284    use super::SharedWhiteBlackList;
285    use crate::error::BootstrapError;
286    use std::net::{IpAddr, SocketAddr};
287    use tempfile::TempDir;
288
289    #[test]
290    fn blacklisting_mapped_ipv6_blocks_equivalent_ipv4_immediately() {
291        let dir = TempDir::new().unwrap();
292        let white = dir.path().join("whitelist.json");
293        let black = dir.path().join("blacklist.json");
294        let list = SharedWhiteBlackList::new(white, black).unwrap();
295
296        // Add the IPv4-mapped IPv6 form of 127.0.0.2 through the private API.
297        let mapped: IpAddr = "::ffff:127.0.0.2".parse().unwrap();
298        list.add_ips_to_blacklist(vec![mapped]).unwrap();
299
300        // The equivalent plain IPv4 peer must be blocked right away, without
301        // waiting for a file reload to canonicalize the stored entry.
302        let peer = SocketAddr::new("127.0.0.2".parse().unwrap(), 12345);
303        assert!(matches!(
304            list.is_ip_allowed(&peer),
305            Err(BootstrapError::BlackListed(_))
306        ));
307    }
308
309    #[test]
310    fn whitelist_file_removal_disables_the_list() {
311        let dir = TempDir::new().unwrap();
312        let white = dir.path().join("whitelist.json");
313        let black = dir.path().join("blacklist.json");
314        std::fs::write(&white, r#"["127.0.0.1"]"#).unwrap();
315        let mut list = SharedWhiteBlackList::new(white.clone(), black).unwrap();
316
317        let refused = SocketAddr::new("127.0.0.2".parse().unwrap(), 12345);
318        assert!(matches!(
319            list.is_ip_allowed(&refused),
320            Err(BootstrapError::WhiteListed(_))
321        ));
322
323        // Deleting the file is an explicit operator action: the refresh must
324        // disable the whitelist.
325        std::fs::remove_file(&white).unwrap();
326        list.update().unwrap();
327
328        assert!(list.is_ip_allowed(&refused).is_ok());
329    }
330
331    #[test]
332    fn unreadable_whitelist_file_keeps_the_loaded_list() {
333        let dir = TempDir::new().unwrap();
334        let white = dir.path().join("whitelist.json");
335        let black = dir.path().join("blacklist.json");
336        std::fs::write(&white, r#"["127.0.0.1"]"#).unwrap();
337        let mut list = SharedWhiteBlackList::new(white.clone(), black).unwrap();
338
339        let allowed = SocketAddr::new("127.0.0.1".parse().unwrap(), 12345);
340        let refused = SocketAddr::new("127.0.0.2".parse().unwrap(), 12345);
341        assert!(list.is_ip_allowed(&allowed).is_ok());
342        assert!(matches!(
343            list.is_ip_allowed(&refused),
344            Err(BootstrapError::WhiteListed(_))
345        ));
346
347        // The path exists but reading it fails (it is now a directory): the
348        // refresh must keep the previously loaded list instead of failing open.
349        std::fs::remove_file(&white).unwrap();
350        std::fs::create_dir(&white).unwrap();
351        list.update().unwrap();
352
353        assert!(list.is_ip_allowed(&allowed).is_ok());
354        assert!(matches!(
355            list.is_ip_allowed(&refused),
356            Err(BootstrapError::WhiteListed(_))
357        ));
358    }
359
360    #[test]
361    fn blacklist_file_removal_disables_the_list() {
362        let dir = TempDir::new().unwrap();
363        let white = dir.path().join("whitelist.json");
364        let black = dir.path().join("blacklist.json");
365        std::fs::write(&black, r#"["127.0.0.2"]"#).unwrap();
366        let mut list = SharedWhiteBlackList::new(white, black.clone()).unwrap();
367
368        let blocked = SocketAddr::new("127.0.0.2".parse().unwrap(), 12345);
369        assert!(matches!(
370            list.is_ip_allowed(&blocked),
371            Err(BootstrapError::BlackListed(_))
372        ));
373
374        std::fs::remove_file(&black).unwrap();
375        list.update().unwrap();
376
377        assert!(list.is_ip_allowed(&blocked).is_ok());
378    }
379
380    #[test]
381    fn refresh_without_any_configured_list_keeps_access_open() {
382        let dir = TempDir::new().unwrap();
383        let white = dir.path().join("whitelist.json");
384        let black = dir.path().join("blacklist.json");
385        // no file at all: the common case of a node without access lists
386        let mut list = SharedWhiteBlackList::new(white, black).unwrap();
387
388        list.update().unwrap();
389
390        let peer = SocketAddr::new("127.0.0.1".parse().unwrap(), 12345);
391        assert!(list.is_ip_allowed(&peer).is_ok());
392    }
393
394    #[test]
395    fn recreated_whitelist_file_is_reloaded_on_refresh() {
396        let dir = TempDir::new().unwrap();
397        let white = dir.path().join("whitelist.json");
398        let black = dir.path().join("blacklist.json");
399        std::fs::write(&white, r#"["127.0.0.1"]"#).unwrap();
400        let mut list = SharedWhiteBlackList::new(white.clone(), black).unwrap();
401
402        // the file disappears, then comes back with a different content:
403        // the refresh must pick up the new list
404        std::fs::remove_file(&white).unwrap();
405        list.update().unwrap();
406        std::fs::write(&white, r#"["127.0.0.3"]"#).unwrap();
407        list.update().unwrap();
408
409        let newly_allowed = SocketAddr::new("127.0.0.3".parse().unwrap(), 12345);
410        let formerly_allowed = SocketAddr::new("127.0.0.1".parse().unwrap(), 12345);
411        assert!(list.is_ip_allowed(&newly_allowed).is_ok());
412        assert!(matches!(
413            list.is_ip_allowed(&formerly_allowed),
414            Err(BootstrapError::WhiteListed(_))
415        ));
416    }
417}