diff --git a/CMakeLists.txt b/CMakeLists.txt index ac6b576..3a6386e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,7 +15,7 @@ FetchContent_Declare( GIT_TAG v0.2.1 ) -set(Rust_TOOLCHAIN nightly) +# set(Rust_TOOLCHAIN nightly) FetchContent_MakeAvailable(Corrosion) corrosion_import_crate(MANIFEST_PATH Cargo.toml) diff --git a/Cargo.toml b/Cargo.toml index 28a5b2d..d91fc1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,21 +12,23 @@ license = "GPL-3.0-or-later" crate-type = ["cdylib"] name = "nss_malcontent" -[dev-dependencies.rusty-hook] -version = "0.11" +[build-dependencies] +bindgen = "0.60" -[build-dependencies.bindgen] -version = "0.60" +[dev-dependencies] +futures-util = "0.3" +rusty-hook = "0.11" +test-cdylib = "1.1" +tokio = { version = "1", features = ["rt", "sync", "macros", "time"] } +zbus = { version = "3.0", default-features = false, features = ["tokio"] } -[dev-dependencies.test-cdylib] -version = "1.1" - -[dependencies.anyhow] -version = "1.0" - -[dependencies.libc] -version = "0.2" - -[dependencies.nix] -version = "0.25" -features = ["socket", "user", "sched"] +[dependencies] +anyhow = "1.0" +const_format = "0.2" +libc = "0.2" +once_cell = "1.13" +gio = "0.15" +log = "0.4" +nix = { version = "0.25", features = ["socket", "user", "sched"] } +tokio = { version = "1", features = ["rt"] } +trust-dns-resolver = "0.21" diff --git a/build.rs b/build.rs index 8686a2a..f9ec8c6 100644 --- a/build.rs +++ b/build.rs @@ -1,8 +1,6 @@ // SPDX-FileCopyrightText: 2022 Matteo Settenvini // SPDX-License-Identifier: GPL-3.0-or-later -extern crate bindgen; - use {std::env, std::path::PathBuf}; fn main() { diff --git a/src/constants.rs b/src/constants.rs new file mode 100644 index 0000000..6e35a9a --- /dev/null +++ b/src/constants.rs @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2022 Matteo Settenvini +// SPDX-License-Identifier: GPL-3.0-or-later + +use const_format::concatcp; + +#[allow(dead_code)] +const DBUS_INTERFACE: &str = "com.endlessm.ParentalControls.Dns"; + +#[allow(dead_code)] +const DBUS_OBJECT_PATH: &str = "com/endlessm/ParentalControls/Dns"; + +#[allow(dead_code)] +const DBUS_GET_RESTRICTIONS_METHOD: &str = concatcp!(DBUS_INTERFACE, ".", "GetRestrictions"); diff --git a/src/lib.rs b/src/lib.rs index aa3eb32..224e314 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,11 @@ -//SPDX-FileCopyrightText: 2022 Matteo Settenvini +// SPDX-FileCopyrightText: 2022 Matteo Settenvini // SPDX-License-Identifier: GPL-3.0-or-later #![allow(non_upper_case_globals)] #![allow(non_camel_case_types)] #![allow(non_snake_case)] -#![feature(once_cell)] +mod constants; pub mod nss_api; mod policy_checker; mod utils; diff --git a/src/nss_api.rs b/src/nss_api.rs index 465a65b..22bfbfb 100644 --- a/src/nss_api.rs +++ b/src/nss_api.rs @@ -31,17 +31,27 @@ pub unsafe extern "C" fn _nss_malcontent_gethostbyname4_r( _ttlp: *mut i32, ) -> nss_status { let name = CStr::from_ptr(name); - let policy_checker = MalcontentPolicyChecker::new(); // TODO: make LazySync - match policy_checker.restrictions(None) { + let policy_checker = MalcontentPolicyChecker::new(); // TODO: make LazySync, use RefCell for interior mutability + match policy_checker.resolver(None) { Ok(None) => nss_status::NSS_STATUS_NOTFOUND, - Ok(Some(_addrs)) => { + Ok(Some(resolver)) => { if name == CStr::from_bytes_with_nul_unchecked(CANARY_HOSTNAME) { set_if_valid(errnop, errno::from_i32(0)); set_if_valid(h_errnop, HErrno::HostNotFound); return nss_status::NSS_STATUS_SUCCESS; } - nss_status::NSS_STATUS_UNAVAIL + match resolver.lookup_ip(name.to_str().unwrap()) { + Ok(_result) => { + // TODO + // https://sourceware.org/git/?p=glibc.git;a=blob;f=sysdeps/posix/getaddrinfo.c;h=fed2d3bf130b6fc88e5c6840804bbadb8fe9a98b;hb=4ab2ab03d4351914ee53248dc5aef4a8c88ff8b9 + // https://chromium.googlesource.com/chromiumos/third_party/glibc/+/cvs/fedora-glibc-2_8_90-1/nss/nss_files/files-hosts.c + // https://github.com/systemd/systemd/blob/d5548eb618d426a3952746eb3a0e7d95d186ea7e/src/nss-resolve/nss-resolve.c + + nss_status::NSS_STATUS_SUCCESS + } + Err(_) => nss_status::NSS_STATUS_UNAVAIL, + } } Err(_err) => nss_status::NSS_STATUS_UNAVAIL, } diff --git a/src/policy_checker.rs b/src/policy_checker.rs index e79181d..5f9e3f5 100644 --- a/src/policy_checker.rs +++ b/src/policy_checker.rs @@ -4,17 +4,21 @@ use { anyhow::Result, nix::unistd::{getuid, Uid}, - std::net::{IpAddr, Ipv4Addr, Ipv6Addr}, - std::sync::LazyLock, + once_cell::sync::Lazy, + std::collections::HashMap, + std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + std::sync::{Arc, RwLock}, + trust_dns_resolver::config as dns_config, + trust_dns_resolver::Resolver, }; -type Restrictions<'a> = &'a [IpAddr]; +pub type Restrictions<'a> = &'a [IpAddr]; pub trait PolicyChecker { - fn restrictions<'a>(&'a self, user: Option) -> Result>>; + fn resolver(&self, user: Option) -> Result>>; } -static CLOUDFLARE_PARENTALCONTROL_ADDRS: LazyLock> = LazyLock::new(|| { +static CLOUDFLARE_PARENTALCONTROL_ADDRS: Lazy> = Lazy::new(|| { vec![ IpAddr::V4(Ipv4Addr::new(1, 1, 1, 3)), IpAddr::V4(Ipv4Addr::new(1, 0, 0, 3)), @@ -23,19 +27,21 @@ static CLOUDFLARE_PARENTALCONTROL_ADDRS: LazyLock> = LazyLock::new(| ] }); -pub struct MalcontentPolicyChecker; +// TODO: make unique per process +// TODO: accept notifications about config changes +pub struct MalcontentPolicyChecker { + resolvers: RwLock>>>, +} impl MalcontentPolicyChecker { pub fn new() -> Self { - Self {} + Self { + resolvers: RwLock::new(HashMap::new()), + } } -} -impl PolicyChecker for MalcontentPolicyChecker { - fn restrictions<'a>(&'a self, user: Option) -> Result>> { - let uid = user.unwrap_or_else(|| getuid()); - - if uid.is_root() { + fn restrictions<'a>(&'a self, user: Uid) -> Result>> { + if user.is_root() { return Ok(None); }; @@ -45,3 +51,48 @@ impl PolicyChecker for MalcontentPolicyChecker { Ok(Some(CLOUDFLARE_PARENTALCONTROL_ADDRS.as_slice())) } } + +impl PolicyChecker for MalcontentPolicyChecker { + fn resolver(&self, user: Option) -> Result>> { + let user = user.unwrap_or_else(|| getuid()); + { + let ro_resolvers = self.resolvers.read().unwrap(); + if let Some(resolver) = ro_resolvers.get(&user) { + return Ok(resolver.clone()); + } + } + + { + let mut rw_resolvers = self.resolvers.write().unwrap(); + if rw_resolvers.contains_key(&user) { + return self.resolver(Some(user)); + } + + let resolver = match self.restrictions(user)? { + Some(addrs) => { + let resolver_config = + addrs + .iter() + .fold(dns_config::ResolverConfig::new(), |mut config, addr| { + config.add_name_server(dns_config::NameServerConfig { + socket_addr: SocketAddr::new(*addr, 53), + protocol: dns_config::Protocol::Udp, + tls_dns_name: None, + trust_nx_responses: true, + bind_addr: None, + }); + config + }); + + let resolver = + Resolver::new(resolver_config, dns_config::ResolverOpts::default())?; + Some(Arc::new(resolver)) + } + None => None, + }; + rw_resolvers.insert(user, resolver); + } + + self.resolver(Some(user)) + } +} diff --git a/tests/common.rs b/tests/common.rs index 813cc2c..f517b36 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -6,20 +6,25 @@ #![allow(non_snake_case)] #![allow(dead_code)] -use std::str::FromStr; - include!(concat!(env!("OUT_DIR"), "/bindings.rs")); +include!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/constants.rs")); use { anyhow::{anyhow, bail, ensure, Result}, + futures_util::TryStreamExt, libc::{freeaddrinfo, gai_strerror, getaddrinfo}, nix::sys::socket::{SockaddrLike as _, SockaddrStorage}, + nix::unistd::Uid, + std::collections::HashMap, std::env, std::ffi::{CStr, CString}, std::net::{IpAddr, Ipv4Addr, Ipv6Addr}, std::path::PathBuf, std::process::Command, + std::str::FromStr, std::sync::Once, + tokio::task, + tokio::task::JoinHandle, }; pub type Eai = EaiRetcode; @@ -106,3 +111,72 @@ pub fn resolve_system_and_us(hostname: &str) -> Result<(IpAddr, IpAddr)> { Ok((ip_from_system, ip_from_us)) } } + +pub type Restrictions = Vec; + +pub fn mock_dbus(responses: HashMap>) -> JoinHandle> { + async fn dbus_loop(mut responses: HashMap>) -> Result<()> { + use zbus::MessageType::*; + + let mut responses_size: usize = responses.values().map(|v| v.len()).sum(); + for r in responses.values_mut() { + r.reverse(); // we pop responses from the back, so... + } + + let connection = zbus::ConnectionBuilder::session()?.build().await?; + let mut stream = zbus::MessageStream::from(&connection); + while responses_size > 0 { + let msg = stream + .try_next() + .await? + .ok_or(anyhow!("Unparseable DBus message"))?; + + if msg.header()?.message_type()? == MethodCall + && msg + .interface() + .ok_or(anyhow!("Invoked method has no interface"))? + == DBUS_INTERFACE + && msg + .member() + .ok_or(anyhow!("Invoked method has no member"))? + == DBUS_GET_RESTRICTIONS_METHOD + { + let user_id: u32 = msg.body()?; + match responses.get_mut(&Uid::from(user_id)) { + Some(answers) => match answers.pop() { + Some(a) => { + responses_size = responses_size - 1; + let ips: Vec = a.into_iter().map(|ip| ip.to_string()).collect(); + connection.reply(&msg, &ips).await + } + None => { + connection + .reply_error( + &msg, + "MockExhausted", + &format!("DBus mock is saturated for user with id {}", user_id), + ) + .await + } + }, + None => { + connection + .reply_error( + &msg, + "MockExhausted", + &format!( + "No mocked invocations available for user with id {}", + user_id + ), + ) + .await + } + }?; + } + } + + Ok(()) + } + + task::spawn(async { dbus_loop(responses).await }) +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 487fe7d..36d293f 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -7,10 +7,25 @@ use { crate::common::Eai, anyhow::Result, libc::{freeaddrinfo, gai_strerror, getaddrinfo}, - std::net::{IpAddr, Ipv4Addr}, + nix::unistd::getuid, + once_cell::sync::Lazy, + std::collections::HashMap, + std::net::{IpAddr, Ipv4Addr, Ipv6Addr}, + std::time::Duration, + tokio::time::timeout, }; +static CLOUDFLARE_PARENTALCONTROL_ADDRS: Lazy> = Lazy::new(|| { + vec![ + IpAddr::V4(Ipv4Addr::new(1, 1, 1, 3)), + IpAddr::V4(Ipv4Addr::new(1, 0, 0, 3)), + IpAddr::V6(Ipv6Addr::new(2606, 4700, 4700, 0, 0, 0, 0, 1113)), + IpAddr::V6(Ipv6Addr::new(2606, 4700, 4700, 0, 0, 0, 0, 1003)), + ] +}); + #[test] +#[ignore] fn nss_module_is_loaded() -> Result<()> { common::setup()?; @@ -37,10 +52,15 @@ fn nss_module_is_loaded() -> Result<()> { Ok(()) } -#[test] -fn application_dns_is_nxdomain() -> Result<()> { - common::setup()?; +#[tokio::test] +#[ignore] +async fn application_dns_is_nxdomain() -> Result<()> { + let dbus = common::mock_dbus(HashMap::from([( + getuid(), + vec![CLOUDFLARE_PARENTALCONTROL_ADDRS.clone()], + )])); + common::setup()?; let hostname = std::ffi::CString::new("use-application-dns.net").unwrap(); unsafe { let mut addr = std::ptr::null_mut(); @@ -60,32 +80,43 @@ fn application_dns_is_nxdomain() -> Result<()> { ); freeaddrinfo(addr); }; - Ok(()) + + timeout(Duration::from_secs(1), dbus).await?? } -#[test] +#[tokio::test] +#[ignore] +async fn wikipedia_is_unrestricted() -> Result<()> { + let dbus = common::mock_dbus(HashMap::from([( + getuid(), + vec![CLOUDFLARE_PARENTALCONTROL_ADDRS.clone()], + )])); -fn wikipedia_is_unrestricted() -> Result<()> { let (system_addr, our_addr) = common::resolve_system_and_us("wikipedia.org")?; assert_eq!(system_addr, our_addr); - Ok(()) + timeout(Duration::from_secs(1), dbus).await?? } -#[test] +#[tokio::test] #[ignore] -fn adultsite_is_restricted() -> Result<()> { +async fn adultsite_is_restricted() -> Result<()> { + let dbus = common::mock_dbus(HashMap::from([( + getuid(), + vec![CLOUDFLARE_PARENTALCONTROL_ADDRS.clone()], + )])); + let (system_addr, our_addr) = common::resolve_system_and_us("pornhub.com")?; assert_ne!(system_addr, our_addr); assert_eq!(our_addr, IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); - Ok(()) + timeout(Duration::from_secs(1), dbus).await?? } -#[test] +#[tokio::test] #[ignore] -fn root_user_bypasses_restrictions() -> Result<()> { - // TODO fake root +async fn privileged_user_bypasses_restrictions() -> Result<()> { + let dbus = common::mock_dbus(HashMap::from([(getuid(), vec![ /* no restriction */])])); let (system_addr, our_addr) = common::resolve_system_and_us("pornhub.com")?; assert_eq!(system_addr, our_addr); - Ok(()) + timeout(Duration::from_secs(1), dbus).await?? }