Commit 3508aac1 authored by jan.koester's avatar jan.koester
Browse files

test

parent 5700f8da
Loading
Loading
Loading
Loading
+151 −28
Changes for src/security/DnsTxtResolver.cpp: 151 added lines, 28 removed lines.
Original line number Diff line number Diff line
@@ -3,6 +3,7 @@
#include <netplus/socket.h>
#include <netplus/exception.h>

#include <algorithm>
#include <atomic>
#include <cstdint>
#include <fstream>
@@ -40,10 +41,9 @@ std::vector<uint8_t> encodeName(const std::string &name) {
    return out;
}

// Builds a single-question TXT query, with an EDNS0 OPT record advertising a
// 4096-byte UDP payload size so typical DKIM/DMARC records fit without
// truncation.
std::vector<uint8_t> buildQuery(const std::string &name, uint16_t id) {
// Builds a single-question query of the given RR type, with an EDNS0 OPT record advertising a
// 4096-byte UDP payload size so typical records fit without truncation.
std::vector<uint8_t> buildQuery(const std::string &name, uint16_t qtype, uint16_t id) {
    std::vector<uint8_t> nameEnc = encodeName(name);
    if (nameEnc.size() <= 1) return {};

@@ -56,7 +56,7 @@ std::vector<uint8_t> buildQuery(const std::string &name, uint16_t id) {
    push16(q, 1);      // ARCOUNT (EDNS0 OPT)

    q.insert(q.end(), nameEnc.begin(), nameEnc.end());
    push16(q, 16); // QTYPE = TXT
    push16(q, qtype);
    push16(q, 1);  // QCLASS = IN

    q.push_back(0x00);  // OPT owner name = root
@@ -86,9 +86,45 @@ bool skipName(const uint8_t *data, size_t size, size_t &pos) {
    return false;
}

std::vector<std::string> parseTxtResponse(const std::vector<uint8_t> &resp, uint16_t expectedId,
// Decodes a (possibly compressed) DNS name into dotted-label text, following compression
// pointers -- unlike skipName(), which only advances past a name without reading it. Needed
// for MX rdata, whose exchange-name field routinely points back into the message rather than
// spelling itself out. `hops` bounds pointer-chasing against a maliciously/corruptly looping
// response (RFC 1035 has no hard limit; any well-formed message needs far fewer than this).
std::string decodeName(const uint8_t *data, size_t size, size_t pos) {
    std::string name;
    int hops = 0;
    while (pos < size && hops++ < 128) {
        uint8_t b = data[pos];
        if ((b & 0xC0) == 0xC0) {
            if (pos + 2 > size) return "";
            size_t target = (static_cast<size_t>(b & 0x3F) << 8) | data[pos + 1];
            if (target >= size) return "";
            pos = target;
            continue;
        }
        if ((b & 0xC0) != 0) return ""; // reserved label type
        if (b == 0) break;
        if (pos + 1 + b > size) return "";
        if (!name.empty()) name += '.';
        name.append(reinterpret_cast<const char *>(data + pos + 1), b);
        pos += 1 + b;
    }
    return name;
}

struct RawAnswer {
    uint16_t type;
    size_t rdataStart;
    uint16_t rdlength;
};

// Walks the answer section of a response, returning each answer's type and where its RDATA
// lives in `resp` -- callers interpret RDATA themselves (TXT character-strings, raw A/AAAA
// address bytes, or an MX preference+name pair) since that's type-specific.
std::vector<RawAnswer> parseAnswers(const std::vector<uint8_t> &resp, uint16_t expectedId,
                                     bool &truncated) {
    std::vector<std::string> results;
    std::vector<RawAnswer> results;
    truncated = false;
    if (resp.size() < 12) return results;

@@ -119,10 +155,21 @@ std::vector<std::string> parseTxtResponse(const std::vector<uint8_t> &resp, uint
        pos += 2;
        if (pos + rdlength > size) return results;

        if (type == 16) { // TXT
        results.push_back({type, pos, rdlength});
        pos += rdlength;
    }
    return results;
}

std::vector<std::string> extractTxt(const std::vector<uint8_t> &resp,
                                     const std::vector<RawAnswer> &answers) {
    std::vector<std::string> out;
    const uint8_t *data = resp.data();
    for (const auto &a : answers) {
        if (a.type != 16) continue;
        std::string value;
            size_t end = pos + rdlength;
            size_t p = pos;
        size_t end = a.rdataStart + a.rdlength;
        size_t p = a.rdataStart;
        while (p < end) {
            uint8_t len = data[p];
            p += 1;
@@ -130,11 +177,48 @@ std::vector<std::string> parseTxtResponse(const std::vector<uint8_t> &resp, uint
            value.append(reinterpret_cast<const char *>(data + p), len);
            p += len;
        }
            results.push_back(std::move(value));
        out.push_back(std::move(value));
    }
        pos += rdlength;
    return out;
}
    return results;

std::vector<std::array<uint8_t, 4>> extractA(const std::vector<uint8_t> &resp,
                                              const std::vector<RawAnswer> &answers) {
    std::vector<std::array<uint8_t, 4>> out;
    const uint8_t *data = resp.data();
    for (const auto &a : answers) {
        if (a.type != 1 || a.rdlength != 4) continue;
        std::array<uint8_t, 4> addr{};
        std::copy(data + a.rdataStart, data + a.rdataStart + 4, addr.begin());
        out.push_back(addr);
    }
    return out;
}

std::vector<std::array<uint8_t, 16>> extractAaaa(const std::vector<uint8_t> &resp,
                                                  const std::vector<RawAnswer> &answers) {
    std::vector<std::array<uint8_t, 16>> out;
    const uint8_t *data = resp.data();
    for (const auto &a : answers) {
        if (a.type != 28 || a.rdlength != 16) continue;
        std::array<uint8_t, 16> addr{};
        std::copy(data + a.rdataStart, data + a.rdataStart + 16, addr.begin());
        out.push_back(addr);
    }
    return out;
}

std::vector<std::string> extractMx(const std::vector<uint8_t> &resp,
                                    const std::vector<RawAnswer> &answers) {
    std::vector<std::string> out;
    const uint8_t *data = resp.data();
    size_t size = resp.size();
    for (const auto &a : answers) {
        if (a.type != 15 || a.rdlength < 3) continue; // 2-byte preference + at least a root label
        std::string exchange = decodeName(data, size, a.rdataStart + 2);
        if (!exchange.empty()) out.push_back(std::move(exchange));
    }
    return out;
}

std::vector<std::string> readResolvConf() {
@@ -153,8 +237,8 @@ std::vector<std::string> readResolvConf() {
    return servers;
}

std::vector<std::string> queryOverTcp(const std::string &server, const std::vector<uint8_t> &query,
                                      uint16_t id, int timeoutMs) {
std::vector<uint8_t> queryOverTcp(const std::string &server, const std::vector<uint8_t> &query,
                                   int timeoutMs) {
    try {
        netplus::tcp sock;
        sock.connectTimeout(server, 53, timeoutMs);
@@ -180,21 +264,19 @@ std::vector<std::string> queryOverTcp(const std::string &server, const std::vect
            resp.insert(resp.end(), chunk.data.buf, chunk.data.buf + n);
        }
        if (resp.size() != respLen) return {};

        bool truncated = false;
        return parseTxtResponse(resp, id, truncated);
        return resp;
    } catch (const netplus::NetException &) {
        return {};
    }
}

} // namespace

std::vector<std::string> queryTxtRecords(const std::string &name, int timeoutMs) {
    static std::atomic<uint16_t> idCounter{1};
    uint16_t id = idCounter.fetch_add(1);

    std::vector<uint8_t> query = buildQuery(name, id);
// Shared transport for every RR type: builds the query, tries each configured resolver over
// UDP, retries over TCP (RFC 7766) if the resolver truncates, and returns the first usable raw
// response (or empty if every resolver failed/timed out). Type-specific extraction happens in
// the caller via parseAnswers()/extract*().
std::vector<uint8_t> resolveRaw(const std::string &name, uint16_t qtype, uint16_t id,
                                 int timeoutMs) {
    std::vector<uint8_t> query = buildQuery(name, qtype, id);
    if (query.empty()) return {};

    for (const auto &server : readResolvConf()) {
@@ -212,14 +294,16 @@ std::vector<std::string> queryTxtRecords(const std::string &name, int timeoutMs)

            std::vector<uint8_t> resp(recvBuf.data.buf, recvBuf.data.buf + n);
            bool truncated = false;
            std::vector<std::string> txt = parseTxtResponse(resp, id, truncated);
            // Only used here to check the truncation flag; the actual parse into RawAnswers
            // happens in the caller, which needs to interpret RDATA for a specific RR type.
            parseAnswers(resp, id, truncated);

            if (truncated) {
                std::vector<std::string> viaTcp = queryOverTcp(server, query, id, timeoutMs);
                std::vector<uint8_t> viaTcp = queryOverTcp(server, query, timeoutMs);
                if (!viaTcp.empty()) return viaTcp;
                continue; // TCP fallback failed too; try the next configured resolver
            }
            return txt; // authoritative answer, possibly empty (no such record)
            return resp; // authoritative answer, possibly empty (no such record)
        } catch (const netplus::NetException &) {
            continue; // unreachable/timed-out resolver; try the next one
        }
@@ -227,4 +311,43 @@ std::vector<std::string> queryTxtRecords(const std::string &name, int timeoutMs)
    return {};
}

uint16_t nextQueryId() {
    static std::atomic<uint16_t> idCounter{1};
    return idCounter.fetch_add(1);
}

} // namespace

std::vector<std::string> queryTxtRecords(const std::string &name, int timeoutMs) {
    uint16_t id = nextQueryId();
    std::vector<uint8_t> resp = resolveRaw(name, 16, id, timeoutMs);
    if (resp.empty()) return {};
    bool truncated = false;
    return extractTxt(resp, parseAnswers(resp, id, truncated));
}

std::vector<std::array<uint8_t, 4>> queryARecords(const std::string &name, int timeoutMs) {
    uint16_t id = nextQueryId();
    std::vector<uint8_t> resp = resolveRaw(name, 1, id, timeoutMs);
    if (resp.empty()) return {};
    bool truncated = false;
    return extractA(resp, parseAnswers(resp, id, truncated));
}

std::vector<std::array<uint8_t, 16>> queryAaaaRecords(const std::string &name, int timeoutMs) {
    uint16_t id = nextQueryId();
    std::vector<uint8_t> resp = resolveRaw(name, 28, id, timeoutMs);
    if (resp.empty()) return {};
    bool truncated = false;
    return extractAaaa(resp, parseAnswers(resp, id, truncated));
}

std::vector<std::string> queryMxRecords(const std::string &name, int timeoutMs) {
    uint16_t id = nextQueryId();
    std::vector<uint8_t> resp = resolveRaw(name, 15, id, timeoutMs);
    if (resp.empty()) return {};
    bool truncated = false;
    return extractMx(resp, parseAnswers(resp, id, truncated));
}

} // namespace proxyplus::security
+25 −11
Changes for src/security/DnsTxtResolver.h: 25 added lines, 11 removed lines.
Original line number Diff line number Diff line
#pragma once

#include <array>
#include <cstdint>
#include <string>
#include <vector>

namespace proxyplus::security {

// Minimal stub-resolver TXT lookup (RFC 1035) against the system's configured
// DNS resolvers (/etc/resolv.conf), used to fetch DKIM public-key records
// (<selector>._domainkey.<domain>) and DMARC policy records (_dmarc.<domain>).
// EDNS0 is advertised to avoid truncation for typical record sizes, with a
// TCP retry (RFC 7766) if the resolver truncates anyway.
// Minimal stub-resolver lookups (RFC 1035) against the system's configured DNS resolvers
// (/etc/resolv.conf), used to fetch DKIM public-key records (<selector>._domainkey.<domain>),
// DMARC policy records (_dmarc.<domain>), and SPF policy records + the A/AAAA/MX lookups SPF's
// "a"/"mx" mechanisms need (see SpfAdapter). EDNS0 is advertised to avoid truncation for
// typical record sizes, with a TCP retry (RFC 7766) if the resolver truncates anyway.
//
// Returns one entry per matching TXT answer RR, each already reassembled from
// its DNS <character-string> chunks (RFC 1035 3.3.14). Empty on NXDOMAIN,
// timeout/unreachable resolver, or any malformed/unparseable response --
// callers cannot distinguish "no such record" from "lookup failed" from this
// alone, which matches how both DKIM and DMARC treat a failed lookup (no
// valid signature / no policy found).
// All of these return one entry per matching answer RR. Empty on NXDOMAIN, timeout/unreachable
// resolver, or any malformed/unparseable response -- callers cannot distinguish "no such
// record" from "lookup failed" from this alone, which matches how DKIM/DMARC/SPF all treat a
// failed lookup (no valid signature / no policy found / mechanism does not match).

// TXT records, each already reassembled from its DNS <character-string> chunks (RFC 1035
// §3.3.14).
std::vector<std::string> queryTxtRecords(const std::string &name, int timeoutMs = 3000);

// A records, as raw 4-byte addresses.
std::vector<std::array<uint8_t, 4>> queryARecords(const std::string &name, int timeoutMs = 3000);

// AAAA records, as raw 16-byte addresses.
std::vector<std::array<uint8_t, 16>> queryAaaaRecords(const std::string &name,
                                                       int timeoutMs = 3000);

// MX target hostnames -- not preference-sorted; SPF's "mx" mechanism doesn't care about
// priority, only whether the client IP matches any listed exchange's address.
std::vector<std::string> queryMxRecords(const std::string &name, int timeoutMs = 3000);

} // namespace proxyplus::security
+317 −0

File added.

Preview size limit exceeded, changes collapsed.

+35 −0
Changes for src/security/SpfAdapter.h: 35 added lines, 0 removed lines.
Original line number Diff line number Diff line
#pragma once

#include <string>

namespace proxyplus::security {

enum class SpfResult { None, Neutral, Pass, Fail, SoftFail, TempError, PermError };

// Own RFC 7208 (SPF) implementation on top of DnsTxtResolver's TXT/A/AAAA/MX lookups -- no
// external SPF library dependency, matching DkimAdapter/DmarcAdapter (see their headers).
//
// Supports: the "all", "ip4", "ip6", "a" and "mx" mechanisms (with dual-cidr-length), the
// "include" mechanism (recursive), the "redirect" modifier, and the RFC's mandatory 10-DNS-
// lookup budget (§4.6.4) -- exceeding it, or hitting any other syntax error, yields PermError.
//
// Known gaps against the full RFC:
//  - No macro expansion (%{...}) anywhere. A domain-spec containing '%' is left unevaluated
//    and that mechanism/modifier is treated as never matching, rather than misinterpreting it.
//  - "exists" and "ptr" mechanisms are recognized (so a record using them is not a syntax
//    error) but never match -- "exists" needs macro expansion to build its query name, and
//    "ptr" is deprecated by the RFC itself (§5.5, "SHOULD NOT be published/used").
//  - The "exp=" modifier (explanation text) is parsed and ignored -- cosmetic only, doesn't
//    affect the pass/fail result.
//  - "mx"'s own per-mechanism address-lookup budget (RFC 7208 §4.6.4, distinct from the
//    overall 10-term budget) is approximated as "at most 10 MX targets processed", since this
//    implementation does exactly one address lookup per target.
class SpfAdapter {
public:
    // domain: the domain whose policy governs this check -- normally the MAIL FROM address's
    // domain, or the HELO/EHLO domain when MAIL FROM is the null/bounce address "<>" (RFC 7208
    // §2.4). clientIp: the connecting SMTP client's IP address, IPv4 or IPv6 text form.
    SpfResult check(const std::string &domain, const std::string &clientIp);
};

} // namespace proxyplus::security
+65 −26
Changes for tools/proxyplus-bootstrap.cpp: 65 added lines, 26 removed lines.
Original line number Diff line number Diff line
@@ -19,6 +19,7 @@

#include <fstream>
#include <iostream>
#include <map>
#include <string>
#include <vector>

@@ -31,39 +32,57 @@ namespace {

struct PermissionSpec {
    std::string permission;
    // Static per-permission GPO id, in the same spirit as authdb's own src/gpo_default.h:
    // fixed so reruns hit GPOadd's id-match idempotent fast-path (api.cpp) instead of
    // colliding on Gpo::create()'s by-name uniqueness check with a fresh random id every time.
    std::string gpoId;
    std::vector<std::string> allowedGroups;
};

// The mandate's example permission list (see docs/ROLES.md). super_admin is implicitly
// allowed everywhere in a real deployment; kept explicit here for clarity.
const std::vector<PermissionSpec> kPermissions = {
    {"admin.dashboard.read", {"proxyplus.super_admin", "proxyplus.readonly_operator"}},
    {"admin.users.read", {"proxyplus.super_admin", "proxyplus.user_admin", "proxyplus.auditor"}},
    {"admin.users.write", {"proxyplus.super_admin", "proxyplus.user_admin"}},
    {"admin.roles.manage", {"proxyplus.super_admin"}},
    {"http.routes.read", {"proxyplus.super_admin", "proxyplus.http_admin", "proxyplus.auditor"}},
    {"http.routes.write", {"proxyplus.super_admin", "proxyplus.http_admin"}},
    {"http.proxy.use", {"proxyplus.http_proxy_user"}},
    {"smtp.domains.read", {"proxyplus.super_admin", "proxyplus.smtp_admin", "proxyplus.auditor"}},
    {"smtp.domains.write", {"proxyplus.super_admin", "proxyplus.smtp_admin"}},
    {"smtp.relay.use", {"proxyplus.smtp_relay_user"}},
    {"smtp.queue.manage", {"proxyplus.super_admin", "proxyplus.smtp_admin"}},
    {"smtp.quarantine.manage", {"proxyplus.super_admin", "proxyplus.smtp_admin", "proxyplus.security_admin"}},
    {"plugins.read", {"proxyplus.super_admin", "proxyplus.auditor"}},
    {"plugins.manage", {"proxyplus.super_admin"}},
    {"cluster.read", {"proxyplus.super_admin", "proxyplus.cluster_admin", "proxyplus.auditor"}},
    {"cluster.manage", {"proxyplus.super_admin", "proxyplus.cluster_admin"}},
    {"audit.read", {"proxyplus.super_admin", "proxyplus.auditor"}},
    {"tls.profiles.read",
    {"admin.dashboard.read", "51c132c1-aa5f-4118-92a4-685465f43d8a",
     {"proxyplus.super_admin", "proxyplus.readonly_operator"}},
    {"admin.users.read", "3992ff8a-cbc2-4047-925b-fd158b33301b",
     {"proxyplus.super_admin", "proxyplus.user_admin", "proxyplus.auditor"}},
    {"admin.users.write", "a1185ad7-7023-4fff-b3e5-c2aa5dbe1994",
     {"proxyplus.super_admin", "proxyplus.user_admin"}},
    {"admin.roles.manage", "8b91a514-7fba-4f81-ad70-b493f68cbf17", {"proxyplus.super_admin"}},
    {"http.routes.read", "f6ba0fff-a1cb-4ba4-a78b-5a2b2622e267",
     {"proxyplus.super_admin", "proxyplus.http_admin", "proxyplus.auditor"}},
    {"http.routes.write", "93d97335-85d2-4f5d-93ef-cbe752e82cfe",
     {"proxyplus.super_admin", "proxyplus.http_admin"}},
    {"http.proxy.use", "7efe89cd-b6b0-40e4-96a3-dc9e8f6d2ebb", {"proxyplus.http_proxy_user"}},
    {"smtp.domains.read", "9da99680-23b0-4d00-9029-5654f2f78acc",
     {"proxyplus.super_admin", "proxyplus.smtp_admin", "proxyplus.auditor"}},
    {"smtp.domains.write", "e4b715c2-fb61-4a25-8658-050a46312c8f",
     {"proxyplus.super_admin", "proxyplus.smtp_admin"}},
    {"smtp.relay.use", "5c9aff18-a5b7-4361-a197-fd348edab069", {"proxyplus.smtp_relay_user"}},
    {"smtp.queue.manage", "359a786c-14af-4533-bc6c-f0686b3886c4",
     {"proxyplus.super_admin", "proxyplus.smtp_admin"}},
    {"smtp.quarantine.manage", "5e4d3e48-44bc-4ad4-ae77-789d9f45cc9a",
     {"proxyplus.super_admin", "proxyplus.smtp_admin", "proxyplus.security_admin"}},
    {"plugins.read", "4973d272-fec5-4048-9e9e-c2c34f3d321b", {"proxyplus.super_admin", "proxyplus.auditor"}},
    {"plugins.manage", "91828350-94b7-4488-9858-48574275e06d", {"proxyplus.super_admin"}},
    {"cluster.read", "c704993a-e51b-49f9-91c6-0f6aad133677",
     {"proxyplus.super_admin", "proxyplus.cluster_admin", "proxyplus.auditor"}},
    {"cluster.manage", "c9c5deb5-9180-422d-958e-931ddded5789",
     {"proxyplus.super_admin", "proxyplus.cluster_admin"}},
    {"audit.read", "3c4af3a9-fb57-4ffd-9a0e-e4adcde0870e", {"proxyplus.super_admin", "proxyplus.auditor"}},
    {"tls.profiles.read", "03d00068-b1bc-4813-a9a7-fe4d0ad60b17",
     {"proxyplus.super_admin", "proxyplus.security_admin", "proxyplus.http_admin",
      "proxyplus.smtp_admin", "proxyplus.cluster_admin", "proxyplus.auditor"}},
    {"tls.profiles.write", {"proxyplus.super_admin", "proxyplus.security_admin"}},
    {"lb.pools.read",
    {"tls.profiles.write", "550129f0-bfce-475d-96b1-d1679405a6a1",
     {"proxyplus.super_admin", "proxyplus.security_admin"}},
    {"lb.pools.read", "9f3f8089-5322-407a-98ed-701f9eeeccb1",
     {"proxyplus.super_admin", "proxyplus.http_admin", "proxyplus.smtp_admin", "proxyplus.auditor"}},
    {"lb.pools.write", {"proxyplus.super_admin", "proxyplus.http_admin", "proxyplus.smtp_admin"}},
    {"security.dkim.read",
    {"lb.pools.write", "2d5cea23-9d45-4abe-a026-697a1c662867",
     {"proxyplus.super_admin", "proxyplus.http_admin", "proxyplus.smtp_admin"}},
    {"security.dkim.read", "d427db33-2673-45a1-a48a-0ae1820b3a2f",
     {"proxyplus.super_admin", "proxyplus.security_admin", "proxyplus.smtp_admin", "proxyplus.auditor"}},
    {"security.dkim.write", {"proxyplus.super_admin", "proxyplus.security_admin"}},
    {"security.dkim.write", "ce163c81-f4b8-47c2-bda5-c3a4bf3dedc4",
     {"proxyplus.super_admin", "proxyplus.security_admin"}},
};

} // namespace
@@ -102,13 +121,33 @@ int main(int argc, char **argv) {
    std::cerr << "created service client '" << serviceClientName << "' (id=" << clientId << ")"
              << std::endl;

    // authdb's wire format takes GPO members as group UUIDs, not names (api.cpp parses each
    // gpomembers entry as uuid::uuid(str)) -- resolve docs/ROLES.md's proxyplus.* group names
    // to their real ids here. Groups not yet created by an authdb admin (see "Bekannte Luecke"
    // above) are dropped from that GPO's members with a warning rather than sent as garbage.
    std::map<std::string, std::string> groupNameToId;
    for (const auto &g : admin.listGroups()) {
        groupNameToId[g.name] = g.gid;
    }

    proxyplus::store::ConfigStore store(dataDir);
    for (const auto &spec : kPermissions) {
        uuid::uuid gpoId;
        gpoId.generate();
        std::vector<std::string> memberIds;
        for (const auto &groupName : spec.allowedGroups) {
            auto it = groupNameToId.find(groupName);
            if (it == groupNameToId.end()) {
                std::cerr << "warning: group '" << groupName << "' does not exist yet in authdb"
                          << " -- omitted from " << spec.permission
                          << "'s GPO members until an authdb admin creates it" << std::endl;
                continue;
            }
            memberIds.push_back(it->second);
        }

        uuid::uuid gpoId(spec.gpoId.c_str());
        try {
            admin.GPOadd(gpoId, spec.permission, "ProxyPlus permission: " + spec.permission, false,
                         spec.allowedGroups);
                         memberIds);
        } catch (const std::exception &e) {
            std::cerr << "GPOadd(" << spec.permission << ") failed: " << e.what() << std::endl;
            continue;