BridgeHead
Native C++ access to Active Directory over ADWS, no .NET, no WCF, no HTTP stack.
BridgeHead is a C++20 static library implementing the full Active Directory Web Services (ADWS) protocol stack directly over TCP. Named after the AD bridgehead server, the gateway through which directory traffic flows, it gives your C++ code the same low-level access to port 9389 that PowerShell's Get-ADUser and Get-ADComputer use under the hood.
Table of contents
- Protocol stack
- Quick start
- API reference
- Build
- Integration
- Platform support
- Dependencies
- Known limitations
- Protocol references
- License
Protocol stack
The transport layers wrap the one below via the common bridgehead::transport::IByteStream interface. NbfseCodec is a codec utility called by AdwsClient to encode/decode SOAP messages before and after framing:
AdwsClient WS-Enumeration + WS-Transfer [MS-ADDM]
├── NbfseCodec .NET Binary Format for SOAP [MC-NBFSE] (encode/decode)
└── NmfFramer .NET Message Framing [MC-NMF] (send/receive frames)
└── NnsSession .NET NegotiateStream [MS-NNS]
└── TcpSocket raw TCP/IP
The entry point for consumers is bridgehead::adws::AdwsClient.
Quick start
Query, enumerate all users
#include "bridgehead/adws/AdwsClient.hpp"
// NTLM (works on any host)
auto client = bridgehead::adws::AdwsClient::EnumerationClient(
"192.168.1.10", // DC IP or hostname
"DC01.corp.local", // DC FQDN, used in NMF Via header and Kerberos SPN
"CORP", // NetBIOS domain name
"Administrator", // username
"Passw0rd" // password
);
// Kerberos (username hidden on the wire; requires DC reachable on port 88)
auto client = bridgehead::adws::AdwsClient::EnumerationClient(
"192.168.1.10", "DC01.corp.local", "CORP",
"Administrator", "Passw0rd",
bridgehead::adws::AuthPackage::Kerberos
);
auto users = client.Query(
"(objectClass=user)",
{"sAMAccountName", "distinguishedName", "memberOf"}
);
for (auto& obj : users)
std::cout << obj.FirstValue("sAMAccountName") << '\n';
Stream large result sets
client.Enumerate(
"(objectClass=computer)",
{"dNSHostName", "operatingSystem"},
"", // empty = domain root base DN
[](const bridgehead::adws::LdapObject& obj) {
std::cout << obj.FirstValue("dNSHostName") << '\n';
return true; // return false to stop early (sends wsen:Release)
}
);
Scope and pagination
// OneLevel scope, 50 objects per Pull round-trip
client.Query(
"(objectClass=user)",
{"sAMAccountName"},
"OU=Admins,DC=corp,DC=local",
50,
bridgehead::adws::SearchScope::OneLevel
);
Binary attributes, objectGUID, objectSid
auto objs = client.Query("(objectClass=user)", {"objectGUID", "objectSid"});
for (auto& obj : objs) {
if (auto* b = obj.FirstBytes("objectGUID"))
std::cout << bridgehead::adws::ParseGuid(*b) << '\n'; // {XXXXXXXX-...}
if (auto* b = obj.FirstBytes("objectSid"))
std::cout << bridgehead::adws::ParseSid(*b) << '\n'; // S-1-5-...
}
Security descriptor decoding
#include "bridgehead/adws/SecurityDescriptor.hpp"
auto objs = client.Query("(objectClass=user)", {"nTSecurityDescriptor"});
if (auto* raw = objs[0].FirstBytes("nTSecurityDescriptor")) {
auto sd = bridgehead::adws::ParseSecurityDescriptor(*raw);
std::cout << "Owner: " << sd.ownerSid << '\n';
for (auto& ace : sd.dacl.aces)
std::cout << " type=" << (int)ace.type
<< " mask=0x" << std::hex << ace.mask
<< " sid=" << ace.sid << '\n';
}
Write attributes (Resource endpoint)
auto rc = bridgehead::adws::AdwsClient::ResourceClient(
"192.168.1.10", "DC01.corp.local", "CORP", "Administrator", "Passw0rd");
// Modify attributes
rc.Put("CN=Alice,OU=Users,DC=corp,DC=local", {
{"description", {"managed by bridgehead"}},
{"telephoneNumber", {"555-1234"}},
});
// Clear an attribute (both values and bytes empty = delete)
rc.Put("CN=Alice,OU=Users,DC=corp,DC=local", {
{"telephoneNumber", {}},
});
// Read back
auto obj = rc.Get("CN=Alice,OU=Users,DC=corp,DC=local",
{"description", "telephoneNumber"});
// Delete object
rc.Delete("CN=TempUser,OU=Users,DC=corp,DC=local");
LDAP modify types, Add / Replace / Delete
Put defaults to Replace (overwrites all existing values). Use ModifyOperation for fine-grained control on multi-valued attributes:
using bridgehead::adws::LdapModification;
using bridgehead::adws::ModifyOperation;
rc.Put("CN=Alice,OU=Users,DC=corp,DC=local", {
// Append a value to an existing multi-valued attribute
LdapModification{"otherTelephone", {"555-9999"}, {}, ModifyOperation::Add},
// Remove one specific value (leave others intact)
LdapModification{"otherTelephone", {"555-0000"}, {}, ModifyOperation::Delete},
// Replace is the default, explicit here for clarity
LdapModification{"description", {"updated"}, {}, ModifyOperation::Replace},
});
Move / Rename
// Move to a different OU
rc.Move(
"CN=Alice,OU=OldOU,DC=corp,DC=local", // current DN
"CN=Alice,OU=NewOU,DC=corp,DC=local" // new DN
);
// Rename in place (same parent, new CN)
rc.Move(
"CN=Alice,OU=Users,DC=corp,DC=local",
"CN=AliceSmith,OU=Users,DC=corp,DC=local"
);
Write binary attributes
Supply binary values in the bytes field of LdapModification. They are base64-encoded on the wire automatically:
std::vector<uint8_t> thumbnail = loadFile("photo.jpg");
rc.Put("CN=Alice,OU=Users,DC=corp,DC=local", {
LdapModification{"thumbnailPhoto", {}, {thumbnail}},
});
Create objects (ResourceFactory endpoint)
auto rf = bridgehead::adws::AdwsClient::ResourceFactoryClient(
"192.168.1.10", "DC01.corp.local", "CORP", "Administrator", "Passw0rd");
rf.Create(
"CN=NewUser,OU=Users,DC=corp,DC=local",
"user",
{{"sAMAccountName", {"newuser"}}, {"userAccountControl", {"512"}}}
);
Async operations
#include "bridgehead/adws/AdwsClientAsync.hpp"
auto ac = bridgehead::adws::AdwsClientAsync::EnumerationClient(
"192.168.1.10", "DC01.corp.local", "CORP", "Administrator", "Passw0rd");
auto future = ac.QueryAsync("(objectClass=user)", {"sAMAccountName"});
// ... do other work while the query runs ...
auto users = future.get(); // blocks until complete; re-throws any exception
Timeout on a slow DC:
if (future.wait_for(std::chrono::seconds(5)) == std::future_status::timeout) {
// query is still running
}
All operations have async variants: QueryAsync, EnumerateAsync, GetAsync, PutAsync, DeleteAsync, CreateAsync, MoveAsync.
Connection pool (high-frequency / multi-threaded workloads)
#include "bridgehead/adws/AdwsClientPool.hpp"
// Enumeration pool, Query / Enumerate
bridgehead::adws::AdwsClientPool pool({
.host = "192.168.1.10", .fqdn = "DC01.corp.local",
.domain = "CORP", .username = "Administrator", .password = "Passw0rd",
.maxSize = 4, // up to 4 concurrent authenticated sessions
});
auto users = pool.Query("(objectClass=user)", {"sAMAccountName"});
// Resource pool, Get / Put / Delete
bridgehead::adws::AdwsClientPool resPool({
.host = "192.168.1.10", .fqdn = "DC01.corp.local",
.domain = "CORP", .username = "Administrator", .password = "Passw0rd",
.maxSize = 4,
.endpoint = bridgehead::adws::PoolEndpoint::Resource,
});
auto obj = resPool.Get("CN=Alice,OU=Users,DC=corp,DC=local", {"mail"});
resPool.Put("CN=Alice,OU=Users,DC=corp,DC=local", {{"mail", {"alice@corp.local"}}});
// ResourceFactory pool, Create
bridgehead::adws::AdwsClientPool rfPool({
.host = "192.168.1.10", .fqdn = "DC01.corp.local",
.domain = "CORP", .username = "Administrator", .password = "Passw0rd",
.endpoint = bridgehead::adws::PoolEndpoint::ResourceFactory,
});
rfPool.Create("CN=NewUser,OU=Users,DC=corp,DC=local", "user",
{{"sAMAccountName", {"newuser"}}});
API reference
All public headers live under include/bridgehead/. Full API documentation can be generated with Doxygen (see Build).
bridgehead::adws::AdwsClient
| Method | Endpoint | Description |
|---|---|---|
EnumerationClient(host, fqdn, domain, user, pass [, auth, timeoutMs, opTimeoutMs, limits]) |
/Enumeration |
Factory, connect and authenticate |
ResourceClient(...) |
/Resource |
Factory for Get / Put / Delete / Move |
ResourceFactoryClient(...) |
/ResourceFactory |
Factory for Create |
Query(filter, attrs [, baseDN, maxElems, scope]) |
Enumeration | Collect all results into a vector |
Enumerate(filter, attrs, baseDN, callback [, maxElems, scope]) |
Enumeration | Stream results via callback |
Get(dn, attrs) |
Resource | Read attributes of a single object |
Put(dn, modifications) |
Resource | Modify attributes |
Delete(dn) |
Resource | Delete object |
Create(dn, objectClass, attrs) |
ResourceFactory | Create new object |
Move(dn, newDn) |
Resource | Move or rename an existing object |
bridgehead::adws::AdwsClientAsync
std::future-based async wrapper. Same factory methods as AdwsClient; each operation returns a std::future<T>:
std::future<std::vector<LdapObject>> f = ac.QueryAsync(...);
std::future<void> e = ac.EnumerateAsync(filter, attrs, baseDN, callback);
std::future<LdapObject> g = ac.GetAsync(...);
std::future<void> h = ac.PutAsync(...);
std::future<void> i = ac.DeleteAsync(...);
std::future<void> j = ac.CreateAsync(...);
std::future<void> k = ac.MoveAsync(...);
For concurrent queries across multiple connections, use AdwsClientPool.
bridgehead::adws::AdwsClientPool
Thread-safe pool of pre-authenticated sessions. Connections are created lazily and reused across calls.
pool.IdleCount(); // sessions currently idle
pool.TotalCount(); // idle + checked-out
pool.MaxSize(); // configured maximum pool size
pool.Move(dn, newDn); // Move/rename (Resource pool)
bridgehead::adws::SearchScope
enum class SearchScope { Base, OneLevel, Subtree /*default*/ };
bridgehead::adws::LdapObject / LdapAttribute
struct LdapAttribute {
std::string name;
std::string syntax; // LdapSyntax OID, empty if absent
std::vector<std::string> values; // text values (raw base64 for binary)
std::vector<std::vector<uint8_t>> bytes; // decoded binary; parallel to values
};
struct LdapObject {
std::vector<LdapAttribute> attributes; // all returned attributes
const LdapAttribute* Find(const std::string& name) const; // case-insensitive
std::string FirstValue(const std::string& name) const;
const std::vector<uint8_t>* FirstBytes(const std::string& name) const;
};
Binary helpers
std::string ParseGuid(const std::vector<uint8_t>& bytes); // → "{XXXXXXXX-XXXX-...}"
std::string ParseSid (const std::vector<uint8_t>& bytes); // → "S-1-5-..."
FilterValue and filter builder helpers
FilterValue is a type-safe wrapper that escapes RFC 4515 §3 metacharacters (\, *, (, ), NUL) on construction. Use it with the builder helpers to make LDAP injection structurally impossible:
// UNSAFE, raw string concatenation, easy to forget escaping
client.Query("(sAMAccountName=" + username + ")", ...);
// SAFE, FilterValue escapes on construction; FilterEq composes the assertion
FilterValue user = username;
client.Query(FilterEq("sAMAccountName", user), ...);
// Compose complex filters
client.Query(
FilterAnd({
FilterEq("objectClass", FilterValue::Raw("user")), // Raw() for safe literals
FilterOr({
FilterEq("sAMAccountName", user),
FilterEq("mail", FilterValue(email)),
}),
FilterNot(FilterPresent("userAccountControl")),
}), {"sAMAccountName", "mail"}
);
EscapeLdapFilter(str) is still available as a lower-level primitive when you need to build filter strings manually.
DnValue and DN builder helpers
Same pattern as FilterValue, but for Distinguished Name RDN values (RFC 4514 §2.4). Escapes ,, +, ", \, <, >, ;, NUL, and leading/trailing # and space:
// UNSAFE, comma injection breaks the DN structure
rc.Get("CN=" + username + ",OU=Users,DC=corp,DC=local", attrs);
// SAFE
DnValue cn = username; // auto-escaped
auto dn = BuildDn({DnAttr("CN", cn), "OU=Users", "DC=corp", "DC=local"});
rc.Get(dn, attrs);
// DnValue::Raw() for values you control
auto dn2 = BuildDn({DnAttr("CN", DnValue::Raw("Service Account")), "OU=SvcAccounts", "DC=corp", "DC=local"});
bridgehead::adws::SecurityDescriptor
SecurityDescriptor ParseSecurityDescriptor(const std::vector<uint8_t>& bytes);
struct SecurityDescriptor {
uint16_t control; // SdControl::* flags
std::string ownerSid;
std::string groupSid;
bool hasDacl; Acl dacl;
bool hasSacl; Acl sacl;
};
struct Acl { std::vector<Ace> aces; };
struct Ace {
uint8_t type; // AceType::*
uint8_t flags; // AceFlags::*
uint32_t mask;
std::string sid;
std::string objectType; // GUID string, object ACEs only
std::string inheritedObjectType; // GUID string, object ACEs only
};
Constant namespaces: AceType::*, AceFlags::*, SdControl::*.
bridgehead::ConnectionError / AuthenticationError / ProtocolError
Defined in include/bridgehead/Exceptions.hpp (included transitively by AdwsClient.hpp). All three extend std::runtime_error:
| Type | Thrown when |
|---|---|
ConnectionError |
TCP-level failure: refused, timeout, I/O error |
AuthenticationError |
NTLM/Kerberos negotiation fails |
ProtocolError |
Malformed server response at any protocol layer |
try {
auto client = bridgehead::adws::AdwsClient::EnumerationClient(...);
auto users = client.Query(...);
} catch (const bridgehead::ConnectionError& e) {
// unreachable DC, wrong port, timeout
} catch (const bridgehead::AuthenticationError& e) {
// bad credentials, KDC unreachable
} catch (const bridgehead::ProtocolError& e) {
// unexpected server response
} catch (const bridgehead::adws::SoapFault& e) {
// DC returned a SOAP fault (e.g. invalid filter)
}
// or coarse-grained:
// } catch (const std::runtime_error& e) { ... }
bridgehead::adws::SoapFault
Thrown on any s:Fault response from the DC instead of a plain std::runtime_error:
struct SoapFault : std::runtime_error {
std::string code; // e.g. "Sender"
std::string subcode; // e.g. "InvalidEnumerationContext", empty if absent
std::string reason; // human-readable text
};
bridgehead::adws::LdapModification
Used by Put and Create:
enum class ModifyOperation { Replace /*default*/, Add, Delete };
struct LdapModification {
std::string name;
std::vector<std::string> values; // text values
std::vector<std::vector<uint8_t>> bytes; // binary values (base64-encoded on the wire)
ModifyOperation operation = ModifyOperation::Replace;
};
When both values and bytes are empty, the attribute is cleared/deleted regardless of operation.
bridgehead::adws::ProtocolLimits
Optional last parameter on all factory methods, AdwsClientAsync factories, and AdwsClientPool::Config::limits. All fields have safe defaults, only change them if you have specific requirements:
struct ProtocolLimits {
uint32_t nmfMaxFrameBytes = 64 * 1024 * 1024; // max NMF frame (default 64 MiB)
uint32_t nnsMaxPayloadBytes = 16 * 1024 * 1024; // max NNS packet (default 16 MB)
int tcpKeepaliveIdleSec = 60; // idle seconds before first probe
int tcpKeepaliveIntervalSec = 10; // seconds between probes
int tcpKeepaliveProbeCount = 5; // probes before declaring dead (POSIX only)
};
Raise nmfMaxFrameBytes / nnsMaxPayloadBytes only if you are reading objects with unusually large binary attributes (e.g. nTSecurityDescriptor with many ACEs, or large thumbnailPhoto). Lower the keepalive fields in cloud/NAT environments where idle connections are dropped aggressively.
Named constructor helpers (inline static factories):
LdapModification::Replace(name, values) // Replace with text values (default)
LdapModification::ReplaceBinary(name, bytes) // Replace with binary values
LdapModification::Append(name, values) // Add to multi-valued attribute
LdapModification::Remove(name, values={}) // Remove specific values (or all)
LdapModification::Clear(name) // Delete the attribute entirely
Build
Requirements: CMake 3.25+ and a C++20-capable compiler (MSVC 2022+, GCC 12+, Clang 15+).
# Configure and build (unit tests only, no live AD needed)
cmake -B build -A x64
cmake --build build --config Release
# Run unit tests
ctest --test-dir build -C Release --output-on-failure
# With integration tests (requires a live Domain Controller)
cmake -B build -A x64 -DBRIDGEHEAD_INTEGRATION_TESTS=ON
cmake --build build --config Release
./build/tests/Release/bridgehead_integration_tests.exe
# With CLI tool (adws_list, browse AD objects from the command line)
cmake -B build -A x64 -DBRIDGEHEAD_BUILD_TOOLS=ON
cmake --build build --config Release --target adws_list
./build/tools/Release/adws_list.exe --host <dc-ip> --fqdn <dc-fqdn> --domain <domain> --user <user>
# Generate API reference docs (requires Doxygen)
cmake --build build --target docs
# or directly:
doxygen Doxyfile
# Output: docs/doxygen/html/index.html
CMake options
| Option | Default | Description |
|---|---|---|
BRIDGEHEAD_BUILD_TESTS |
ON |
Build the unit test suite |
BRIDGEHEAD_INTEGRATION_TESTS |
OFF |
Build integration tests (requires a live DC) |
BRIDGEHEAD_BUILD_TOOLS |
OFF |
Build the adws_list CLI tool |
Integration
Option A, CMake subdirectory (no install needed)
add_subdirectory(bridgehead)
target_link_libraries(my_target PRIVATE bridgehead::bridgehead)
Option B, installed package (find_package)
cmake --install build --prefix /usr/local # or any install prefix
find_package(bridgehead REQUIRED)
target_link_libraries(my_target PRIVATE bridgehead::bridgehead)
pugixml, ws2_32, and secur32 (Windows) / gssapi_krb5 (Linux/macOS) are all pulled in transitively.
Platform support
| Platform | Auth backend | Status |
|---|---|---|
| Windows | SSPI (secur32.dll), NTLM and Kerberos |
Working |
| Linux / macOS | GSSAPI (libgssapi_krb5), SPNEGO / Kerberos |
Compiled; not yet integration-tested |
Both AuthPackage::Ntlm and AuthPackage::Kerberos work on Windows. Kerberos hides the username on the wire and is preferred in production; NTLM is the fallback when the KDC (port 88) is unreachable.
On Linux/macOS the GSSAPI backend uses SPNEGO with Kerberos. For NTLM support install the gss-ntlmssp plugin:
apt install libgss-ntlmssp # Debian / Ubuntu
dnf install gssntlmssp # Fedora / RHEL
Without the plugin the server will negotiate Kerberos. Explicit user/password credentials use gss_acquire_cred_with_password (MIT Kerberos 1.9+); pass empty strings to use the default credential cache (kinit). The GSSAPI path compiles cleanly and is correct by design, but has not yet been validated end-to-end against a live DC, treat Linux/macOS support as beta.
Encryption note: ADWS on port 9389 is encrypted at the NNS layer (NTLM session key or Kerberos AES-256), not TLS. This is by design, the DC does not offer TLS on this port. If certificate-based server authentication is required, LDAPS (port 636) is the alternative.
Dependencies
Fetched automatically at configure time via cmake/Dependencies.cmake, no manual installation needed:
| Library | Version | Purpose |
|---|---|---|
| Catch2 | v3.5.2 | Unit test framework (test targets only) |
| pugixml | v1.14 | XML DOM (ADWS response parsing only) |
No OpenSSL. No Asio. No Boost.
Known limitations
-
NTLM on Linux/macOS requires the
gss-ntlmsspGSSAPI plugin (see Platform support). Without it the server will negotiate Kerberos instead. NTLM on Windows works natively via SSPI. -
pugixml DOM, each SOAP response is fully parsed into an in-memory XML tree before any results are returned. This is bounded by the
maxElementspage size (default 256 objects per Pull), so the full result set is never held in memory at once. Memory only becomes a concern with very large page sizes or objects carrying large binary attributes (e.g.nTSecurityDescriptoron objects with many ACEs). A SAX/streaming approach would eliminate this but is not currently planned.
Protocol references
- [MS-ADDM], Active Directory Web Services: Data Model and Common Elements
- [MS-NNS], .NET NegotiateStream Protocol
- [MC-NMF], .NET Message Framing
- [MC-NBFSE], .NET Binary Format: SOAP Extension
- [MS-DTYP], Windows Data Types (SECURITY_DESCRIPTOR, ACL, ACE, SID, GUID)
- [MS-ADTS], Active Directory Technical Specification (LDAP filters / attributes)
All specifications are available from Microsoft's Open Specifications documentation.
Special Thanks
Special thanks to IBM X-Force and the SoaPy project for their inspiration, research, and publicly shared work that helped inform this project. I would also like to recognize Logan Goins, the creator of SoaPy at IBM X-Force Red, for the effort and insight that made that work possible.
License
See LICENSE for details.