Commit 8a9e69c6 authored by jan.koester's avatar jan.koester
Browse files

adconnect: Active Directory users and groups for systempp

adconnectd reads /etc/adconnect/adconnect.yaml (libconfplus), binds to a
domain controller over LDAPS or StartTLS (libnetplus) and answers user
and group lookups on /run/adconnect.sock, with a cache that is kept on
disk and serves answers while no domain controller is reachable.
adconnect.so is the systempp userdb module (<userdb.h>) that asks it.

Ids: id_base + RID, RFC 2307 uidNumber/gidNumber, or the objectGUID as
a FenrirOS GUID id. Tests run against a fake LDAP server.

Co-Authored-By: default avatarClaude Opus 5.5 <noreply@anthropic.com>
parents
Loading
Loading
Loading
Loading

.gitignore

0 → 100644
+2 −0
Changes for .gitignore: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
build/
/build-*/

CMakeLists.txt

0 → 100644
+80 −0
Changes for CMakeLists.txt: 80 added lines, 0 removed lines.
Original line number Diff line number Diff line
cmake_minimum_required(VERSION 3.16)
project(adconnect VERSION 0.1.0 LANGUAGES CXX)

# adconnect: the users and groups of an Active Directory domain for
# systempp's user lookups (getpwnam(), getgrouplist(), ... -- see
# systempp's <userdb.h>).
#
#   adconnectd    the daemon (root): reads /etc/adconnect/adconnect.yaml
#                 (libconfplus), binds to a domain controller over LDAPS or
#                 StartTLS (libnetplus), answers on /run/adconnect.sock and
#                 caches what it was told -- on disk too, so that users and
#                 groups stay known while no domain controller is reachable
#   adconnect.so  the userdb module /etc/userdb.yaml loads into every
#                 process: asks adconnectd over the socket. It never sees
#                 the bind password and doesn't link libnetplus.
#
# Ids: id_base + RID (default), the RFC 2307 uidNumber/gidNumber, or the
# objectGUID as a FenrirOS GUID id. Logins (passwords) are not part of it.

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
if(NOT CMAKE_BUILD_TYPE)
  set(CMAKE_BUILD_TYPE RelWithDebInfo)
endif()

include(GNUInstallDirs)

find_package(libconfplus REQUIRED)
find_package(libnetplus REQUIRED)
find_package(Threads REQUIRED)

set(ADCONNECT_CONFIG "/etc/adconnect/adconnect.yaml" CACHE FILEPATH "configuration file")
set(ADCONNECT_SOCKET "/run/adconnect.sock" CACHE FILEPATH "adconnectd's socket")
set(ADCONNECT_CACHE "/var/lib/adconnect/cache" CACHE FILEPATH "adconnectd's offline cache")
set(ADCONNECT_MODULEDIR "${CMAKE_INSTALL_LIBDIR}/systempp/userdb" CACHE PATH "userdb module directory")
set(ADCONNECT_UNITDIR "/etc/syslite/services" CACHE PATH "syslite unit directory")

add_compile_definitions(
    ADCONNECT_CONFIG="${ADCONNECT_CONFIG}"
    ADCONNECT_SOCKET="${ADCONNECT_SOCKET}"
    ADCONNECT_CACHE="${ADCONNECT_CACHE}"
)

add_library(adconnect_proto STATIC src/protocol.cpp)
set_target_properties(adconnect_proto PROPERTIES POSITION_INDEPENDENT_CODE ON)

add_library(adconnect_core STATIC
    src/ber.cpp
    src/ldap.cpp
    src/config.cpp
    src/directory.cpp
    src/cache.cpp
)
target_link_libraries(adconnect_core PUBLIC adconnect_proto confplus::confplus netplus::netplus Threads::Threads)

add_executable(adconnectd src/daemon.cpp)
target_link_libraries(adconnectd PRIVATE adconnect_core)

add_library(adconnect MODULE src/module.cpp)
set_target_properties(adconnect PROPERTIES PREFIX "" OUTPUT_NAME "adconnect")
target_link_libraries(adconnect PRIVATE adconnect_proto)

install(TARGETS adconnectd RUNTIME DESTINATION ${CMAKE_INSTALL_SBINDIR})
install(TARGETS adconnect LIBRARY DESTINATION ${ADCONNECT_MODULEDIR})
# may hold the bind password: root only
install(FILES dist/adconnect.yaml DESTINATION /etc/adconnect PERMISSIONS OWNER_READ OWNER_WRITE)
install(FILES dist/adconnect.service DESTINATION ${ADCONNECT_UNITDIR})
# the offline cache: names and groups of the domain, root only
install(DIRECTORY DESTINATION /var/lib/adconnect DIRECTORY_PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE)

include(CTest)
if(BUILD_TESTING)
    add_executable(adconnect_test test/test.cpp test/fakeldap.cpp)
    target_compile_definitions(adconnect_test PRIVATE
        ADCONNECTD_PATH="$<TARGET_FILE:adconnectd>"
        ADCONNECT_MODULE_PATH="$<TARGET_FILE:adconnect>")
    target_link_libraries(adconnect_test PRIVATE adconnect_core ${CMAKE_DL_LIBS})
    add_dependencies(adconnect_test adconnectd adconnect)
    add_test(NAME adconnect_test COMMAND adconnect_test)
endif()

dist/adconnect.service

0 → 100644
+11 −0
Changes for dist/adconnect.service: 11 added lines, 0 removed lines.
Original line number Diff line number Diff line
[Unit]
Description=adconnectd: users and groups from Active Directory
After=network.target

[Service]
Type=simple
ExecStart=/usr/sbin/adconnectd
Restart=always

[Install]
WantedBy=multi-user.target

dist/adconnect.yaml

0 → 100644
+64 −0
Changes for dist/adconnect.yaml: 64 added lines, 0 removed lines.
Original line number Diff line number Diff line
# adconnectd: the Active Directory domain whose users and groups the
# system sees. Check it with `adconnectd -t`, ask it with
# `adconnectd -q "PWNAM someone"`. adconnect.so has to be listed in
# /etc/userdb.yaml as well:
#
#   - module: adconnect
#
adconnect:
  # the DNS name of the domain; the base DN follows from it
  # (DC=example,DC=com) unless "base" says otherwise
  domain: example.com
  # base: DC=example,DC=com

  # domain controllers, tried in turn (default: the domain name itself)
  servers: [dc1.example.com, dc2.example.com]

  # ldaps (port 636, default), starttls (389) or none (389, the bind
  # password in the clear: needs insecure: true)
  tls: ldaps
  # port: 636
  # the CA that issued the DCs' certificates, PEM (default: the system bundle)
  # ca_file: /etc/adconnect/ca.pem

  # a service account allowed to read users and groups; as user@domain
  # or a DN. The password from a file of its own is better than here.
  bind_dn: svc-fenriros@example.com
  bind_password_file: /etc/adconnect/bind.secret
  # bind_password: secret

  # ids: rid (id_base + the RID of the account's SID), rfc2307 (uidNumber
  # and gidNumber; accounts without them are left out) or guid (the
  # objectGUID as a GUID id: only programs built for <sys/uid_r.h> see it,
  # the others the overflow id)
  id_mapping: rid
  id_base: 200000
  id_range: 2000000

  # %u the user name, %d the domain; unixHomeDirectory and loginShell win
  # where an account has them
  home: /home/%u
  shell: /bin/sh
  lowercase_names: true

  # getpwent()/getgrent() list the whole domain: off for big domains
  enumerate: false

  # only these users / groups, ANDed to every search; e.g. the members of
  # one group, nested ones too:
  # user_filter: "(memberOf:1.2.840.113556.1.4.1941:=CN=Linux Users,OU=Groups,DC=example,DC=com)"
  # group_filter: ""

  # seconds an answer is kept; an empty one ("no such user")
  cache_ttl: 300
  negative_ttl: 60
  # no domain controller reachable: answers up to this many seconds old
  # are still given (0: none) -- the cache is kept on disk, so this holds
  # across restarts and boots without a network too. The controllers are
  # tried again every retry_interval seconds meanwhile.
  offline_ttl: 259200
  retry_interval: 30
  # cache_file: /var/lib/adconnect/cache
  # seconds a domain controller gets per operation
  timeout: 10
  # socket: /run/adconnect.sock

src/ber.cpp

0 → 100644
+156 −0
Changes for src/ber.cpp: 156 added lines, 0 removed lines.
Original line number Diff line number Diff line
/*******************************************************************************
Copyright (c) 2026, Jan Koester jan.koester@gmx.net
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
    * Redistributions of source code must retain the above copyright
      notice, this list of conditions and the following disclaimer.
    * Redistributions in binary form must reproduce the above copyright
      notice, this list of conditions and the following disclaimer in the
      documentation and/or other materials provided with the distribution.
    * Neither the name of the <organization> nor the
      names of its contributors may be used to endorse or promote products
      derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*******************************************************************************/

#include "ber.h"

#include <cstdio>

namespace adconnect {
namespace ber {

namespace {

/* an LDAP message bigger than this is refused rather than buffered */
constexpr size_t MaxElement = 64u << 20;

void putLength(Bytes &out, size_t n) {
    if (n < 0x80) {
        out += (char)n;
        return;
    }
    char tmp[8];
    int len = 0;
    while (n) {
        tmp[len++] = (char)(n & 0xff);
        n >>= 8;
    }
    out += (char)(0x80 | len);
    while (len) out += tmp[--len];
}

/* tag and length of the element at p; false if [p, end) is too short */
bool header(const char *p, const char *end, uint8_t &tag, size_t &hdr, size_t &len) {
    if (end - p < 2) return false;
    tag = (uint8_t)p[0];
    if ((tag & 0x1f) == 0x1f) throw Error("multi-octet tags are not supported");
    uint8_t l = (uint8_t)p[1];
    if (l < 0x80) {
        hdr = 2;
        len = l;
        return true;
    }
    int nl = l & 0x7f;
    if (nl == 0) throw Error("indefinite lengths are not supported");
    if (nl > 4) throw Error("length too long");
    if (end - p < 2 + nl) return false;
    len = 0;
    for (int i = 0; i < nl; i++) len = len << 8 | (uint8_t)p[2 + i];
    hdr = 2 + (size_t)nl;
    if (len > MaxElement) throw Error("element too large");
    return true;
}

} // namespace

Bytes tlv(uint8_t tag, const Bytes &content) {
    Bytes out;
    out.reserve(content.size() + 6);
    out += (char)tag;
    putLength(out, content.size());
    out += content;
    return out;
}

Bytes integer(long long v, uint8_t tag) {
    /* two's complement, big endian, without the leading octets that only
     * repeat the sign */
    unsigned char b[8];
    for (int i = 0; i < 8; i++) b[7 - i] = (unsigned char)((unsigned long long)v >> (8 * i));
    int start = 0;
    while (start < 7 && ((b[start] == 0x00 && !(b[start + 1] & 0x80)) ||
                         (b[start] == 0xff && (b[start + 1] & 0x80))))
        start++;
    return tlv(tag, Bytes((const char *)b + start, (size_t)(8 - start)));
}

Bytes octets(const std::string &v, uint8_t tag) {
    return tlv(tag, v);
}

Bytes boolean(bool v, uint8_t tag) {
    return tlv(tag, Bytes(1, v ? (char)0xff : (char)0));
}

uint8_t Reader::peekTag() const {
    if (atEnd()) throw Error("unexpected end of data");
    return (uint8_t)_data[_pos];
}

Element Reader::next() {
    uint8_t tag;
    size_t hdr, len;
    const char *p = _data.data() + _pos, *end = _data.data() + _data.size();
    if (!header(p, end, tag, hdr, len) || (size_t)(end - p) < hdr + len)
        throw Error("truncated element");
    Element e;
    e.tag = tag;
    e.content.assign(p + hdr, len);
    _pos += hdr + len;
    return e;
}

Element Reader::next(uint8_t tag) {
    Element e = next();
    if (e.tag != tag) {
        char msg[64];
        snprintf(msg, sizeof msg, "expected tag 0x%02x, got 0x%02x", tag, e.tag);
        throw Error(msg);
    }
    return e;
}

long long toInt(const Element &e) {
    if (e.content.empty() || e.content.size() > 8) throw Error("bad integer");
    long long v = (signed char)e.content[0];
    for (size_t i = 1; i < e.content.size(); i++) v = (long long)((unsigned long long)v << 8) | (uint8_t)e.content[i];
    return v;
}

bool toBool(const Element &e) {
    if (e.content.size() != 1) throw Error("bad boolean");
    return e.content[0] != 0;
}

size_t elementSize(const char *data, size_t n) {
    uint8_t tag;
    size_t hdr, len;
    if (!header(data, data + n, tag, hdr, len)) return 0;
    return hdr + len;
}

} // namespace ber
} // namespace adconnect
Loading