diff --git a/accounts-service/com.endlessm.ParentalControls.Dns.xml b/accounts-service/com.endlessm.ParentalControls.Dns.xml new file mode 100644 index 0000000..fe5967c --- /dev/null +++ b/accounts-service/com.endlessm.ParentalControls.Dns.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + diff --git a/accounts-service/com.endlessm.ParentalControls.policy.in b/accounts-service/com.endlessm.ParentalControls.policy.in index ceae227..295f301 100644 --- a/accounts-service/com.endlessm.ParentalControls.policy.in +++ b/accounts-service/com.endlessm.ParentalControls.policy.in @@ -40,6 +40,46 @@ + + Change your own DNS servers + Authentication is required to change your DNS servers. + + auth_admin_keep + auth_admin_keep + auth_admin_keep + + + + + Read your own DNS servers + Authentication is required to read your DNS servers. + + yes + yes + yes + + + + + Change another user’s DNS servers + Authentication is required to change another user’s DNS servers. + + auth_admin_keep + auth_admin_keep + auth_admin_keep + + + + + Read another user’s DNS servers + Authentication is required to read another user’s DNS servers. + + auth_admin_keep + auth_admin_keep + auth_admin_keep + + + Change your own session limits Authentication is required to change your session limits. diff --git a/accounts-service/com.endlessm.ParentalControls.rules.in b/accounts-service/com.endlessm.ParentalControls.rules.in index fa021fa..be6a3b6 100644 --- a/accounts-service/com.endlessm.ParentalControls.rules.in +++ b/accounts-service/com.endlessm.ParentalControls.rules.in @@ -24,6 +24,8 @@ polkit.addRule(function(action, subject) { * needing an additional polkit authorisation dialogue. */ if ((action.id == "com.endlessm.ParentalControls.AppFilter.ReadOwn" || 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.ReadAny") && subject.active && subject.local && diff --git a/accounts-service/meson.build b/accounts-service/meson.build index 198692c..967cfff 100644 --- a/accounts-service/meson.build +++ b/accounts-service/meson.build @@ -9,6 +9,7 @@ i18n.merge_file( dbus_interfaces = [ 'com.endlessm.ParentalControls.AccountInfo', 'com.endlessm.ParentalControls.AppFilter', + 'com.endlessm.ParentalControls.Dns', 'com.endlessm.ParentalControls.SessionLimits', ] diff --git a/meson.build b/meson.build index 4f0cabd..c43df29 100644 --- a/meson.build +++ b/meson.build @@ -1,4 +1,4 @@ -project('malcontent', 'c', +project('malcontent', 'c', 'cpp', version : '0.12.0', meson_version : '>= 0.59.0', license: ['LGPL-2.1-or-later', 'GPL-2.0-or-later'], @@ -9,6 +9,10 @@ project('malcontent', 'c', ] ) +if get_option('buildtype') in ['debugoptimized', 'debug'] + add_project_arguments('-g3', language : ['c', 'cpp']) +endif + gnome = import('gnome') i18n = import('i18n') pkgconfig = import('pkgconfig') @@ -152,5 +156,10 @@ if get_option('ui').enabled() update_desktop_database: true, ) endif + +if get_option('nss').enabled() + subdir('nss') +endif + subdir('pam') subdir('po') diff --git a/meson_options.txt b/meson_options.txt index 726cac1..8f58ea3 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -9,6 +9,12 @@ option( type: 'string', description: 'directory for PAM modules' ) +option( + 'nss', + type: 'feature', + value: 'enabled', + description: 'enable NSS module support (for parental DNS controls)' +) option( 'ui', type: 'feature', diff --git a/nss/cares_init.cc b/nss/cares_init.cc new file mode 100644 index 0000000..3b5d855 --- /dev/null +++ b/nss/cares_init.cc @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Matteo Settenvini +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "cares_init.hh" + +#include + +#include +#include + +using namespace std::literals; + +auto malcontent::CAresLibrary::instance() -> std::shared_ptr { + if (auto ret = _instance.lock()) { + return ret; + } + + auto ret = std::make_shared(Private{}); + _instance = ret; + return ret; +} + +malcontent::CAresLibrary::CAresLibrary(Private) { + std::lock_guard guard { _init_mutex }; + int ret = ares_library_init(ARES_LIB_INIT_ALL); + if (ret != ARES_SUCCESS) { + const auto err = "ares_library_init: "s + ares_strerror(ret); + throw std::system_error(ret, std::generic_category(), err.c_str()); + } +} + +malcontent::CAresLibrary::~CAresLibrary() noexcept { + std::lock_guard guard { _init_mutex }; + ares_library_cleanup(); +} + +std::mutex malcontent::CAresLibrary::_init_mutex{}; + +std::weak_ptr malcontent::CAresLibrary::_instance{}; diff --git a/nss/cares_init.hh b/nss/cares_init.hh new file mode 100644 index 0000000..0df9092 --- /dev/null +++ b/nss/cares_init.hh @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Matteo Settenvini +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +namespace malcontent { + +class CAresLibrary { + struct Private {}; + +public: + static auto instance() -> std::shared_ptr; + + CAresLibrary(Private); + ~CAresLibrary() noexcept; + + CAresLibrary(const CAresLibrary&) = delete; + CAresLibrary(CAresLibrary&&) = delete; + +private: + static std::mutex _init_mutex; + static std::weak_ptr _instance; +}; + +} // ~ namespace malcontent diff --git a/nss/get_host.cc b/nss/get_host.cc new file mode 100644 index 0000000..2bdb97f --- /dev/null +++ b/nss/get_host.cc @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Matteo Settenvini +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "get_host.hh" +#include "helpers.hh" +#include "logger.hh" +#include "user_policy.hh" + +namespace /* anonymous */ { + +static const malcontent::UserPolicy USER_POLICY; + +} // ~ namespace anonymous + + +auto malcontent::get_host::by_name(ResolverArgs& args) -> nss_status { + set_if_valid(args.errnop, 0); + set_if_valid(args.h_errnop, HErrno::Success); + + try { + auto resolver = USER_POLICY.resolver({}); + if (!resolver) { + return nss_status::NSS_STATUS_NOTFOUND; + } + return resolver->resolve(args); + } catch(const std::exception& e) { + Logger::error(e.what()); + return nss_status::NSS_STATUS_UNAVAIL; + } +} + +auto malcontent::get_host::by_addr( + const void * /* addr */, + socklen_t /* len */, + int /* family */, + hostent * /* host */, + char * /* buffer */, + size_t /* buflen */, + int *errnop, + HErrno *h_errnop, + int32_t * /* ttlp */ +) -> nss_status { + set_if_valid(errnop, 0); + set_if_valid(h_errnop, HErrno::Success); + + // 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. + + return nss_status::NSS_STATUS_UNAVAIL; +} diff --git a/nss/get_host.hh b/nss/get_host.hh new file mode 100644 index 0000000..17957da --- /dev/null +++ b/nss/get_host.hh @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Matteo Settenvini +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "resolver.hh" + +namespace malcontent::get_host { + +auto by_name(ResolverArgs& args) -> nss_status; + +auto by_addr( + const void *addr, + socklen_t len, + int family, + hostent *host, + char *buffer, + size_t buflen, + int *errnop, + HErrno *h_errnop, + int32_t *ttlp +) -> nss_status; + +} // ~ namespace malcontent::get_host diff --git a/nss/helpers.cc b/nss/helpers.cc new file mode 100644 index 0000000..a76ea89 --- /dev/null +++ b/nss/helpers.cc @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Matteo Settenvini +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "helpers.hh" + +#include +#include +#include + +#include +#include + +auto malcontent::copy_hostent(const hostent& src, hostent& dst, void *buffer, size_t buflen) -> void { + dst.h_addrtype = src.h_addrtype; + dst.h_length = src.h_length; + + // copy name + { + auto name_len = std::strlen(src.h_name) + 1; + buffer = std::align(alignof(char), name_len, buffer, buflen); + if (buffer == nullptr) { + throw std::bad_alloc{}; + } + + dst.h_name = static_cast(std::memcpy(buffer, src.h_name, name_len)); + reinterpret_cast(buffer) += name_len; + buflen -= name_len; + } + + // copy addresses + { + auto begin_it = src.h_addr_list; + auto end_it = begin_it; + for (; *end_it != nullptr; ++end_it); + auto n = std::distance(begin_it, end_it); + + auto needs_bytes = (n + 1) * sizeof(char *); + buffer = std::align(alignof(char *), needs_bytes, buffer, buflen); + if (buffer == nullptr) { + throw std::bad_alloc {}; + } + dst.h_addr_list = static_cast(buffer); + reinterpret_cast(buffer) += n + 1; + + for (auto i = 0; i < n; ++i) { + buffer = std::align(alignof(char), src.h_length, buffer, buflen); + if (buffer == nullptr) { + throw std::bad_alloc {}; + } + + dst.h_addr_list[i] = static_cast( + std::memcpy(buffer, src.h_addr_list[i], src.h_length)); + reinterpret_cast(buffer) += src.h_length; + buflen -= src.h_length; + } + dst.h_addr_list[n] = nullptr; + } + + // copy aliases + { + auto begin_it = src.h_aliases; + auto end_it = begin_it; + for (; *end_it != nullptr; ++end_it); + + auto n = std::distance(begin_it, end_it); + auto needs_bytes = (n + 1) * sizeof(char *); + buffer = std::align(alignof(char *), needs_bytes, buffer, buflen); + if (buffer == nullptr) { + throw std::bad_alloc {}; + } + + dst.h_aliases = static_cast(buffer); + reinterpret_cast(buffer) += n + 1; + buflen -= needs_bytes; + + for (int i = 0; i < n; ++i) { + auto alias_len = strlen(*begin_it) + 1; + buffer = std::align(alignof(char), alias_len, buffer, buflen); + if (buffer == nullptr) { + throw std::bad_alloc{}; + } + + dst.h_addr_list[i] = static_cast( + std::memcpy(buffer, src.h_aliases[i], n)); + + reinterpret_cast(buffer) += alias_len; + buflen -= alias_len; + } + dst.h_aliases[n] = nullptr; + } +} + +auto malcontent::copy_hostent_to_gaih_addrtuple(const hostent& src, gaih_addrtuple *head, void *& buffer, size_t& buflen) -> gaih_addrtuple * { + auto name_len = std::strlen(src.h_name) + 1; + buffer = std::align(alignof(char), name_len, buffer, buflen); + if (buffer == nullptr) { + throw std::bad_alloc{}; + } + auto namep = static_cast(std::memcpy(buffer, src.h_name, name_len)); + reinterpret_cast(buffer) += name_len; + buflen -= name_len; + + for (auto addr = src.h_addr_list; *addr != nullptr; ++addr) { + buffer = std::align(alignof(gaih_addrtuple), sizeof(gaih_addrtuple), buffer, buflen); + if (buffer == nullptr) { + throw std::bad_alloc{}; + } + + auto tuple = static_cast(buffer); + + tuple->family = src.h_addrtype; + tuple->name = namep; + std::memcpy(tuple->addr, *addr, src.h_length); + tuple->next = head; // <-- We are reversing result order here, but it shouldn't be a problem, right? + tuple->scopeid = 0; // FIXME: I have no clue how to determine this + head = tuple; + + buflen -= sizeof(gaih_addrtuple); + } + + return head; +} diff --git a/nss/helpers.hh b/nss/helpers.hh new file mode 100644 index 0000000..c861732 --- /dev/null +++ b/nss/helpers.hh @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Matteo Settenvini +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include + +template +inline auto set_if_valid(T *ptr, T val) -> T * { + if (ptr != nullptr) { + *ptr = std::move(val); + } + return ptr; +} + +namespace malcontent { +auto copy_hostent(const hostent& src, hostent& dst, void *buffer, size_t buflen) -> void; +auto copy_hostent_to_gaih_addrtuple(const hostent& src, gaih_addrtuple *head, void *& buffer, size_t& buflen) -> gaih_addrtuple *; +} // ~ namespace malcontent diff --git a/nss/logger.cc b/nss/logger.cc new file mode 100644 index 0000000..5d7b294 --- /dev/null +++ b/nss/logger.cc @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Matteo Settenvini +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "logger.hh" + +#include +#include +#include + +#include +#include + +namespace /* anonymous */ { +auto get_log_priority() -> int { + auto level = getenv("NSS_MALCONTENT_LOG_LEVEL"); + if (level != nullptr) { + return atoi(level); + } + + #ifdef NDEBUG + return LOG_WARNING; + #else + return LOG_DEBUG; + #endif +} +} // ~ namespace anonymous + +std::unique_ptr malcontent::Logger::_instance = nullptr; +std::shared_mutex malcontent::Logger::_instance_mtx = {}; + +auto malcontent::Logger::debug(const std::string_view& msg) -> void { + instance().log(LOG_DEBUG, msg); +} + +auto malcontent::Logger::info(const std::string_view& msg) -> void { + instance().log(LOG_INFO, msg); +} + +auto malcontent::Logger::warn(const std::string_view& msg) -> void { + instance().log(LOG_WARNING, msg); +} + +auto malcontent::Logger::error(const std::string_view& msg) -> void { + instance().log(LOG_ERR, msg); +} + +auto malcontent::Logger::instance() -> Logger& { + { + std::shared_lock lock { _instance_mtx }; + if (_instance) return *_instance; + } + + // First invocation: construct lazily + std::unique_lock lock { _instance_mtx }; + if (!_instance) { + _instance = std::make_unique(); + } + return *_instance; +} + +auto malcontent::Logger::log(int priority, std::string_view msg) -> void { + if (priority <= _max_log_priority) { + msg = msg.substr(0, std::numeric_limits::max()); // clamp to avoid malicious overflows + ::syslog(priority, "%.*s", static_cast(msg.length()), msg.data()); + } +} + +malcontent::Logger::Logger() + : _max_log_priority(get_log_priority()) +{ + ::openlog("nss_malcontent", LOG_PID | LOG_CONS, LOG_AUTHPRIV); +} + +malcontent::Logger::~Logger() noexcept { + ::closelog(); +} diff --git a/nss/logger.hh b/nss/logger.hh new file mode 100644 index 0000000..be45fec --- /dev/null +++ b/nss/logger.hh @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Matteo Settenvini +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +namespace malcontent { + +class Logger { + friend std::unique_ptr std::make_unique(); + friend std::default_delete; + +public: + static auto debug(const std::string_view& msg) -> void; + static auto info(const std::string_view& msg) -> void; + static auto warn(const std::string_view& msg) -> void; + static auto error(const std::string_view& msg) -> void; + +private: + Logger(); + Logger(const Logger&) = delete; + Logger(Logger&&) = delete; + ~Logger() noexcept; + + auto operator=(const Logger&) = delete; + auto operator=(Logger&&) = delete; + + auto log(int priority, std::string_view msg) -> void; + + int _max_log_priority; + + static auto instance() -> Logger&; + + static std::unique_ptr _instance; + static std::shared_mutex _instance_mtx; +}; + +} // ~ namespace malcontent diff --git a/nss/meson.build b/nss/meson.build new file mode 100644 index 0000000..2b257a5 --- /dev/null +++ b/nss/meson.build @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: Matteo Settenvini +# SPDX-License-Identifier: GPL-3.0-or-later + +gnome = import('gnome') +gdbus_src = gnome.gdbus_codegen('parentalcontrol-dns', + sources: '../accounts-service/com.endlessm.ParentalControls.Dns.xml', + interface_prefix : 'com.endlessm.', + namespace : '', +) + +source_files = [ + 'cares_init.hh', + 'cares_init.cc', + 'get_host.hh', + 'get_host.cc', + 'helpers.hh', + 'helpers.cc', + 'logger.hh', + 'logger.cc', + 'nss_adapters.cc', + 'resolver.hh', + 'resolver.cc', + 'user_policy.hh', + 'user_policy.cc', + 'wrapper.hh', + gdbus_src, +] + +standard_args = [ + '-Wall', + '-Wextra', + '-pedantic', +] + +dependencies = [ + dependency('libcares', version: '>= 1.19.1'), + dependency('gio-2.0', version: '>= 2.26'), +] + +standards = ['cpp_std=c++20'] + +shared_library('nss_malcontent', + sources: source_files, + cpp_args: standard_args, + gnu_symbol_visibility: 'hidden', + extra_files: [ + '../accounts-service/com.endlessm.ParentalControls.Dns.xml', + '../accounts-service/com.endlessm.ParentalControls.policy.in', + '../accounts-service/com.endlessm.ParentalControls.rules.in', + ], + override_options: standards, + soversion: '2', + install: true, + dependencies: dependencies, +) + +test_source_files = [ + 'test_main.cc', + 'test_resolver.cc', + 'testsuite.hh', +] + +nss_tests = executable('nss_malcontent-tests', + sources: source_files + test_source_files, + cpp_args: ['-DNSS_MALCONTENT_TEST'], + override_options : standards, + dependencies: dependencies + [ dependency('glib-2.0', version: '>= 2.74') ]) + +test('nss_malcontent', + nss_tests, + env: [ + 'G_TEST_SRCDIR=@0@'.format(meson.current_source_dir()), + 'G_TEST_BUILDDIR=@0@'.format(meson.current_build_dir()), + ], + protocol: 'tap', +) diff --git a/nss/nss_adapters.cc b/nss/nss_adapters.cc new file mode 100644 index 0000000..353f6cc --- /dev/null +++ b/nss/nss_adapters.cc @@ -0,0 +1,214 @@ +// SPDX-FileCopyrightText: Matteo Settenvini +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "get_host.hh" +#include "helpers.hh" +#include "logger.hh" +#include "resolver.hh" +#include "wrapper.hh" + +#include +#include +#include +#include + +#include +#include +#include + +#define DSO_EXPORT extern "C" __attribute__((visibility("default"))) + +// -------------- by host --------------- + +DSO_EXPORT +auto _nss_malcontent_gethostbyname4_r( + const char *name, + gaih_addrtuple **pat, + char *buffer_c, + size_t buflen, + int *errnop, + HErrno *h_errnop, + int32_t *ttlp +) -> nss_status { + try { + void *buffer = buffer_c; + hostent he; + auto local_buffer = std::make_unique(buflen); + + auto args = malcontent::ResolverArgs { + name, + 0, + &he, + local_buffer.get(), + buflen, + errnop, + h_errnop, + ttlp + }; + + args.family = AF_INET; + auto ret_a = malcontent::get_host::by_name(args); + if (ret_a != NSS_STATUS_SUCCESS || pat == nullptr) { + return ret_a; + } + *pat = malcontent::copy_hostent_to_gaih_addrtuple(he, nullptr, buffer, buflen); + + args.family = AF_INET6; + auto ret_a6 = malcontent::get_host::by_name(args); + if (ret_a6 != NSS_STATUS_SUCCESS) { + return ret_a6; + } + *pat = malcontent::copy_hostent_to_gaih_addrtuple(he, *pat, buffer, buflen); + set_if_valid(ttlp, 0); // We don't know which one to keep, so 0. + return NSS_STATUS_SUCCESS; + + } catch(const std::bad_alloc&) { + set_if_valid(errnop, ERANGE); + set_if_valid(h_errnop, HErrno::Internal); + return nss_status::NSS_STATUS_TRYAGAIN; + + } catch(const std::exception& e) { + malcontent::Logger::error(std::string(__func__) + e.what()); + set_if_valid(h_errnop, HErrno::Internal); + return nss_status::NSS_STATUS_TRYAGAIN; + } +} + +DSO_EXPORT +auto _nss_malcontent_gethostbyname3_r( + const char *name, + int af, + hostent *host, + char *buffer, + size_t buflen, + int *errnop, + HErrno *h_errnop, + int32_t *ttlp, + char **canonp +) -> nss_status { + try { + auto args = malcontent::ResolverArgs { + name, + af, + host, + buffer, + buflen, + errnop, + h_errnop, + ttlp + }; + + auto status = malcontent::get_host::by_name(args); + if (host != nullptr) { + set_if_valid(canonp, host->h_name); + } + return status; + + } catch(const std::exception& e) { + malcontent::Logger::error(std::string(__func__) + e.what()); + set_if_valid(h_errnop, HErrno::Internal); + return nss_status::NSS_STATUS_TRYAGAIN; + } +} + +DSO_EXPORT +auto _nss_malcontent_gethostbyname2_r( + const char *name, + int af, + hostent *host, + char *buffer, + size_t buflen, + int *errnop, + HErrno *h_errnop +) -> nss_status { + return _nss_malcontent_gethostbyname3_r( + name, + af, + host, + buffer, + buflen, + errnop, + h_errnop, + nullptr, + nullptr + ); +} + +DSO_EXPORT +auto _nss_malcontent_gethostbyname_r( + const char *name, + hostent *host, + char *buffer, + size_t buflen, + int *errnop, + HErrno *h_errnop +) -> nss_status { + return _nss_malcontent_gethostbyname3_r( + name, + AF_INET, + host, + buffer, + buflen, + errnop, + h_errnop, + nullptr, + nullptr + ); +} + +// ----------------- by addr ----------------- + +DSO_EXPORT +auto _nss_malcontent_gethostbyaddr2_r( + const void *addr, + socklen_t len, + int af, + hostent *host, + char *buffer, + size_t buflen, + int *errnop, + HErrno *h_errnop, + int32_t *ttlp +) -> nss_status { + try { + return malcontent::get_host::by_addr( + addr, + len, + af, + host, + buffer, + buflen, + errnop, + h_errnop, + ttlp + ); + } catch(const std::exception& e) { + malcontent::Logger::error(std::string(__func__) + e.what()); + set_if_valid(h_errnop, HErrno::Internal); + return nss_status::NSS_STATUS_TRYAGAIN; + } +} + +DSO_EXPORT +auto _nss_malcontent_gethostbyaddr_r( + const void *addr, + socklen_t len, + int af, + hostent *host, + char *buffer, + size_t buflen, + int *errnop, + HErrno *h_errnop +) -> nss_status { + return _nss_malcontent_gethostbyaddr2_r( + addr, + len, + af, + host, + buffer, + buflen, + errnop, + h_errnop, + nullptr + ); +} diff --git a/nss/resolver.cc b/nss/resolver.cc new file mode 100644 index 0000000..24de3f2 --- /dev/null +++ b/nss/resolver.cc @@ -0,0 +1,304 @@ +// SPDX-FileCopyrightText: Matteo Settenvini +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "resolver.hh" + +#include "cares_init.hh" +#include "helpers.hh" +#include "logger.hh" +#include "wrapper.hh" + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +using namespace std::literals; + +namespace /* anonymous */ { + +constexpr const auto CANARY_HOSTNAME = "use-application-dns.net"sv; + +auto init_channel(const std::vector dns) -> ares_channel_t *; +auto setup_servers(ares_channel_t *channel, const std::vector dns) -> void; +auto parse_address(const std::string& addr, ares_addr_port_node& into) -> void; + +struct CAresAddrListDeletor { + auto operator()(ares_addr_port_node *list) const -> void { + if(list == nullptr) { + return; + } + operator()(list->next); + delete list; + } +}; + +using CAresAddrList = std::unique_ptr; + +struct CallbackArgs { + malcontent::Resolver *resolver; + malcontent::ResolverArgs *args; + nss_status return_status = NSS_STATUS_TRYAGAIN; +}; + +} // ~ anonymous namespace + +malcontent::Resolver::Resolver(std::vector dns) + : _ensure_cares_initialized(CAresLibrary::instance()) + , _channel(init_channel(std::move(dns))) +{ +} + +auto malcontent::Resolver::resolve(ResolverArgs& args) -> nss_status { + if (args.name == CANARY_HOSTNAME) { + // disable DoH if user did not explicitly turn it on + set_if_valid(args.errnop, 0); + set_if_valid(args.h_errnop, HErrno::HostNotFound); + return NSS_STATUS_SUCCESS; + } + + ns_type type; + switch(args.family) { + case AF_INET: { + type = ns_t_a; + break; + } + case AF_INET6: { + type = ns_t_a6; + break; + } + default: { + throw std::invalid_argument("only AF_INET and AF_INET6 are supported families"); + } + } + + CallbackArgs closure { .resolver = this, .args = &args }; + ares_query(_channel.get(), args.name, ns_c_in, type, &Resolver::resolve_cb, &closure); + + fd_set readers, writers; + FD_ZERO(&readers); + FD_ZERO(&writers); + int nfds = ares_fds(_channel.get(), &readers, &writers); + + int epollfd = epoll_create1(EPOLL_CLOEXEC); + if (epollfd == -1) { + throw std::system_error{ errno, std::system_category(), "epoll_create1" }; + } + + // translate from obsolete select() to epoll(), + // as calling process might use a big number + // of file descriptors. + epoll_event ev; + for (int& i = ev.data.fd = 0; i < nfds; ++i) { + if (FD_ISSET(i, &readers)) { + ev.events = EPOLLIN; + } else if (FD_ISSET(i, &writers)) { + ev.events = EPOLLOUT; + } else { + continue; + } + + if (epoll_ctl(epollfd, EPOLL_CTL_ADD, i, &ev) == -1) { + throw std::system_error{ errno, std::system_category(), "epoll_ctl" }; + } + } + + timeval tv; + while (true) { + epoll_event ev; + int timeout_ms = 0; + auto tvp = ares_timeout(_channel.get(), nullptr, &tv); + if (tvp != nullptr) { + timeout_ms = tvp->tv_sec * 1000 + tvp->tv_usec / 1000; + } + + auto nfds = epoll_wait(epollfd, &ev, 1, timeout_ms); + if (nfds == -1) { + throw std::system_error{ errno, std::system_category(), "epoll_wait" }; + } else if (nfds == 0) { + // timeout or end of processing + break; + } + + if (ev.events & EPOLLIN) { + ares_process_fd(_channel.get(), ev.data.fd, ARES_SOCKET_BAD); + } else if (ev.events & EPOLLOUT) { + ares_process_fd(_channel.get(), ARES_SOCKET_BAD, ev.data.fd); + } + } + + return closure.return_status; +} + +auto malcontent::Resolver::resolve_cb(void *arg, + int status, + int timeouts, + unsigned char *abuf, + int alen) -> void { + auto& closure = *static_cast(arg); + closure.return_status = closure.resolver->resolved(*closure.args, status, timeouts, abuf, alen); +} + +auto malcontent::Resolver::resolved(ResolverArgs& args, + int status, + int /*timeouts*/, + unsigned char *abuf, + int alen) -> nss_status { + using std::swap; + + switch (status) { + case ARES_SUCCESS: { + hostent *results = nullptr; + int parse_ret; + switch(args.family) { + case AF_INET: { + int n_ttls = 1; + ares_addrttl ttl; + parse_ret = ares_parse_a_reply(abuf, alen, &results, &ttl, &n_ttls); + set_if_valid(args.ttlp, n_ttls == 1 ? ttl.ttl : 0); + break; + } + case AF_INET6: { + int n_ttls = 1; + ares_addr6ttl ttl; + parse_ret = ares_parse_aaaa_reply(abuf, alen, &results, &ttl, &n_ttls); + set_if_valid(args.ttlp, n_ttls == 1 ? ttl.ttl : 0); + break; + } + default: { + throw std::invalid_argument("only AF_INET and AF_INET6 are supported families"); + } + } + + if (parse_ret != ARES_SUCCESS) { + set_if_valid(args.errnop, EAGAIN); + set_if_valid(args.h_errnop, HErrno::Internal); + return NSS_STATUS_TRYAGAIN; + } + + try { + copy_hostent(*results, *args.result, args.buffer, args.buflen); + ares_free_hostent(results); + } catch (const std::bad_alloc&) { + // buffer is too small + ares_free_hostent(results); + set_if_valid(args.errnop, ERANGE); + set_if_valid(args.h_errnop, HErrno::Internal); + return NSS_STATUS_TRYAGAIN; + } + + set_if_valid(args.errnop, 0); + set_if_valid(args.h_errnop, HErrno::Success); + return NSS_STATUS_SUCCESS; + } + + case ARES_ENOTFOUND: { + set_if_valid(args.errnop, 0); + set_if_valid(args.h_errnop, HErrno::HostNotFound); + return NSS_STATUS_SUCCESS; + } + + case ARES_ENODATA: { + set_if_valid(args.errnop, 0); + set_if_valid(args.h_errnop, HErrno::NoData); + return NSS_STATUS_SUCCESS; + } + + case ARES_ETIMEOUT: { + set_if_valid(args.errnop, EAGAIN); + set_if_valid(args.h_errnop, HErrno::TryAgain); + return NSS_STATUS_SUCCESS; + } + + case ARES_ECANCELLED: + case ARES_EDESTRUCTION: + case ARES_ENOMEM: + default: { + set_if_valid(args.errnop, EAGAIN); + set_if_valid(args.h_errnop, HErrno::Internal); + return NSS_STATUS_TRYAGAIN; + } + } +} + +namespace /* anonymous */ { + +auto init_channel(const std::vector dns) -> ares_channel_t * { + ares_channel_t *channel = nullptr; + ares_init(&channel); + setup_servers(channel, std::move(dns)); + return channel; +} + +auto setup_servers(ares_channel_t *channel, const std::vector dns) -> void { + CAresAddrList list; + for (auto it = dns.crbegin(); it != dns.crend(); ++it) { + auto new_node = std::make_unique(); + try { + parse_address(*it, *new_node); + auto new_node_unsafe = new_node.release(); + new_node_unsafe->next = list.release(); + list.reset(new_node_unsafe); + malcontent::Logger::debug(std::format("adding {} to the list of user DNS resolvers", *it)); + } catch (const std::exception& e) { + malcontent::Logger::error(e.what()); + throw; + } + } + + int ret = ares_set_servers_ports(channel, list.get()); + if (ret != ARES_SUCCESS) { + const auto err = std::string("ares_set_server_ports: ") + ares_strerror(ret); + throw std::system_error(ret, std::generic_category(), err.c_str()); + } +} + +auto parse_address(const std::string& addr, ares_addr_port_node& into) -> void { + static const auto ADDR4_REGEX = std::regex{ R"(([0-9\.]+)(?::([0-9]+))?(?:#(.*))?)" }; + static const auto ADDR6_REGEX = std::regex{ R"((?:([[0-9a-f:]+)|\[([0-9a-f:]+)\]:([0-9]+)?)(?:#(.*))?)" }; + try { + std::smatch matches; + size_t ip_idx, port_idx, host_idx; + + if (std::regex_match(addr, matches, ADDR4_REGEX)) { + into.family = AF_INET; + ip_idx = 1; + port_idx = 2; + host_idx = 3; + } else if (std::regex_match(addr, matches, ADDR6_REGEX)) { + into.family = AF_INET6; + ip_idx = matches[1].matched ? 1 : 2; + port_idx = 3; + host_idx = 4; + } else { + throw std::invalid_argument{"expecting '[:port][#hostname]'"}; + } + + if (ares_inet_pton(into.family, matches[ip_idx].str().c_str(), &into.addr) <= 0) { + throw std::system_error {errno, std::system_category()}; + } + + if (matches[host_idx].matched) { // hostname -> TLS + // FIXME: as of now we are ignoring the hostname verification + into.tcp_port = matches[port_idx].matched ? stoi(matches[port_idx]) : 853; + } else { // no hostname -> no TLS + into.udp_port = matches[port_idx].matched ? stoi(matches[port_idx]) : 53; + } + } catch (const std::exception& e) { + throw std::invalid_argument("unable to parse DNS server address '" + addr + "': " + e.what()); + } +} + +} // ~ namespace anonymous diff --git a/nss/resolver.hh b/nss/resolver.hh new file mode 100644 index 0000000..e94ea4e --- /dev/null +++ b/nss/resolver.hh @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Matteo Settenvini +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "wrapper.hh" + +#include "cares_init.hh" + +#include +#include + +#include +#include +#include + +namespace malcontent { + +struct ResolverArgs { + const char *name; // in + int family; // in + hostent *result; // out + char *buffer; // out + size_t buflen; // in + int *errnop; // out + HErrno *h_errnop; // out + int32_t *ttlp; // out +}; + +class Resolver { +public: + Resolver(std::vector dns); + + auto resolve(ResolverArgs& args) -> nss_status; + +private: + struct CAresDeletor { + auto operator() (ares_channel_t *ch) const -> void { + ares_destroy(ch); + } + }; + + using CAresChannel = std::unique_ptr; + + static auto resolve_cb(void *arg, + int status, + int timeouts, + unsigned char *abuf, + int alen) -> void; + + auto resolved(ResolverArgs& result, + int status, + int timeouts, + unsigned char *abuf, + int alen) -> nss_status; + + std::shared_ptr _ensure_cares_initialized; + CAresChannel _channel; +}; + +} // ~ namespace malcontent diff --git a/nss/test_main.cc b/nss/test_main.cc new file mode 100644 index 0000000..17949f6 --- /dev/null +++ b/nss/test_main.cc @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Matteo Settenvini +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "testsuite.hh" + +#include + +#include + + +auto main(int argc, char *argv[]) -> int +{ + setenv("NSS_MALCONTENT_LOG_LEVEL", "7", 1); + setlocale(LC_ALL, ""); + g_test_init(&argc, &argv, NULL); + g_test_set_nonfatal_assertions(); + + define_resolver_tests(); + + return g_test_run(); +} diff --git a/nss/test_resolver.cc b/nss/test_resolver.cc new file mode 100644 index 0000000..1bb2c3a --- /dev/null +++ b/nss/test_resolver.cc @@ -0,0 +1,192 @@ +// SPDX-FileCopyrightText: Matteo Settenvini +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "testsuite.hh" + +#include "resolver.hh" + +#include + +#include +#include +#include +#include + +namespace /* anonymous */ { + +auto resolver_valid_dns_address(gconstpointer addr_cstr) -> void; +auto resolver_invalid_dns_address(gconstpointer addr_cstr) -> void; +auto resolver_dns0eu_blocks_unsafe_domains(gconstpointer should_be_blocked_cstr) -> void; +auto resolver_cloudflare_family_blocks_unsafe_domains(gconstpointer should_be_blocked_cstr) -> void; + +} // ~ namespace anonymous + +auto define_resolver_tests() -> void { + auto valid_dns_addrs = std::array { + "192.168.0.1", + "192.168.0.1:53", + "192.168.0.1#dns.example.com", + "192.168.0.1:53#dns.example.com", + "192.168.0.1#", + "2345:0425:2ca1:0000:0000:0567:5673:23b5", + "2345:425:2ca1::567:5673:23b5", + "[2345::5673:23b5]:63", + "[2345::5673:23b5]:63#example.com", + "2345::5673:23b5#example.com", + "[2345::5673:23b5]:", + "[2345::5673:23b5]:#foo.bar", + "2345::5673:23b5#", + }; + + for (auto addr: valid_dns_addrs) { + g_test_add_data_func(std::format("/resolver/parses-valid-dns/{}", addr).c_str(), + addr, resolver_valid_dns_address); + } + + auto invalid_dns_addrs = std::array { + "192.168.0.1.1", + "192.168.0.256", + "192.168..20", + "-192.168.0.1", + "192.168.0.1:", + "192.168.0.1:#foo.bar", + "2345:0425:2ca1:0000:0000:0567:5673:23b5:2020:2021", + "2345::5673::23b5", + "2345::5673:ghij", + "2345::5673:23b5:", + }; + + for (auto addr: invalid_dns_addrs) { + g_test_add_data_func(std::format("/resolver/rejects-invalid-dns/{}", addr).c_str(), + addr, resolver_invalid_dns_address); + } + + // dns0.eu seems fairly good as blockfilters go + auto should_be_blocked_dns0 = std::array { + "pornhub.com", + "bookmaker.com", + "thepiratebay.org", + }; + + for (auto hostname : should_be_blocked_dns0) { + g_test_add_data_func(std::format("/resolver/kids.dns0.eu/blocks/{}", hostname).c_str(), + hostname, resolver_dns0eu_blocks_unsafe_domains); + } + + // Cloudflare family seems far less restrictive + auto should_be_blocked_cff = std::array { + "pornhub.com", + "thepiratebay.org", + }; + + for (auto hostname : should_be_blocked_cff) { + g_test_add_data_func(std::format("/resolver/family.cloudflare-dns.com/blocks/{}", hostname).c_str(), + hostname, resolver_cloudflare_family_blocks_unsafe_domains); + } +} + +namespace /* anonymous */ { +auto resolver_valid_dns_address(gconstpointer addr_cstr) -> void { + auto addr = static_cast(addr_cstr); + try { + malcontent::Resolver resolver({ addr }); + + } catch (const std::exception& e) { + g_assert_unreachable(e.what()); + } +} + +auto resolver_invalid_dns_address(gconstpointer addr_cstr) -> void { + auto addr = static_cast(addr_cstr); + try { + malcontent::Resolver resolver({ addr }); + auto error = std::format("DNS addr parsing did not fail as expected for {}", addr); + g_assert_unreachable(error.c_str()); + + } catch (const std::invalid_argument& e) { + g_assert_true(std::string(e.what()).find("unable to parse DNS server address") != std::string::npos); + + } catch (const std::exception& e) { + g_assert_unreachable(e.what()); + } +} + +auto resolver_dns0eu_blocks_unsafe_domains(gconstpointer should_be_blocked_cstr) -> void { + auto should_be_blocked = static_cast(should_be_blocked_cstr); + try { + malcontent::Resolver resolver({ + "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", + }); + + + hostent result; + int local_errno{}; + HErrno local_herrno{}; + malcontent::ResolverArgs args { + .name = should_be_blocked, + .family = AF_INET, + .result = &result, + .buffer = nullptr, + .buflen = 0, + .errnop = &local_errno, + .h_errnop = &local_herrno, + .ttlp = nullptr, + }; + + resolver.resolve(args); + g_assert_cmpint(local_errno, ==, 0); + g_assert_true(local_herrno == HErrno::HostNotFound); + + } catch (const std::exception& e) { + g_assert_unreachable(e.what()); + } +} + +auto resolver_cloudflare_family_blocks_unsafe_domains(gconstpointer should_be_blocked_cstr) -> void { + auto should_be_blocked = static_cast(should_be_blocked_cstr); + + const char NULL_NULL_NULL_NULL[] = { 0x0, 0x0, 0x0, 0x0 }; + try { + malcontent::Resolver resolver({ + "1.1.1.3#family.cloudflare-dns.com", + "1.0.0.3#family.cloudflare-dns.com", + "2606:4700:4700::1113#family.cloudflare-dns.com", + "2606:4700:4700::1003#family.cloudflare-dns.com", + }); + + constexpr size_t BUFLEN = 2056; + char buffer[BUFLEN]; + hostent result; + int local_errno{}; + HErrno local_herrno{}; + malcontent::ResolverArgs args { + .name = should_be_blocked, + .family = AF_INET, + .result = &result, + .buffer = buffer, + .buflen = BUFLEN, + .errnop = &local_errno, + .h_errnop = &local_herrno, + .ttlp = nullptr, + }; + + resolver.resolve(args); + g_assert_cmpint(local_errno, ==, 0); + g_assert_true(local_herrno == HErrno::Success); + g_assert_cmpint(result.h_addrtype, ==, AF_INET); + g_assert_cmpint(result.h_length, ==, 4); + g_assert_nonnull(result.h_addr_list); + g_assert_cmpmem(*result.h_addr_list, result.h_length, NULL_NULL_NULL_NULL, 4); + g_assert_null(result.h_addr_list[1]); + + } catch (const std::exception& e) { + g_assert_unreachable(e.what()); + } +} + +// TODO: TEST canary + +} // ~ namespace anonymous diff --git a/nss/testsuite.hh b/nss/testsuite.hh new file mode 100644 index 0000000..10e62a4 --- /dev/null +++ b/nss/testsuite.hh @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: Matteo Settenvini +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#define g_assert_unreachable(msg) g_assert_cmpstr(msg, ==, nullptr) + +auto define_resolver_tests() -> void; diff --git a/nss/user_policy.cc b/nss/user_policy.cc new file mode 100644 index 0000000..8fedb5c --- /dev/null +++ b/nss/user_policy.cc @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Matteo Settenvini +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "user_policy.hh" + +#include "logger.hh" +#include "resolver.hh" + +#include "parentalcontrol-dns.h" + +#include +#include +#include + +#include + +namespace /* anonymous */ { + +struct GObjectDeletor { + auto operator() (void *gobj) const noexcept -> void { + g_object_unref(gobj); + } +}; + +} // ~ namespace anonymous + + +using ProxyPtr = std::unique_ptr; + +auto malcontent::UserPolicy::resolver(std::optional maybe_uid) const -> std::shared_ptr { + auto uid = maybe_uid.value_or(getuid()); + if (uid == 0) { + return nullptr; // no restrictions for root! + } + + // If cached, let's return this immediately + { + std::shared_lock lock { _cache_mutex }; + auto it = _resolver_cache.find(uid); + if (it != _resolver_cache.cend()) { + return it->second; + } + } + + // Missing from cache, insert if still needed + std::unique_lock lock { _cache_mutex }; + auto it = _resolver_cache.find(uid); + if (it != _resolver_cache.cend()) { + return it->second; + } + + GError *error = nullptr; + auto dbus_proxy = ProxyPtr { + parental_controls_dns_proxy_new_for_bus_sync( + G_BUS_TYPE_SYSTEM, + // right now we check the property value at start + // and we never update during runtime. + G_DBUS_PROXY_FLAGS_DO_NOT_CONNECT_SIGNALS, + "com.endlessm.ParentalControls.Dns", + "/com/endlessm/ParentalControls/Dns", + nullptr, + &error + ) + }; + + if (error != nullptr) { + malcontent::Logger::warn(error->message); + return nullptr; + } + + std::vector dns; + for (auto it = parental_controls_dns_get_dns(dbus_proxy.get()); it != nullptr; ++it) { + dns.push_back(*it); + } + + it = _resolver_cache.insert(it, { uid, std::make_shared(std::move(dns)) }); + return it->second; +} diff --git a/nss/user_policy.hh b/nss/user_policy.hh new file mode 100644 index 0000000..dae7e61 --- /dev/null +++ b/nss/user_policy.hh @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Matteo Settenvini +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +#include + +namespace malcontent { + +class Resolver; + +class UserPolicy { +public: + UserPolicy() = default; + UserPolicy(const UserPolicy&) = delete; + UserPolicy(UserPolicy&&) = delete; + ~UserPolicy() noexcept = default; + + auto resolver(std::optional uid = {}) const -> std::shared_ptr; + +private: + mutable std::unordered_map> _resolver_cache; + mutable std::shared_mutex _cache_mutex; +}; + +} // ~ namespace malcontent diff --git a/nss/wrapper.hh b/nss/wrapper.hh new file mode 100644 index 0000000..977e88d --- /dev/null +++ b/nss/wrapper.hh @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Matteo Settenvini +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +// 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 */ +};