Compare commits

..

2 Commits

Author SHA1 Message Date
Matteo Settenvini 03fbdb2d26
dns: NSS module implementation 2024-01-09 09:24:58 +01:00
Matteo Settenvini 3beee1cf84
dns: add dbus interfaces 2024-01-05 17:55:52 +01:00
27 changed files with 4128 additions and 1 deletions

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
<node>
<interface name="com.endlessm.ParentalControls.Dns">
<annotation name="org.freedesktop.Accounts.VendorExtension" value="true"/>
<annotation name="org.freedesktop.Accounts.Authentication.ChangeOwn"
value="com.endlessm.ParentalControls.SessionLimits.ChangeOwn"/>
<annotation name="org.freedesktop.Accounts.Authentication.ReadOwn"
value="com.endlessm.ParentalControls.SessionLimits.ReadOwn"/>
<annotation name="org.freedesktop.Accounts.Authentication.ChangeAny"
value="com.endlessm.ParentalControls.SessionLimits.ChangeAny"/>
<annotation name="org.freedesktop.Accounts.Authentication.ReadAny"
value="com.endlessm.ParentalControls.SessionLimits.ReadAny"/>
<!--
Dns:
A list of DNS server IP addresses to use for this user, in order of preference.
Each can be optionally qualified by a hashtag and the corresponding
hostname (if a TLS version is supported).
For instance: using "dns0.eu Kids" (https://www.dns0.eu/kids),
which offers a good number of blocked domains, would entail:
- `193.110.81.1#kids.dns0.eu`
- `2a0f:fc80::1#kids.dns0.eu`
- `185.253.5.1#kids.dns0.eu`
- `2a0f:fc81::1#kids.dns0.eu`
If the array is left empty, the globally-set system resolver
is used instead.
-->
<property name="Dns" type="as" access="readwrite">
<annotation name="org.freedesktop.Accounts.DefaultValue" value=""/>
</property>
</interface>
</node>

View File

@ -40,6 +40,46 @@
</defaults> </defaults>
</action> </action>
<action id="com.endlessm.ParentalControls.Dns.ChangeOwn">
<description>Change your own DNS servers</description>
<message>Authentication is required to change your DNS servers.</message>
<defaults>
<allow_any>auth_admin_keep</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
</action>
<action id="com.endlessm.ParentalControls.Dns.ReadOwn">
<description>Read your own DNS servers</description>
<message>Authentication is required to read your DNS servers.</message>
<defaults>
<allow_any>yes</allow_any>
<allow_inactive>yes</allow_inactive>
<allow_active>yes</allow_active>
</defaults>
</action>
<action id="com.endlessm.ParentalControls.Dns.ChangeAny">
<description>Change another users DNS servers</description>
<message>Authentication is required to change another users DNS servers.</message>
<defaults>
<allow_any>auth_admin_keep</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
</action>
<action id="com.endlessm.ParentalControls.Dns.ReadAny">
<description>Read another users DNS servers</description>
<message>Authentication is required to read another users DNS servers.</message>
<defaults>
<allow_any>auth_admin_keep</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
</action>
<action id="com.endlessm.ParentalControls.SessionLimits.ChangeOwn"> <action id="com.endlessm.ParentalControls.SessionLimits.ChangeOwn">
<description>Change your own session limits</description> <description>Change your own session limits</description>
<message>Authentication is required to change your session limits.</message> <message>Authentication is required to change your session limits.</message>

View File

@ -24,6 +24,8 @@ polkit.addRule(function(action, subject) {
* needing an additional polkit authorisation dialogue. */ * needing an additional polkit authorisation dialogue. */
if ((action.id == "com.endlessm.ParentalControls.AppFilter.ReadOwn" || if ((action.id == "com.endlessm.ParentalControls.AppFilter.ReadOwn" ||
action.id == "com.endlessm.ParentalControls.AppFilter.ReadAny" || action.id == "com.endlessm.ParentalControls.AppFilter.ReadAny" ||
action.id == "com.endlessm.ParentalControls.Dns.ReadOwn" ||
action.id == "com.endlessm.ParentalControls.Dns.ReadAny" ||
action.id == "com.endlessm.ParentalControls.SessionLimits.ReadOwn" || action.id == "com.endlessm.ParentalControls.SessionLimits.ReadOwn" ||
action.id == "com.endlessm.ParentalControls.SessionLimits.ReadAny") && action.id == "com.endlessm.ParentalControls.SessionLimits.ReadAny") &&
subject.active && subject.local && subject.active && subject.local &&

View File

@ -9,6 +9,7 @@ i18n.merge_file(
dbus_interfaces = [ dbus_interfaces = [
'com.endlessm.ParentalControls.AccountInfo', 'com.endlessm.ParentalControls.AccountInfo',
'com.endlessm.ParentalControls.AppFilter', 'com.endlessm.ParentalControls.AppFilter',
'com.endlessm.ParentalControls.Dns',
'com.endlessm.ParentalControls.SessionLimits', 'com.endlessm.ParentalControls.SessionLimits',
] ]

View File

@ -1,6 +1,6 @@
project('malcontent', 'c', project('malcontent', 'c',
version : '0.12.0', version : '0.12.0',
meson_version : '>= 0.59.0', meson_version : '>= 0.60.0',
license: ['LGPL-2.1-or-later', 'GPL-2.0-or-later'], license: ['LGPL-2.1-or-later', 'GPL-2.0-or-later'],
default_options : [ default_options : [
'buildtype=debugoptimized', 'buildtype=debugoptimized',
@ -154,3 +154,7 @@ if get_option('ui').enabled()
endif endif
subdir('pam') subdir('pam')
subdir('po') subdir('po')
if get_option('dns').enabled()
subdir('nss')
endif

View File

@ -9,6 +9,12 @@ option(
type: 'string', type: 'string',
description: 'directory for PAM modules' description: 'directory for PAM modules'
) )
option(
'dns',
type: 'feature',
value: 'enabled',
description: 'enable NSS module support (for parental DNS controls)'
)
option( option(
'ui', 'ui',
type: 'feature', type: 'feature',

10
nss/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
# SPDX-FileCopyrightText: Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
# SPDX-License-Identifier: GPL-3.0-or-later
*.orig
*~
.gdbinit
.lldbinit
/target

30
nss/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,30 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Cargo integration_test",
"cargo": {
"args": [
"test",
"--no-run",
"--test=integration_test"
]
},
"args": [],
"cwd": "${workspaceFolder}",
"env": {
"RUST_BACKTRACE": "full",
"NSS_MALCONTENT_LOG": "trace",
},
"preRunCommands": [
"settings set target.process.follow-fork-mode child",
"settings set target.process.stop-on-exec false"
]
},
]
}

2351
nss/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

2
nss/Cargo.lock.license Normal file
View File

@ -0,0 +1,2 @@
SPDX-FileCopyrightText: Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
SPDX-License-Identifier: CC0-1.0

54
nss/Cargo.toml Normal file
View File

@ -0,0 +1,54 @@
# SPDX-FileCopyrightText: 2022 Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
# SPDX-License-Identifier: CC0-1.0
[package]
name = "malcontent-nss"
version = "0.1.0"
edition = "2021"
authors = ["Matteo Settenvini <matteo.settenvini@montecristosoftware.eu"]
license = "GPL-3.0-or-later"
[profile.release]
strip = true
lto = true
codegen-units = 1
panic = "unwind" # We rely on this
[features]
integration_test = ["dep:zbus_names"]
[lib]
crate-type = ["cdylib"]
name = "nss_malcontent"
[dependencies]
anyhow = "1.0"
gethostname = "0.4"
libc = "0.2"
once_cell = "1.13"
ld_preload = "0.1"
log = "0.4"
nix = { version = "0.27", features = ["socket", "user", "net", "sched"] }
serde = "1.0"
syslog = "6.1"
tokio = { version = "1", features = ["rt"] }
hickory-resolver = { version = "0.24", features = ["dns-over-native-tls"] }
hickory-proto = "0.24"
zbus = { version = "3.0", default-features = false, features = ["tokio"] }
zvariant = "3.6"
zbus_names = { version = "2.2", optional = true }
[dev-dependencies]
elf = "0.7"
malcontent-nss = { path = ".", features = ["integration_test"] }
env_logger = "0.10"
event-listener = "4.0"
rusty-fork = "*"
futures-util = "0.3"
static_init = "1.0"
test-cdylib = "1.1"
tokio = { version = "1", features = ["rt", "sync", "macros", "net", "time"] }
tokio-util = { version = "0.7" }
[build-dependencies]
bindgen = "0.69"

15
nss/README.md Normal file
View File

@ -0,0 +1,15 @@
# Malcontent NSS Module
This is a NSS module allowing to perform parental controls
when querying the system interfaces for hostname resolution (DNS).
A system administrator would install the module and then add it to the
`hosts` entry in `/etc/nsswitch.conf`. For instance:
```
hosts: files myhostname malcontent resolve [!UNAVAIL=return] dns
```
Note how `malcontent` precedes both systemd's `resolve` and the libc `dns` modules.
Parental control configuration would happen through the Malcontent client or GUI.

2
nss/README.md.license Normal file
View File

@ -0,0 +1,2 @@
SPDX-FileCopyrightText: Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
SPDX-License-Identifier: CC-BY-SA-4.0

58
nss/build.rs Normal file
View File

@ -0,0 +1,58 @@
// SPDX-FileCopyrightText: Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: GPL-3.0-or-later
use {std::env, std::io::Error, std::io::ErrorKind, std::path::PathBuf};
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=wrapper.hpp");
// Required by NSS 2
println!("cargo:rustc-cdylib-link-arg=-Wl,-soname,libnss_malcontent.so.2");
// Pass down the path to the output folder to the integration test binary
let cdylib_dir = get_cargo_target_dir().unwrap();
println!("cargo:rustc-env=CDYLIB_OUT_DIR={}", cdylib_dir.display());
// Set the DT_RPATH of the test binary to search in its own folder first
println!("cargo:rustc-link-arg-tests=-Wl,--disable-new-dtags");
println!("cargo:rustc-link-arg-tests=-Wl,--rpath=$ORIGIN");
let bindings = bindgen::Builder::default()
.header("wrapper.hpp")
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.newtype_enum("nss_status")
.allowlist_type("nss_status")
.newtype_enum("HErrno")
.allowlist_type("HErrno")
.newtype_enum("EaiRetcode")
.allowlist_type("EaiRetcode")
.allowlist_type("gaih_addrtuple")
.allowlist_function("__nss_configure_lookup")
.generate()
.expect("Unable to generate bindings");
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out_path.join("bindings.rs"))
.expect("Couldn't write bindings!");
}
fn get_cargo_target_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
let out_dir = PathBuf::from(env::var("OUT_DIR")?);
let profile = std::env::var("PROFILE")?;
let mut path = out_dir.as_path();
loop {
if let Some(parent) = path.parent() {
path = parent;
if parent.ends_with(&profile) {
return Ok(path.to_path_buf());
}
} else {
return Err(Box::new(Error::new(
ErrorKind::NotFound,
"Unable to determine cargo build target directory",
)));
}
}
}

59
nss/meson.build Normal file
View File

@ -0,0 +1,59 @@
# SPDX-FileCopyrightText: Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
# SPDX-License-Identifier: GPL-3.0-or-later
if get_option('buildtype') == 'debug'
profile = 'dev'
target_subdir = 'debug'
else
profile = 'release'
target_subdir = 'release'
endif
dbus_iface_file = meson.project_source_root() / 'accounts-service/com.endlessm.ParentalControls.Dns.xml'
env = environment({
'DBUS_IFACE': dbus_iface_file,
})
cargo = find_program('cargo')
cargo_common_opts = [
'--manifest-path', meson.current_source_dir() / 'Cargo.toml',
'--workspace',
'--profile', profile,
'--target-dir', meson.current_build_dir() / 'target',
]
library = 'libnss_malcontent.so'
build_library = custom_target('cargo-nss_malcontent',
output: 'target' / target_subdir / library,
build_always_stale: true,
command: [cargo, 'build'] + cargo_common_opts,
console: true,
env: env,
)
custom_target('libnss_malcontent',
input: build_library,
output: library + '.2', # Required by NSS 3
build_by_default: true,
command: ['ln', '-f', '@INPUT@', '@OUTPUT@'],
env: env,
install: true,
install_dir: get_option('libdir'),
)
build_tests = custom_target('nss_malcontent-tests',
output: 'something-something-something',
build_always_stale: true,
build_by_default: true,
command: [cargo, 'test'] + cargo_common_opts + '--no-run',
console: true,
env: env
)
test('dns', cargo,
args: ['test'] + cargo_common_opts + ['--no-fail-fast', '--message-format', 'json'],
env: env,
depends: [build_tests],
protocol: 'rust',
)

33
nss/src/gethostbyaddr.rs Normal file
View File

@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: GPL-3.0-or-later
use {
crate::nss_bindings::{nss_status, HErrno},
libc::{hostent, size_t, socklen_t},
nix::errno::Errno,
std::os::raw::{c_char, c_int, c_void},
};
pub async unsafe fn with(
_addr: *const c_void,
_len: socklen_t,
_af: c_int,
_host: *mut hostent,
_buffer: *mut c_char,
_buflen: size_t,
_errnop: *mut Errno,
_h_errnop: *mut HErrno,
_ttlp: *mut i32,
) -> nss_status {
// At the moment, we are not handling this function
// in our module.
//
// We assume it is fine to go to the next module
// in the nsswitch.conf list to get an authoritative
// answer.
//
// The use case for reverse IP lookup
// should not impact parental controls.
nss_status::NSS_STATUS_UNAVAIL
}

337
nss/src/gethostbyname.rs Normal file
View File

@ -0,0 +1,337 @@
// SPDX-FileCopyrightText: Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: GPL-3.0-or-later
use hickory_resolver::error::ResolveErrorKind;
use {
crate::helpers::{set_if_valid, write_record_name_to_buf, write_vector_to_buf},
crate::nss_bindings::{gaih_addrtuple, nss_status, HErrno},
crate::policy_checker::POLICY_CHECKER,
hickory_proto::rr::record_type::RecordType,
hickory_proto::rr::{RData, Record},
hickory_resolver::TokioAsyncResolver,
hickory_resolver::{lookup::Lookup, lookup_ip::LookupIp},
libc::{c_char, c_int, hostent, size_t, AF_INET, AF_INET6},
nix::errno,
nix::errno::Errno,
std::ffi::CStr,
std::mem::{align_of, discriminant, size_of},
std::sync::Arc,
};
// See https://support.mozilla.org/en-US/kb/configuring-networks-disable-dns-over-https
const CANARY_HOSTNAME: &str = "use-application-dns.net";
#[derive(Debug)]
pub struct Args {
pub name: *const c_char,
pub family: c_int,
pub buffer: *mut c_char,
pub buflen: size_t,
pub errnop: *mut Errno,
pub h_errnop: *mut HErrno,
pub ttlp: *mut i32,
pub result: Result,
}
#[derive(Debug)]
pub enum Result {
V3(*mut hostent),
V4(*mut *mut gaih_addrtuple),
}
pub enum DnsResult {
NxDomain,
Found,
}
pub async unsafe fn with(args: &mut Args) -> nss_status {
set_if_valid(args.errnop, errno::from_i32(0));
set_if_valid(args.h_errnop, HErrno::Success);
match POLICY_CHECKER.resolver().await {
Ok(None) => {
// no restrictions for user, the next NSS module will decide
nss_status::NSS_STATUS_NOTFOUND
}
Ok(Some(resolver)) => resolve_hostname_through(resolver, args).await,
Err(err) => {
log::error!("{}", err);
nss_status::NSS_STATUS_UNAVAIL
}
}
}
async unsafe fn resolve_hostname_through(
resolver: Arc<TokioAsyncResolver>,
args: &mut Args,
) -> nss_status {
let name = match CStr::from_ptr(args.name).to_str() {
Ok(name) => name,
Err(_) => {
set_if_valid(args.errnop, Errno::EINVAL);
set_if_valid(args.h_errnop, HErrno::Internal);
return nss_status::NSS_STATUS_TRYAGAIN;
}
};
// Disable DoH in Firefox unless user forced it manually
if name == CANARY_HOSTNAME {
set_if_valid(args.h_errnop, HErrno::HostNotFound);
return nss_status::NSS_STATUS_SUCCESS;
}
let lookup: std::result::Result<Lookup, _> = match args.family {
libc::AF_UNSPEC => resolver.lookup_ip(name).await.map(LookupIp::into),
libc::AF_INET => resolver.lookup(name, RecordType::A).await,
libc::AF_INET6 => resolver.lookup(name, RecordType::AAAA).await,
_ => return nss_status::NSS_STATUS_NOTFOUND,
};
match lookup {
Ok(result) => {
log::trace!("lookup: {} => {:?}", name, result);
prepare_response(args, result)
}
Err(err) => match err.kind() {
ResolveErrorKind::NoRecordsFound {
query: _,
soa: _,
negative_ttl: _,
response_code: _,
trusted: _,
} => {
log::trace!("lookup: {} => NXDOMAIN", name);
set_if_valid(args.h_errnop, HErrno::HostNotFound);
nss_status::NSS_STATUS_SUCCESS
}
_ => {
log::warn!("{}", err);
nss_status::NSS_STATUS_UNAVAIL
}
},
}
}
unsafe fn prepare_response(
args: &mut Args,
lookup: hickory_resolver::lookup::Lookup,
) -> nss_status {
if lookup.iter().peekable().peek().is_none() {
set_if_valid(args.h_errnop, HErrno::HostNotFound);
return nss_status::NSS_STATUS_SUCCESS;
}
let ttl = lookup
.valid_until()
.duration_since(std::time::Instant::now())
.as_secs();
set_if_valid(
args.ttlp,
if ttl < (i32::MAX as u64) {
ttl as i32
} else {
i32::MAX
},
);
let buf = std::slice::from_raw_parts_mut(args.buffer as *mut u8, args.buflen);
let ret = match &mut args.result {
Result::V3(hostent) => {
if hostent.is_null() {
set_if_valid(args.errnop, Errno::EINVAL);
set_if_valid(args.h_errnop, HErrno::Internal);
return nss_status::NSS_STATUS_TRYAGAIN;
}
match records_to_hostent(lookup.into(), &mut **hostent, buf) {
Ok(DnsResult::Found) => nss_status::NSS_STATUS_SUCCESS,
Ok(DnsResult::NxDomain) => {
set_if_valid(args.h_errnop, HErrno::HostNotFound);
nss_status::NSS_STATUS_SUCCESS
}
Err(err) => {
set_if_valid(
args.errnop,
err.raw_os_error()
.map(Errno::from_i32)
.unwrap_or(Errno::EAGAIN),
);
set_if_valid(args.h_errnop, HErrno::Internal);
nss_status::NSS_STATUS_TRYAGAIN
}
}
}
Result::V4(pat) => {
if pat.is_null() {
set_if_valid(args.errnop, Errno::EINVAL);
set_if_valid(args.h_errnop, HErrno::Internal);
return nss_status::NSS_STATUS_TRYAGAIN;
}
match records_to_gaih_addr(lookup.into(), buf) {
Ok(addrs) => {
// DEBUG: eprintln!("{:?} => {:?}", addrs, *addrs);
**pat = addrs;
nss_status::NSS_STATUS_SUCCESS
}
Err(err) => {
set_if_valid(
args.errnop,
err.raw_os_error()
.map(Errno::from_i32)
.unwrap_or(Errno::EAGAIN),
);
set_if_valid(args.h_errnop, HErrno::Internal);
nss_status::NSS_STATUS_TRYAGAIN
}
}
}
};
ret
}
// TODO: refactor to be less ugly
pub unsafe fn records_to_hostent(
lookup: Lookup,
host: &mut hostent,
buf: &mut [u8],
) -> std::io::Result<DnsResult> {
// In C struct hostent:
//
// - for the type of queries we perform, we can assume h_aliases
// is an empty array.
// - hostent is limited to just one address type for all addresses
// in the list. We pick the type of first result, and only
// append addresses of the same type.
let first_record = match lookup.record_iter().peekable().peek() {
Some(record) => *record,
None => return Ok(DnsResult::NxDomain),
};
let first_rdata = match first_record.data() {
Some(rdata) => rdata,
None => return Err(Errno::ENODATA.into()),
};
// First put the name in the buffer
let (buf, name_dest) = write_record_name_to_buf(first_record, buf)?;
// Then the empty aliases array (just a null pointer)
let (mut buf, aliases_ptr) = write_vector_to_buf(buf, vec![std::ptr::null_mut()])?;
// Then the address list
let offset = buf.as_ptr().align_offset(align_of::<*mut c_char>());
buf = &mut buf[offset..];
let records = lookup
.record_iter()
.flat_map(Record::data)
.filter(|r| discriminant(*r) == discriminant(first_rdata));
let mut addresses = vec![];
for record in records {
let offset = buf.as_ptr().align_offset(align_of::<*mut c_char>());
buf = &mut buf[offset..];
addresses.push(buf.as_mut_ptr());
let l = match record {
RData::A(addr) => {
let octets = addr.octets();
let l = octets.len();
if buf.len() < l {
return Err(Errno::ERANGE.into());
}
buf[..l].copy_from_slice(&octets);
l
}
RData::AAAA(addr) => {
let octets = addr.octets();
let l = octets.len();
if buf.len() < l {
return Err(Errno::ERANGE.into());
}
buf[..l].copy_from_slice(&octets);
l
}
_ => unreachable!(),
};
buf = &mut buf[l..];
}
addresses.push(std::ptr::null_mut());
let (_, addr_list) = write_vector_to_buf(buf, addresses)?;
// Finally, populate the hostent structure
host.h_name = name_dest;
host.h_aliases = aliases_ptr;
host.h_addr_list = addr_list as *mut *mut c_char;
match first_rdata {
RData::A(_) => {
host.h_addrtype = AF_INET;
host.h_length = 4;
}
RData::AAAA(_) => {
host.h_addrtype = AF_INET6;
host.h_length = 16;
}
_ => return Err(Errno::EBADMSG.into()),
}
Ok(DnsResult::Found)
}
// TODO: error handling codes chosen a bit sloppily
pub unsafe fn records_to_gaih_addr(
lookup: Lookup,
mut buf: &mut [u8],
) -> std::io::Result<*mut gaih_addrtuple> {
const GAIH_ADDRTUPLE_SZ: usize = size_of::<gaih_addrtuple>();
let mut ret = std::ptr::null_mut();
let mut prev_link: *mut *mut gaih_addrtuple = std::ptr::null_mut();
let mut name_dest;
for record in lookup.record_iter() {
// First add the name to the buffer
(buf, name_dest) = write_record_name_to_buf(record, buf)?;
// Then add the tuple with the address
let offset = buf.as_ptr().align_offset(align_of::<gaih_addrtuple>());
let l = GAIH_ADDRTUPLE_SZ;
if buf.len() < offset + l {
return Err(Errno::ERANGE.into());
}
let tuple = &mut *(buf.as_mut_ptr().add(offset) as *mut gaih_addrtuple);
tuple.next = std::ptr::null_mut();
tuple.name = name_dest;
tuple.scopeid = 0; // how to set tuple.scopeid correctly??? Use a custom trust_dns_resolver::ConnectionProvider?
set_if_valid(prev_link, &mut *tuple); // link from previous tuple to this tuple
prev_link = &mut (*tuple).next;
match record.data() {
Some(RData::A(addr)) => {
tuple.family = AF_INET;
tuple.addr[0] = std::mem::transmute_copy(&addr.octets());
}
Some(RData::AAAA(addr)) => {
tuple.family = AF_INET6;
tuple.addr = std::mem::transmute_copy(&addr.octets());
}
Some(_) => return Err(Errno::EBADMSG.into()),
None => return Err(Errno::ENODATA.into()),
}
if ret == std::ptr::null_mut() {
ret = tuple;
}
buf = &mut buf[(offset + l)..];
}
Ok(ret)
}

81
nss/src/helpers.rs Normal file
View File

@ -0,0 +1,81 @@
// SPDX-FileCopyrightText: Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: GPL-3.0-or-later
use {
hickory_proto::rr::resource::Record,
libc::c_char,
nix::errno::Errno,
once_cell::sync::Lazy,
std::ffi::CString,
std::mem::{align_of, size_of},
};
static RUNTIME: Lazy<std::io::Result<tokio::runtime::Runtime>> = Lazy::new(|| {
// The runtime should remain single-threaded, some
// programs depend on it (e.g. programs calling unshare())
let rt = tokio::runtime::Builder::new_current_thread()
.enable_time()
.enable_io()
.build()?;
Ok(rt)
});
pub fn write_record_name_to_buf<'a>(
record: &Record,
buf: &'a mut [u8],
) -> std::io::Result<(&'a mut [u8], *mut c_char)> {
use std::io::{Error, ErrorKind};
let name = CString::new(record.name().to_utf8())
.map_err(|e| Error::new(ErrorKind::InvalidData, e.to_string()))?;
let offset = buf.as_ptr().align_offset(align_of::<libc::c_char>());
let name_src = name.as_bytes_with_nul();
let l = name_src.len();
if buf.len() < offset + l {
return Err(Errno::ERANGE.into());
}
let name_dest = unsafe { buf.as_mut_ptr().add(offset) };
unsafe {
std::ptr::copy_nonoverlapping(name_src.as_ptr(), name_dest, l);
}
Ok((&mut buf[(offset + l)..], name_dest as *mut c_char))
}
pub fn write_vector_to_buf<'a, T>(
buf: &'a mut [u8],
v: Vec<T>,
) -> std::io::Result<(&'a mut [u8], *mut T)> {
let offset = buf.as_ptr().align_offset(align_of::<*mut T>());
let l = size_of::<*mut c_char>() * v.len();
if buf.len() < offset + l {
return Err(Errno::ERANGE.into());
}
let buf = &mut buf[offset..];
let dest = buf.as_mut_ptr().cast::<T>();
unsafe {
std::ptr::copy_nonoverlapping(v.as_ptr() as *const T, dest, l);
}
Ok((&mut buf[l..], dest))
}
pub fn set_if_valid<T>(ptr: *mut T, val: T) {
if !ptr.is_null() {
unsafe { *ptr = val };
}
}
pub fn block_on<F>(f: F) -> anyhow::Result<F::Output>
where
F: std::future::Future,
{
// TODO: use std::panic::catch_unwind() to be resilient
// to OOM problems
use std::ops::Deref;
match RUNTIME.deref() {
Ok(rt) => Ok(rt.block_on(async { f.await })),
Err(e) => anyhow::bail!("Unable to start tokio runtime: {}", e),
}
}

46
nss/src/lib.rs Normal file
View File

@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: GPL-3.0-or-later
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
mod gethostbyaddr;
mod gethostbyname;
mod helpers;
pub mod nss_api;
mod nss_bindings;
mod policy_checker;
use std::str::FromStr;
struct LevelFilter(log::LevelFilter);
impl FromStr for LevelFilter {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
let lv = match s.to_lowercase().as_str() {
"off" => log::LevelFilter::Off,
"error" => log::LevelFilter::Error,
"warn" => log::LevelFilter::Warn,
"info" => log::LevelFilter::Info,
"debug" => log::LevelFilter::Debug,
"trace" => log::LevelFilter::Trace,
_ => return Err(()),
};
Ok(Self(lv))
}
}
ld_preload::ld_preload_init! {
{
let level = std::env::var("NSS_MALCONTENT_LOG")
.ok()
.and_then(|level| { LevelFilter::from_str(&level).ok() })
.map(|x| x.0)
.unwrap_or(log::LevelFilter::Warn);
let _ = syslog::init(syslog::Facility::LOG_AUTHPRIV, level, Some("nss_malcontent"));
}
}

193
nss/src/nss_api.rs Normal file
View File

@ -0,0 +1,193 @@
// SPDX-FileCopyrightText: Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: GPL-3.0-or-later
use {
crate::helpers::{block_on, set_if_valid},
crate::nss_bindings::{gaih_addrtuple, nss_status, HErrno},
crate::{gethostbyaddr, gethostbyname},
libc::{hostent, size_t, socklen_t, AF_INET},
nix::errno::Errno,
std::os::raw::{c_char, c_int, c_void},
std::ptr,
};
// -------------- by host ---------------
#[no_mangle]
pub unsafe extern "C" fn _nss_malcontent_gethostbyname4_r(
name: *const c_char,
pat: *mut *mut gaih_addrtuple,
buffer: *mut c_char,
buflen: size_t,
errnop: *mut Errno,
h_errnop: *mut HErrno,
ttlp: *mut i32,
) -> nss_status {
log::trace!("--------------- _nss_malcontent_gethostbyname4_r ---------------------");
let mut args = gethostbyname::Args {
name,
family: 0,
result: gethostbyname::Result::V4(pat),
buffer,
buflen,
errnop,
h_errnop,
ttlp,
};
match block_on(async { gethostbyname::with(&mut args).await }) {
Ok(status) => status,
Err(runtime_error) => {
log::error!("gethostbyname4_r: {}", runtime_error);
set_if_valid(args.h_errnop, HErrno::Internal);
nss_status::NSS_STATUS_TRYAGAIN
}
}
}
#[no_mangle]
pub unsafe extern "C" fn _nss_malcontent_gethostbyname3_r(
name: *const c_char,
af: c_int,
host: *mut hostent,
buffer: *mut c_char,
buflen: size_t,
errnop: *mut Errno,
h_errnop: *mut HErrno,
ttlp: *mut i32,
canonp: *mut *mut char,
) -> nss_status {
log::trace!("--------------- _nss_malcontent_gethostbyname3_r ---------------------");
let mut args = gethostbyname::Args {
name,
family: af,
result: gethostbyname::Result::V3(host),
buffer,
buflen,
errnop,
h_errnop,
ttlp,
};
match block_on(async { gethostbyname::with(&mut args).await }) {
Ok(status) => {
if !host.is_null() {
set_if_valid(canonp, (*host).h_name as *mut char);
}
status
}
Err(runtime_error) => {
log::error!("gethostbyname3_r: {}", runtime_error);
set_if_valid(h_errnop, HErrno::Internal);
nss_status::NSS_STATUS_TRYAGAIN
}
}
}
#[no_mangle]
pub unsafe extern "C" fn _nss_malcontent_gethostbyname2_r(
name: *const c_char,
af: c_int,
host: *mut hostent,
buffer: *mut c_char,
buflen: size_t,
errnop: *mut Errno,
h_errnop: *mut HErrno,
) -> nss_status {
log::trace!("--------------- _nss_malcontent_gethostbyname2_r ---------------------");
_nss_malcontent_gethostbyname3_r(
name,
af,
host,
buffer,
buflen,
errnop,
h_errnop,
ptr::null_mut(),
ptr::null_mut(),
)
}
#[no_mangle]
pub unsafe extern "C" fn _nss_malcontent_gethostbyname_r(
name: *const c_char,
host: *mut hostent,
buffer: *mut c_char,
buflen: size_t,
errnop: *mut Errno,
h_errnop: *mut HErrno,
) -> nss_status {
log::trace!("--------------- _nss_malcontent_gethostbyname_r ---------------------");
_nss_malcontent_gethostbyname3_r(
name,
AF_INET,
host,
buffer,
buflen,
errnop,
h_errnop,
ptr::null_mut(),
ptr::null_mut(),
)
}
// ----------------- by addr -----------------
#[no_mangle]
pub unsafe extern "C" fn _nss_malcontent_gethostbyaddr2_r(
addr: *const c_void,
len: socklen_t,
af: c_int,
host: *mut hostent,
buffer: *mut c_char,
buflen: size_t,
errnop: *mut Errno,
h_errnop: *mut HErrno,
ttlp: *mut i32,
) -> nss_status {
log::trace!("--------------- _nss_malcontent_gethostbyaddr2_r ---------------------");
set_if_valid(errnop, nix::errno::from_i32(0));
set_if_valid(h_errnop, HErrno::Success);
match block_on(async {
gethostbyaddr::with(addr, len, af, host, buffer, buflen, errnop, h_errnop, ttlp).await
}) {
Ok(status) => status,
Err(runtime_error) => {
log::error!("gethostbyaddr2_r: {}", runtime_error);
set_if_valid(h_errnop, HErrno::Internal);
nss_status::NSS_STATUS_TRYAGAIN
}
}
}
#[no_mangle]
pub unsafe extern "C" fn _nss_malcontent_gethostbyaddr_r(
addr: *const c_void,
len: socklen_t,
af: c_int,
host: *mut hostent,
buffer: *mut c_char,
buflen: size_t,
errnop: *mut Errno,
h_errnop: *mut HErrno,
) -> nss_status {
log::trace!("--------------- _nss_malcontent_gethostbyaddr_r ---------------------");
_nss_malcontent_gethostbyaddr2_r(
addr,
len,
af,
host,
buffer,
buflen,
errnop,
h_errnop,
ptr::null_mut(),
)
}

5
nss/src/nss_bindings.rs Normal file
View File

@ -0,0 +1,5 @@
// SPDX-FileCopyrightText: Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: GPL-3.0-or-later
#![allow(dead_code)]
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));

View File

@ -0,0 +1,66 @@
// SPDX-FileCopyrightText: Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: GPL-3.0-or-later
use {
serde::{Deserialize, Serialize},
std::net::IpAddr,
zbus::{dbus_proxy, Result},
};
#[dbus_proxy(
default_service = "com.endlessm.ParentalControls",
interface = "com.endlessm.ParentalControls.Dns",
default_path = "/com/endlessm/ParentalControls/Dns",
gen_blocking = false
)]
trait MalcontentDns {
fn get_dns(&self) -> Result<Restrictions>;
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
pub struct Restriction {
pub ip: IpAddr,
pub hostname: String,
}
pub type Restrictions = Vec<Restriction>;
pub async fn restrictions() -> anyhow::Result<Vec<Restriction>, anyhow::Error> {
#[cfg(not(feature = "integration_test"))]
let connection = {
// This is the normal behavior
zbus::Connection::session().await?
};
#[cfg(feature = "integration_test")]
let connection = {
use tokio::net::TcpStream;
// During integration testing, we want to connect to a private
// bus name to avoid clashes with existing system services.
let socketaddr = std::env::var("TEST_DBUS_SOCKET")
.expect("The test has not set the TEST_DBUS_SOCKET environment variable");
let socket = loop {
match TcpStream::connect(&socketaddr).await {
Ok(stream) => break stream,
Err(e) if e.kind() == std::io::ErrorKind::ConnectionRefused => {
tokio::task::yield_now().await;
continue;
}
Err(e) => anyhow::bail!(e),
}
};
log::trace!("connecting to DBus on {}", socket.local_addr()?);
zbus::ConnectionBuilder::tcp_stream(socket)
.p2p()
.build()
.await?
};
let proxy = MalcontentDnsProxy::builder(&connection).build().await?;
let restrictions = proxy.get_dns().await;
log::trace!("malcontent-nss: user restrictions are {:?}", &restrictions);
Ok(restrictions?)
}

View File

@ -0,0 +1,115 @@
// SPDX-FileCopyrightText: Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: GPL-3.0-or-later
mod dbus;
use {
anyhow::Result,
hickory_proto::rr::domain::Name as DomainName,
hickory_resolver::config as dns_config,
hickory_resolver::TokioAsyncResolver,
nix::unistd::{getuid, Uid},
once_cell::sync::Lazy,
std::collections::HashMap,
std::net::{SocketAddr, TcpStream},
std::sync::{Arc, RwLock},
std::time::Duration,
};
pub use self::dbus::{Restriction, Restrictions};
const DNS_UDP_PORT: u16 = 53;
const DNS_TLS_PORT: u16 = 853;
pub static POLICY_CHECKER: Lazy<PolicyChecker> = Lazy::new(|| PolicyChecker::new());
// TODO: accept notifications about config changes
pub struct PolicyChecker {
resolvers: RwLock<HashMap<Uid, Option<Arc<TokioAsyncResolver>>>>,
}
impl PolicyChecker {
pub fn new() -> Self {
Self {
resolvers: RwLock::new(HashMap::new()),
}
}
async fn restrictions(&self, user: Uid) -> Result<Restrictions> {
if user.is_root() {
return Ok(vec![]);
};
dbus::restrictions().await
}
pub async fn resolver(&self) -> Result<Option<Arc<TokioAsyncResolver>>> {
let user = getuid(); // account for processes changing user during execution
log::trace!("querying PolicyChecker::resolver() for user {}", user);
// Check if already cached
{
let ro_resolvers = self.resolvers.read().unwrap();
if let Some(resolver) = ro_resolvers.get(&user) {
return Ok(resolver.clone());
}
}
// Else, initialize and cache it
{
let mut rw_resolvers = self.resolvers.write().unwrap();
if let Some(resolver) = rw_resolvers.get(&user) {
return Ok(resolver.clone());
}
// try first to prime resolver with DoT implementation,
// fallback to unencrypted only if not available.
let restrictions = self.restrictions(user).await?;
let resolver = if !restrictions.is_empty() {
let resolver = TokioAsyncResolver::tokio(
resolver_config_for(restrictions),
dns_config::ResolverOpts::default(),
);
Some(Arc::new(resolver))
} else {
None
};
rw_resolvers.insert(user, resolver.clone());
Ok(resolver)
}
}
}
fn resolver_config_for(restrictions: Vec<Restriction>) -> dns_config::ResolverConfig {
use dns_config::NameServerConfigGroup as NsConfig;
let resolver_config_group =
restrictions
.into_iter()
.fold(NsConfig::new(), |mut config, restr| {
let supports_tls = TcpStream::connect_timeout(
&SocketAddr::new(restr.ip, DNS_TLS_PORT),
Duration::from_secs(1),
)
.is_ok();
let new_config = if supports_tls {
NsConfig::from_ips_tls(&[restr.ip], DNS_TLS_PORT, restr.hostname, true)
} else {
NsConfig::from_ips_clear(&[restr.ip], DNS_UDP_PORT, true)
};
config.merge(new_config);
config
});
let basename = gethostname::gethostname()
.as_os_str()
.to_str()
.map(|hn| DomainName::from_labels(hn.split('.')).ok())
.flatten()
.map(|hn| hn.base_name());
dns_config::ResolverConfig::from_parts(basename, vec![], resolver_config_group)
}

142
nss/tests/common/dbus.rs Normal file
View File

@ -0,0 +1,142 @@
// SPDX-FileCopyrightText: Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: GPL-3.0-or-later
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/src/policy_checker/dbus.rs"
));
use {
std::sync::atomic::{AtomicUsize, Ordering},
tokio_util::sync::CancellationToken,
zbus::dbus_interface,
};
#[derive(Debug)]
pub struct MalcontentDBusMock {
responses: Vec<Restrictions>,
invocations_left: AtomicUsize,
}
#[dbus_interface(name = "com.endlessm.ParentalControls.Dns")]
impl MalcontentDBusMock {
fn get_dns(&mut self) -> Restrictions {
let restrictions = self
.responses
.pop()
.expect("MockError: DBus mock is saturated");
self.invocations_left.fetch_sub(1, Ordering::SeqCst);
restrictions
}
}
impl MalcontentDBusMock {
pub fn new(mut responses: Vec<Restrictions>) -> Self {
responses.reverse(); // We pop responses from the back, so...
Self {
invocations_left: AtomicUsize::new(responses.len()),
responses,
}
}
}
impl Drop for MalcontentDBusMock {
fn drop(&mut self) {
let invocations_left = self.invocations_left.load(Ordering::Acquire);
assert_eq!(
invocations_left, 0,
"MockError: During teardown, {} invocations are still left on the mock object",
invocations_left
);
}
}
pub struct DBusMockServer {
handle: std::thread::JoinHandle<Result<()>>,
cancellation: CancellationToken,
}
impl DBusMockServer {
pub fn new(responses: Vec<Restrictions>) -> Result<Self> {
let token = CancellationToken::new();
let cloned_token = token.clone();
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
std::env::set_var("TEST_DBUS_SOCKET", format!("{}", listener.local_addr()?));
let handle = std::thread::spawn(move || {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(Self::spawn_async(responses, listener, cloned_token))
});
Ok(Self {
handle: handle,
cancellation: token,
})
}
pub fn stop(self) -> Result<()> {
self.cancellation.cancel();
self.handle.join().unwrap()
}
async fn spawn_async(
responses: Vec<Restrictions>,
listener: std::net::TcpListener,
cancellation_token: CancellationToken,
) -> Result<()> {
listener.set_nonblocking(true)?;
let listener = tokio::net::TcpListener::from_std(listener)?;
let guid = zbus::Guid::generate();
let mock = MalcontentDBusMock::new(responses);
let (stream, _) = listener
.accept()
.await
.expect("Server socket closed unexpectedly");
log::trace!("dbus mock server accepted client connection");
let _connection = zbus::ConnectionBuilder::tcp_stream(stream)
.server(&guid)
.p2p()
.auth_mechanisms(&[zbus::AuthMechanism::Anonymous])
.name("com.endlessm.ParentalControls")
.expect("Unable to serve given dbus name")
.serve_at("/com/endlessm/ParentalControls/Dns", mock)
.expect("Unable to server malcontent dbus mock object")
.build()
.await?;
tokio::select! {
_ = cancellation_token.cancelled() => {
Ok(())
}
_ = std::future::pending::<()>() => { unreachable!() }
}
}
}
pub struct DBusMockServerGuard {
mock: Option<DBusMockServer>,
}
impl DBusMockServerGuard {
pub fn new(responses: Vec<Restrictions>) -> Result<Self> {
Ok(Self {
mock: Some(DBusMockServer::new(responses)?),
})
}
}
impl Drop for DBusMockServerGuard {
fn drop(&mut self) {
self.mock
.take()
.unwrap()
.stop()
.expect("cannot stop dbus server mock");
}
}

148
nss/tests/common/mod.rs Normal file
View File

@ -0,0 +1,148 @@
// SPDX-FileCopyrightText: Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: GPL-3.0-or-later
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
#![allow(dead_code)]
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
mod dbus;
use {
anyhow::{anyhow, bail, ensure, Result},
libc::{freeaddrinfo, gai_strerror, getaddrinfo},
nix::sys::socket::{SockaddrLike as _, SockaddrStorage},
std::env,
std::ffi::{CStr, CString},
std::net::{IpAddr, Ipv4Addr, Ipv6Addr},
std::path::PathBuf,
std::process::Command,
std::str::FromStr,
};
pub use self::dbus::{DBusMockServerGuard, Restriction, Restrictions};
pub type Eai = EaiRetcode;
#[static_init::dynamic]
static GLOBAL_SETUP: () = {
let cdylib_path = test_cdylib::build_current_project();
let file_name = cdylib_path.file_name().unwrap();
let mut versioned_file_name = file_name.to_owned();
versioned_file_name.push(".2"); // required for NSS 3 modules
let dest = PathBuf::from(std::env::current_exe().unwrap()).with_file_name(versioned_file_name);
let _ = std::fs::remove_file(&dest);
std::fs::hard_link(&cdylib_path, &dest).expect(&format!(
"Cannot hard link {} to {}",
cdylib_path.display(),
dest.display()
));
};
/// Note that this should be called once per process; this implies that
/// each test calling this function is in a separate process too.
pub fn setup() -> Result<()> {
let _ = env_logger::builder()
.is_test(true)
.filter_level(log::LevelFilter::max())
.try_init();
std::env::set_var("NSS_MALCONTENT_LOG", "trace");
// setup NSS to load our cdylib, now versioned
let db = CString::new("hosts").unwrap();
let resolvers = CString::new("malcontent dns").unwrap();
let nss_config_status = unsafe { __nss_configure_lookup(db.as_ptr(), resolvers.as_ptr()) };
if nss_config_status != 0 {
panic!(
"Unable to configure NSS to load module: __nss_configure_lookup() returned {}",
nss_config_status
);
}
Ok(())
}
pub fn resolve_with_system(family: libc::c_int, host: &str) -> Result<IpAddr> {
let process = Command::new("getent")
.arg(match family {
libc::AF_INET => "ahostsv4",
libc::AF_INET6 => "ahostsv6",
_ => panic!("INET family must be either IPv4 or IPv6"),
})
.arg(host)
.output()?;
ensure!(
process.status.success(),
"Failed to run getent to check host IP"
);
let output = String::from_utf8(process.stdout)?;
log::trace!("system getent reports: {}", &output);
let addr_string = output
.as_str()
.split(' ')
.next()
.ok_or(anyhow!("Unparseable output from getent"))?;
Ok(IpAddr::from_str(&addr_string)?)
}
pub fn resolve_with_module(family: libc::c_int, hostname: &str) -> Result<Vec<IpAddr>> {
let c_hostname = CString::new(hostname).unwrap();
unsafe {
let mut addr = std::ptr::null_mut();
let hints = libc::addrinfo {
ai_family: family,
ai_flags: 0,
ai_socktype: 0,
ai_protocol: 0,
ai_addrlen: 0,
ai_addr: std::ptr::null_mut(),
ai_canonname: std::ptr::null_mut(),
ai_next: std::ptr::null_mut(),
};
let getaddrinfo_status =
getaddrinfo(c_hostname.as_ptr(), std::ptr::null(), &hints, &mut addr);
let error = CStr::from_ptr(gai_strerror(getaddrinfo_status));
assert_eq!(
getaddrinfo_status,
Eai::Success.0,
"Should have gotten hostname for {}, instead got {}",
hostname,
error.to_str().unwrap()
);
let ips = convert_addrinfo(addr)?;
freeaddrinfo(addr);
Ok(ips)
}
}
unsafe fn convert_addrinfo(addrs: *const libc::addrinfo) -> Result<Vec<IpAddr>> {
let mut ips = vec![];
let mut addr = addrs;
while addr != std::ptr::null() {
let addr_storage = SockaddrStorage::from_raw((*addr).ai_addr, Some((*addr).ai_addrlen))
.ok_or(anyhow!("Garbled addrinfo from getaddrinfo()"))?;
if let Some(addr) = addr_storage.as_sockaddr_in() {
ips.push(IpAddr::V4(Ipv4Addr::from(addr.ip())));
} else if let Some(addr) = addr_storage.as_sockaddr_in6() {
ips.push(IpAddr::V6(Ipv6Addr::from(addr.ip())));
} else {
bail!("addrinfo is not either an IPv4 or IPv6 address")
}
addr = (*addr).ai_next;
}
ips.sort();
ips.dedup();
Ok(ips)
}

View File

@ -0,0 +1,243 @@
// SPDX-FileCopyrightText: Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: GPL-3.0-or-later
mod common;
use {
crate::common::{Eai, Restriction, Restrictions},
anyhow::Result,
elf::ElfStream,
libc::{freeaddrinfo, gai_strerror, getaddrinfo},
once_cell::sync::Lazy,
rusty_fork::rusty_fork_test,
std::net::{IpAddr, Ipv4Addr, Ipv6Addr},
std::os::unix::fs::FileExt,
std::path::PathBuf,
};
static DNS0_PARENTALCONTROL_ADDRS: Lazy<Restrictions> = Lazy::new(|| {
vec![
Restriction {
ip: IpAddr::V4(Ipv4Addr::new(193, 110, 81, 1)),
hostname: "kids.dns0.eu".into(),
},
Restriction {
ip: IpAddr::V4(Ipv4Addr::new(185, 253, 5, 1)),
hostname: "kids.dns0.eu".into(),
},
Restriction {
ip: IpAddr::V6(Ipv6Addr::new(0x2a0f, 0xfc80, 0, 0, 0, 0, 0, 0x1)),
hostname: "kids.dns0.eu".into(),
},
Restriction {
ip: IpAddr::V6(Ipv6Addr::new(0x2a0f, 0xfc81, 0, 0, 0, 0, 0, 0x1)),
hostname: "kids.dns0.eu".into(),
},
]
});
static CLOUDFLARE_PARENTALCONTROL_ADDRS: Lazy<Restrictions> = Lazy::new(|| {
vec![
Restriction {
ip: IpAddr::V4(Ipv4Addr::new(1, 1, 1, 3)),
hostname: "family.cloudflare-dns.com".into(),
},
Restriction {
ip: IpAddr::V4(Ipv4Addr::new(1, 0, 0, 3)),
hostname: "family.cloudflare-dns.com".into(),
},
Restriction {
ip: IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1113)),
hostname: "family.cloudflare-dns.com".into(),
},
Restriction {
ip: IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1003)),
hostname: "family.cloudflare-dns.com".into(),
},
]
});
static NO_RESTRICTIONS: Lazy<Restrictions> = Lazy::new(|| vec![]);
#[test]
fn sanity_check() -> Result<()> {
let current_exe = PathBuf::from(std::env::current_exe().unwrap());
let myself = std::fs::File::open(&current_exe)?;
let mut elf_stream: ElfStream<elf::endian::AnyEndian, _> =
elf::ElfStream::open_stream(&myself)?;
let dynamic_section = elf_stream.dynamic()?.expect("No dynamic table");
let rpath_off = dynamic_section
.iter()
.find(|x| x.d_tag == elf::abi::DT_RPATH)
.map(|x| x.d_val())
.expect("No DT_RPATH found in test executable ELF dyn section");
let strtable = dynamic_section
.into_iter()
.find(|x| x.d_tag == elf::abi::DT_STRTAB)
.map(|x| x.d_ptr())
.expect("No DT_STRTAB found in test executable ELF dyn section");
let mut buf = [0u8; 1024];
let _ = myself.read_at(buf.as_mut_slice(), strtable + rpath_off)?;
let end = buf
.iter()
.position(|c| *c == 0u8)
.expect(&format!("RPATH is bigger than {} bytes", buf.len()));
let rpath = std::str::from_utf8(&buf[..end])?;
assert_eq!(rpath, "$ORIGIN");
common::setup()?;
let lib = current_exe.parent().unwrap().join("libnss_malcontent.so.2");
assert!(lib.exists());
Ok(())
}
rusty_fork_test! {
#[test]
fn application_dns_is_nodata() {
common::setup().unwrap();
let _dbus = common::DBusMockServerGuard::new(vec![CLOUDFLARE_PARENTALCONTROL_ADDRS.clone()]).unwrap();
let hostname = std::ffi::CString::new("use-application-dns.net").unwrap();
unsafe {
let mut addr = std::ptr::null_mut();
let getaddrinfo_status = getaddrinfo(
hostname.as_ptr(),
std::ptr::null(),
std::ptr::null(),
&mut addr,
);
let error = std::ffi::CStr::from_ptr(gai_strerror(getaddrinfo_status));
assert_eq!(
getaddrinfo_status,
Eai::NoData.0,
"Should have gotten no data (NODATA), instead got {}",
error.to_str().unwrap()
);
freeaddrinfo(addr);
};
}
#[test]
fn getaddrinfo_resolution() {
common::setup().unwrap();
const HOSTNAME: &str = "gnome.org";
let _dbus = common::DBusMockServerGuard::new(vec![CLOUDFLARE_PARENTALCONTROL_ADDRS.clone()]).unwrap();
unsafe {
let mut addr = std::ptr::null_mut();
let hostname = std::ffi::CString::new(HOSTNAME).unwrap();
let getaddrinfo_status = getaddrinfo(
hostname.as_ptr(),
std::ptr::null(),
std::ptr::null(),
&mut addr,
);
let error = std::ffi::CStr::from_ptr(gai_strerror(getaddrinfo_status));
assert_eq!(
getaddrinfo_status,
Eai::Success.0,
"Should have gotten an hostname, instead got {}",
error.to_str().unwrap()
);
freeaddrinfo(addr);
};
}
#[test]
fn wikipedia_is_unrestricted() {
common::setup().unwrap();
const HOSTNAME: &str = "wikipedia.org";
let _dbus = common::DBusMockServerGuard::new(vec![CLOUDFLARE_PARENTALCONTROL_ADDRS.clone()]).unwrap();
for family in [libc::AF_INET, libc::AF_INET6] {
let system_addr = common::resolve_with_system(family, HOSTNAME).unwrap();
let our_addrs = common::resolve_with_module(family, HOSTNAME).unwrap();
assert!(our_addrs.contains(&system_addr));
}
}
#[test]
fn dns0_adultsites_are_restricted() {
common::setup().unwrap();
const HOSTNAME: &str = "pornhub.com";
let _dbus = common::DBusMockServerGuard::new(vec![DNS0_PARENTALCONTROL_ADDRS.clone()]).unwrap();
let our_addrs_ipv4 = common::resolve_with_module(libc::AF_INET, HOSTNAME).unwrap();
let our_addrs_ipv6 = common::resolve_with_module(libc::AF_INET, HOSTNAME).unwrap();
assert!(
our_addrs_ipv4.is_empty(),
"Resolver answered with {:?}, should be empty",
our_addrs_ipv4
);
assert!(
our_addrs_ipv6.is_empty(),
"Resolver answered with {:?}, should be empty",
our_addrs_ipv6
);
}
#[test]
fn cloudflare_adultsite_is_restricted_ipv4() {
common::setup().unwrap();
const HOSTNAME: &str = "nudity.testcategory.com";
let _dbus = common::DBusMockServerGuard::new(vec![CLOUDFLARE_PARENTALCONTROL_ADDRS.clone()]).unwrap();
let system_addr = common::resolve_with_system(libc::AF_INET, HOSTNAME).unwrap();
let our_addrs = common::resolve_with_module(libc::AF_INET, HOSTNAME).unwrap();
assert!(
!our_addrs.contains(&system_addr),
"Resolver answered with {:?}, should not contain {}",
our_addrs,
system_addr
);
assert_eq!(our_addrs, [IpAddr::V4(Ipv4Addr::UNSPECIFIED)]);
}
#[test]
fn cloudflare_adultsite_is_restricted_ipv6() {
common::setup().unwrap();
const HOSTNAME: &str = "nudity.testcategory.com";
let _dbus = common::DBusMockServerGuard::new(vec![CLOUDFLARE_PARENTALCONTROL_ADDRS.clone()]).unwrap();
let system_addr = common::resolve_with_system(libc::AF_INET6, HOSTNAME).unwrap();
let our_addrs = common::resolve_with_module(libc::AF_INET6, HOSTNAME).unwrap();
assert!(
!our_addrs.contains(&system_addr),
"Resolver answered with {:?}, should not contain {}",
our_addrs,
system_addr
);
assert_eq!(our_addrs, [IpAddr::V6(Ipv6Addr::UNSPECIFIED)]);
}
#[test]
fn privileged_user_bypasses_restrictions() {
common::setup().unwrap();
const HOSTNAME: &str = "malware.testcategory.com";
let _dbus = common::DBusMockServerGuard::new(vec![NO_RESTRICTIONS.clone()]).unwrap();
for family in [libc::AF_INET, libc::AF_INET6] {
let system_addr = common::resolve_with_system(family, HOSTNAME).unwrap();
let our_addrs = common::resolve_with_module(family, HOSTNAME).unwrap();
assert!(our_addrs.contains(&system_addr));
}
}
}

43
nss/wrapper.hpp Normal file
View File

@ -0,0 +1,43 @@
/*
// SPDX-FileCopyrightText: Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: GPL-3.0-or-later
*/
#include <nss.h>
#include <netdb.h>
// Work around enums in netdb.h defined as macros instead :-p
enum class HErrno : int {
Success = 0,
HostNotFound = HOST_NOT_FOUND,
TryAgain = TRY_AGAIN,
NoRecovery = NO_RECOVERY,
NoData = NO_DATA,
#ifdef __USE_MISC
Internal = NETDB_INTERNAL,
#endif
};
enum class EaiRetcode : int {
Success = 0,
BadFlags = EAI_BADFLAGS,
NoName = EAI_NONAME,
Fail = EAI_FAIL,
Family = EAI_FAMILY,
SockType = EAI_SOCKTYPE,
Service = EAI_SERVICE,
Memory = EAI_MEMORY,
System = EAI_SYSTEM,
Overflow = EAI_OVERFLOW,
#ifdef __USE_GNU
NoData = EAI_NODATA,
AddrFamily = EAI_ADDRFAMILY,
InProgress = EAI_INPROGRESS,
Canceled = EAI_CANCELED,
NotCanceled = EAI_NOTCANCELED,
AllDone = EAI_ALLDONE,
Interrupted = EAI_INTR,
IdnEncode = EAI_IDN_ENCODE,
#endif /* __USE_GNU */
};