@blamejs/core 0.8.52 → 0.8.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/index.js +8 -0
- package/lib/audit.js +4 -0
- package/lib/auth/fido-mds3.js +624 -0
- package/lib/auth/passkey.js +214 -2
- package/lib/auth-bot-challenge.js +1 -1
- package/lib/credential-hash.js +2 -2
- package/lib/framework-error.js +55 -0
- package/lib/guard-cidr.js +2 -1
- package/lib/guard-jwt.js +2 -2
- package/lib/guard-oauth.js +2 -2
- package/lib/http-client-cache.js +916 -0
- package/lib/http-client.js +242 -0
- package/lib/mail-arf.js +343 -0
- package/lib/mail-auth.js +265 -40
- package/lib/mail-bimi.js +948 -33
- package/lib/mail-bounce.js +386 -4
- package/lib/mail-mdn.js +424 -0
- package/lib/mail-unsubscribe.js +265 -25
- package/lib/mail.js +403 -21
- package/lib/middleware/bearer-auth.js +1 -1
- package/lib/middleware/clear-site-data.js +122 -0
- package/lib/middleware/dpop.js +1 -1
- package/lib/middleware/index.js +9 -0
- package/lib/middleware/nel.js +214 -0
- package/lib/middleware/security-headers.js +56 -4
- package/lib/middleware/speculation-rules.js +323 -0
- package/lib/mime-parse.js +198 -0
- package/lib/network-dns.js +890 -27
- package/lib/network-tls.js +745 -0
- package/lib/object-store/sigv4.js +54 -0
- package/lib/public-suffix.js +414 -0
- package/lib/safe-buffer.js +7 -0
- package/lib/safe-json.js +1 -1
- package/lib/static.js +120 -0
- package/lib/storage.js +11 -0
- package/lib/vendor/MANIFEST.json +33 -0
- package/lib/vendor/bimi-trust-anchors.pem +33 -0
- package/lib/vendor/public-suffix-list.dat +16376 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/network-dns.js
CHANGED
|
@@ -594,7 +594,7 @@ async function _dotLookup(host, family) {
|
|
|
594
594
|
ready: new Promise(function (res, rej) {
|
|
595
595
|
sock.once("secureConnect", function () { res(); });
|
|
596
596
|
sock.once("error", function (e) {
|
|
597
|
-
rej(new DnsError("dns/dot-handshake",
|
|
597
|
+
rej(new DnsError("dns/dot-handshake-failed",
|
|
598
598
|
"DoT TLS handshake to " + STATE.dot.host + ":" + STATE.dot.port +
|
|
599
599
|
" failed: " + ((e && e.message) || String(e))));
|
|
600
600
|
});
|
|
@@ -671,6 +671,793 @@ function _resetDotPool() {
|
|
|
671
671
|
for (var i = 0; i < keys.length; i++) _dotEvict(keys[i]);
|
|
672
672
|
}
|
|
673
673
|
|
|
674
|
+
// ---- Generic DNS query (arbitrary QTYPE) -----------------------------
|
|
675
|
+
//
|
|
676
|
+
// The pre-v0.8.53 DNS module only handled A (1) and AAAA (28) lookups.
|
|
677
|
+
// SVCB (64) / HTTPS (65) and the DDR / DNR discovery primitives need a
|
|
678
|
+
// path that sends an arbitrary QTYPE and returns the raw rdata buffers
|
|
679
|
+
// for downstream parsing. These helpers reuse the existing encode +
|
|
680
|
+
// transport infrastructure (DoH / DoT / system) and add a small
|
|
681
|
+
// rdata-aware decoder that walks the answer section preserving the
|
|
682
|
+
// rdata bytes and answer offsets (needed because SVCB rdata contains
|
|
683
|
+
// compressed names that point back into the message).
|
|
684
|
+
//
|
|
685
|
+
// NOT IN SCOPE — DoQ (DNS-over-QUIC, RFC 9250). Node's QUIC support is
|
|
686
|
+
// experimental as of Node 24.x (tracking issue
|
|
687
|
+
// https://github.com/nodejs/node/issues/38478) and the framework's
|
|
688
|
+
// "no flag-gated experimental APIs in defaults" policy keeps it
|
|
689
|
+
// deferred. Operators wanting DoQ today wire it in their own agent
|
|
690
|
+
// and feed the returned IP set back through the existing transport
|
|
691
|
+
// abstractions (DDR-discovered DoH / DoT). Re-evaluate when Node
|
|
692
|
+
// flips QUIC stable.
|
|
693
|
+
|
|
694
|
+
// RFC 1035 §4.1.4 name compression — read a possibly-compressed name
|
|
695
|
+
// starting at `start`. Returns { name, nextOff } where nextOff is the
|
|
696
|
+
// byte immediately after the name's length-prefixed encoding (NOT
|
|
697
|
+
// chasing the pointer). `name` is a dot-joined string (without the
|
|
698
|
+
// trailing root label). Hardened against pointer loops with an
|
|
699
|
+
// iteration cap.
|
|
700
|
+
function _readDnsName(buf, start) {
|
|
701
|
+
var labels = [];
|
|
702
|
+
var off = start;
|
|
703
|
+
var nextOff = -1;
|
|
704
|
+
var iterations = 0;
|
|
705
|
+
var ITER_CAP = 256; // allow:raw-byte-literal — DNS name pointer-loop safeguard
|
|
706
|
+
while (off < buf.length && iterations < ITER_CAP) {
|
|
707
|
+
iterations += 1;
|
|
708
|
+
var len = buf[off];
|
|
709
|
+
if (len === 0) {
|
|
710
|
+
if (nextOff === -1) nextOff = off + 1;
|
|
711
|
+
break;
|
|
712
|
+
}
|
|
713
|
+
if ((len & 0xc0) === 0xc0) { // allow:raw-byte-literal — RFC 1035 name-compression pointer mask
|
|
714
|
+
if (off + 1 >= buf.length) {
|
|
715
|
+
throw new DnsError("dns/svcb-malformed",
|
|
716
|
+
"DNS name truncated at compression pointer");
|
|
717
|
+
}
|
|
718
|
+
if (nextOff === -1) nextOff = off + 2;
|
|
719
|
+
var ptr = ((len & 0x3f) << 8) | buf[off + 1]; // allow:raw-byte-literal — RFC 1035 pointer offset mask
|
|
720
|
+
if (ptr >= buf.length || ptr === off) {
|
|
721
|
+
throw new DnsError("dns/svcb-malformed",
|
|
722
|
+
"DNS name pointer out of bounds or self-referential");
|
|
723
|
+
}
|
|
724
|
+
off = ptr;
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
if ((len & 0xc0) !== 0) { // allow:raw-byte-literal — RFC 1035 reserved label-type bits
|
|
728
|
+
throw new DnsError("dns/svcb-malformed",
|
|
729
|
+
"DNS name has reserved label type 0x" + len.toString(HEX_RADIX));
|
|
730
|
+
}
|
|
731
|
+
if (off + 1 + len > buf.length) {
|
|
732
|
+
throw new DnsError("dns/svcb-malformed",
|
|
733
|
+
"DNS name label exceeds message length");
|
|
734
|
+
}
|
|
735
|
+
labels.push(buf.toString("ascii", off + 1, off + 1 + len));
|
|
736
|
+
off += 1 + len;
|
|
737
|
+
}
|
|
738
|
+
if (iterations >= ITER_CAP) {
|
|
739
|
+
throw new DnsError("dns/svcb-malformed",
|
|
740
|
+
"DNS name compression loop (>" + ITER_CAP + " hops)");
|
|
741
|
+
}
|
|
742
|
+
if (nextOff === -1) {
|
|
743
|
+
throw new DnsError("dns/svcb-malformed",
|
|
744
|
+
"DNS name not terminated");
|
|
745
|
+
}
|
|
746
|
+
return { name: labels.join("."), nextOff: nextOff };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Walk the answer section preserving rdata offsets so SVCB rdata can
|
|
750
|
+
// resolve compressed names against the full message buffer.
|
|
751
|
+
function _decodeDnsAnswerRaw(buf) {
|
|
752
|
+
if (!Buffer.isBuffer(buf) || buf.length < 12) {
|
|
753
|
+
throw new DnsError("dns/bad-reply", "dns reply truncated");
|
|
754
|
+
}
|
|
755
|
+
var rcode = buf.readUInt8(3) & 0x0f; // allow:raw-byte-literal — RFC 1035 RCODE nibble mask
|
|
756
|
+
if (rcode !== 0) {
|
|
757
|
+
throw new DnsError("dns/no-result", "dns reply rcode " + rcode);
|
|
758
|
+
}
|
|
759
|
+
var qdcount = buf.readUInt16BE(4);
|
|
760
|
+
var ancount = buf.readUInt16BE(6);
|
|
761
|
+
var state = { off: 12 };
|
|
762
|
+
for (var q = 0; q < qdcount; q++) {
|
|
763
|
+
_skipDnsName(buf, state);
|
|
764
|
+
state.off += 4;
|
|
765
|
+
}
|
|
766
|
+
var answers = [];
|
|
767
|
+
for (var a = 0; a < ancount; a++) {
|
|
768
|
+
_skipDnsName(buf, state);
|
|
769
|
+
var off = state.off;
|
|
770
|
+
if (off + 10 > buf.length) {
|
|
771
|
+
throw new DnsError("dns/bad-reply", "answer record truncated");
|
|
772
|
+
}
|
|
773
|
+
var rtype = buf.readUInt16BE(off); off += 2;
|
|
774
|
+
var rclass = buf.readUInt16BE(off); off += 2;
|
|
775
|
+
var ttl = buf.readUInt32BE(off); off += 4;
|
|
776
|
+
var rdlen = buf.readUInt16BE(off); off += 2;
|
|
777
|
+
if (off + rdlen > buf.length) {
|
|
778
|
+
throw new DnsError("dns/bad-reply", "answer rdata truncated");
|
|
779
|
+
}
|
|
780
|
+
answers.push({
|
|
781
|
+
rtype: rtype,
|
|
782
|
+
rclass: rclass,
|
|
783
|
+
ttl: ttl,
|
|
784
|
+
rdataOff: off,
|
|
785
|
+
rdlen: rdlen,
|
|
786
|
+
});
|
|
787
|
+
off += rdlen;
|
|
788
|
+
state.off = off;
|
|
789
|
+
}
|
|
790
|
+
return { msg: buf, answers: answers, ad: _readAdBit(buf) };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async function _dohRawQuery(host, qtype) {
|
|
794
|
+
var enc = _encodeDnsQuery(host, qtype);
|
|
795
|
+
var b64 = enc.buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
796
|
+
var getUrl = STATE.doh.url + (STATE.doh.url.indexOf("?") === -1 ? "?" : "&") + "dns=" + b64;
|
|
797
|
+
var forcedMethod = STATE.doh.method;
|
|
798
|
+
var usePost = forcedMethod === "POST" || (!forcedMethod && getUrl.length > DOH_GET_URL_MAX_BYTES);
|
|
799
|
+
var u = safeUrl.parse(STATE.doh.url, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS });
|
|
800
|
+
return new Promise(function (resolve, reject) {
|
|
801
|
+
var reqOpts = {
|
|
802
|
+
hostname: u.hostname,
|
|
803
|
+
port: u.port || 443, // allow:raw-byte-literal — HTTPS default port
|
|
804
|
+
path: u.pathname + u.search,
|
|
805
|
+
method: usePost ? "POST" : "GET",
|
|
806
|
+
headers: { "accept": "application/dns-message" },
|
|
807
|
+
minVersion: "TLSv1.3",
|
|
808
|
+
ecdhCurve: C.TLS_GROUP_CURVE_STR,
|
|
809
|
+
};
|
|
810
|
+
if (STATE.doh.ca) reqOpts.ca = STATE.doh.ca;
|
|
811
|
+
if (usePost) {
|
|
812
|
+
reqOpts.headers["content-type"] = "application/dns-message";
|
|
813
|
+
reqOpts.headers["content-length"] = enc.buf.length;
|
|
814
|
+
} else {
|
|
815
|
+
var parsedGet = safeUrl.parse(getUrl, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS });
|
|
816
|
+
reqOpts.path = parsedGet.pathname + parsedGet.search;
|
|
817
|
+
}
|
|
818
|
+
var req = https.request(reqOpts, function (res) {
|
|
819
|
+
var collector = safeBuffer.boundedChunkCollector({
|
|
820
|
+
maxBytes: C.BYTES.kib(256),
|
|
821
|
+
errorClass: DnsError,
|
|
822
|
+
sizeCode: "dns/doh-too-large",
|
|
823
|
+
sizeMessage: "DoH response exceeds 256 KiB",
|
|
824
|
+
});
|
|
825
|
+
var pushFailed = null;
|
|
826
|
+
res.on("data", function (c) {
|
|
827
|
+
if (pushFailed) return;
|
|
828
|
+
try { collector.push(c); }
|
|
829
|
+
catch (e) { pushFailed = e; }
|
|
830
|
+
});
|
|
831
|
+
res.on("end", function () {
|
|
832
|
+
try {
|
|
833
|
+
if (pushFailed) { reject(pushFailed); return; }
|
|
834
|
+
if (res.statusCode !== 200) { // allow:raw-byte-literal — HTTP 200 OK
|
|
835
|
+
reject(new DnsError("dns/doh-http", "DoH HTTP " + res.statusCode + " for " + host));
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
resolve(collector.result());
|
|
839
|
+
} catch (e) { reject(e); }
|
|
840
|
+
});
|
|
841
|
+
});
|
|
842
|
+
req.on("error", function (e) { reject(new DnsError("dns/doh-failed", "DoH request failed: " + e.message)); });
|
|
843
|
+
if (usePost) req.write(enc.buf);
|
|
844
|
+
req.end();
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function _dotRawQuery(host, qtype) {
|
|
849
|
+
var enc = _encodeDnsQuery(host, qtype);
|
|
850
|
+
var key = _dotPoolKey();
|
|
851
|
+
var entry = _dotPool.get(key);
|
|
852
|
+
if (entry && (Date.now() - entry.lastUsedAt > DOT_IDLE_TIMEOUT_MS)) {
|
|
853
|
+
_dotEvict(key);
|
|
854
|
+
entry = null;
|
|
855
|
+
}
|
|
856
|
+
if (!entry) {
|
|
857
|
+
var sock = _dotConnect();
|
|
858
|
+
entry = {
|
|
859
|
+
sock: sock,
|
|
860
|
+
lastUsedAt: Date.now(),
|
|
861
|
+
idle: true,
|
|
862
|
+
ready: new Promise(function (res, rej) {
|
|
863
|
+
sock.once("secureConnect", function () { res(); });
|
|
864
|
+
sock.once("error", function (e) {
|
|
865
|
+
rej(new DnsError("dns/dot-handshake-failed",
|
|
866
|
+
"DoT TLS handshake to " + STATE.dot.host + ":" + STATE.dot.port +
|
|
867
|
+
" failed: " + ((e && e.message) || String(e))));
|
|
868
|
+
});
|
|
869
|
+
}),
|
|
870
|
+
};
|
|
871
|
+
entry.ready.catch(function () { /* observed via per-lookup handler below */ });
|
|
872
|
+
_dotPool.set(key, entry);
|
|
873
|
+
sock.on("error", function () { _dotEvict(key); });
|
|
874
|
+
sock.on("close", function () { if (_dotPool.get(key) === entry) _dotPool.delete(key); });
|
|
875
|
+
}
|
|
876
|
+
var waitTicket = entry._tail || Promise.resolve();
|
|
877
|
+
entry._tail = waitTicket.then(function () {
|
|
878
|
+
return new Promise(function (resolve, reject) {
|
|
879
|
+
entry.idle = false;
|
|
880
|
+
try { entry.sock.ref(); } catch (_e) { /* best-effort event-loop hold */ }
|
|
881
|
+
Promise.resolve(entry.ready).then(function () {
|
|
882
|
+
var lenBuf = Buffer.alloc(2);
|
|
883
|
+
lenBuf.writeUInt16BE(enc.buf.length, 0);
|
|
884
|
+
var got = [];
|
|
885
|
+
var expectLen = -1;
|
|
886
|
+
var done = false;
|
|
887
|
+
function settle(err, val) {
|
|
888
|
+
if (done) return;
|
|
889
|
+
done = true;
|
|
890
|
+
entry.sock.removeListener("data", onData);
|
|
891
|
+
entry.sock.removeListener("error", onErr);
|
|
892
|
+
entry.idle = true;
|
|
893
|
+
entry.lastUsedAt = Date.now();
|
|
894
|
+
try { entry.sock.unref(); } catch (_e) { /* best-effort event-loop release */ }
|
|
895
|
+
if (err) reject(err); else resolve(val);
|
|
896
|
+
}
|
|
897
|
+
function onData(chunk) {
|
|
898
|
+
got.push(chunk);
|
|
899
|
+
var all = Buffer.concat(got);
|
|
900
|
+
if (expectLen === -1 && all.length >= 2) expectLen = all.readUInt16BE(0);
|
|
901
|
+
if (expectLen >= 0 && all.length >= expectLen + 2) {
|
|
902
|
+
settle(null, all.slice(2, 2 + expectLen));
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
function onErr(e) {
|
|
906
|
+
_dotEvict(key);
|
|
907
|
+
settle(new DnsError("dns/dot-failed", "DoT failed: " + e.message));
|
|
908
|
+
}
|
|
909
|
+
entry.sock.on("data", onData);
|
|
910
|
+
entry.sock.on("error", onErr);
|
|
911
|
+
entry.sock.write(lenBuf);
|
|
912
|
+
entry.sock.write(enc.buf);
|
|
913
|
+
}, function (handshakeErr) {
|
|
914
|
+
entry.idle = true;
|
|
915
|
+
try { entry.sock.unref(); } catch (_e) { /* best-effort event-loop release */ }
|
|
916
|
+
reject(handshakeErr);
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
});
|
|
920
|
+
return entry._tail;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
async function _systemRawQuery(host, qtype) {
|
|
924
|
+
// node:dns doesn't expose arbitrary-QTYPE wire-format queries; fall
|
|
925
|
+
// back to TCP framed query against the configured system resolvers
|
|
926
|
+
// (port 53). Used only when the operator has explicitly opted out
|
|
927
|
+
// of DoH/DoT via useSystemResolver().
|
|
928
|
+
var servers = getServers();
|
|
929
|
+
if (servers.length === 0) {
|
|
930
|
+
throw new DnsError("dns/no-system-resolvers",
|
|
931
|
+
"system resolver has no configured servers; cannot send raw QTYPE query");
|
|
932
|
+
}
|
|
933
|
+
var serverEntry = servers[0];
|
|
934
|
+
var serverHost = serverEntry;
|
|
935
|
+
var serverPort = 53; // allow:raw-byte-literal — IANA-assigned DNS port
|
|
936
|
+
var bracketEnd = serverEntry.lastIndexOf("]:");
|
|
937
|
+
if (bracketEnd !== -1) {
|
|
938
|
+
serverHost = serverEntry.slice(1, bracketEnd);
|
|
939
|
+
serverPort = parseInt(serverEntry.slice(bracketEnd + 2), 10) || 53; // allow:raw-byte-literal — IANA-assigned DNS port
|
|
940
|
+
} else if (serverEntry.indexOf(":") !== -1 && net.isIP(serverEntry) === 0) {
|
|
941
|
+
var colonIdx = serverEntry.lastIndexOf(":");
|
|
942
|
+
serverHost = serverEntry.slice(0, colonIdx);
|
|
943
|
+
serverPort = parseInt(serverEntry.slice(colonIdx + 1), 10) || 53; // allow:raw-byte-literal — IANA-assigned DNS port
|
|
944
|
+
}
|
|
945
|
+
var enc = _encodeDnsQuery(host, qtype);
|
|
946
|
+
return new Promise(function (resolve, reject) {
|
|
947
|
+
var sock = net.connect({ host: serverHost, port: serverPort });
|
|
948
|
+
var got = [];
|
|
949
|
+
var expectLen = -1;
|
|
950
|
+
var done = false;
|
|
951
|
+
function settle(err, val) {
|
|
952
|
+
if (done) return;
|
|
953
|
+
done = true;
|
|
954
|
+
try { sock.destroy(); } catch (_e) { /* best-effort socket teardown */ }
|
|
955
|
+
if (err) reject(err); else resolve(val);
|
|
956
|
+
}
|
|
957
|
+
sock.on("connect", function () {
|
|
958
|
+
var lenBuf = Buffer.alloc(2);
|
|
959
|
+
lenBuf.writeUInt16BE(enc.buf.length, 0);
|
|
960
|
+
sock.write(lenBuf);
|
|
961
|
+
sock.write(enc.buf);
|
|
962
|
+
});
|
|
963
|
+
sock.on("data", function (chunk) {
|
|
964
|
+
got.push(chunk);
|
|
965
|
+
var all = Buffer.concat(got);
|
|
966
|
+
if (expectLen === -1 && all.length >= 2) expectLen = all.readUInt16BE(0);
|
|
967
|
+
if (expectLen >= 0 && all.length >= expectLen + 2) {
|
|
968
|
+
settle(null, all.slice(2, 2 + expectLen));
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
sock.on("error", function (e) {
|
|
972
|
+
settle(new DnsError("dns/system-failed", "system DNS TCP query failed: " + e.message));
|
|
973
|
+
});
|
|
974
|
+
sock.on("close", function () {
|
|
975
|
+
if (!done) settle(new DnsError("dns/system-failed", "system DNS TCP closed before reply"));
|
|
976
|
+
});
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// Pick a transport for raw-QTYPE queries based on operator config.
|
|
981
|
+
// `forceTransport` (used by DDR) overrides; "system" routes through
|
|
982
|
+
// the OS resolver.
|
|
983
|
+
async function _rawQuery(host, qtype, forceTransport) {
|
|
984
|
+
_ensureSecureDefault();
|
|
985
|
+
var transport = forceTransport;
|
|
986
|
+
if (!transport) {
|
|
987
|
+
if (STATE.doh) transport = "doh";
|
|
988
|
+
else if (STATE.dot) transport = "dot";
|
|
989
|
+
else transport = "system";
|
|
990
|
+
}
|
|
991
|
+
if (transport === "doh") {
|
|
992
|
+
if (!STATE.doh) {
|
|
993
|
+
throw new DnsError("dns/transport-unavailable",
|
|
994
|
+
"raw query requested DoH transport but useDnsOverHttps() not configured");
|
|
995
|
+
}
|
|
996
|
+
return _withTimeout(_dohRawQuery(host, qtype), STATE.lookupTimeoutMs, host);
|
|
997
|
+
}
|
|
998
|
+
if (transport === "dot") {
|
|
999
|
+
if (!STATE.dot) {
|
|
1000
|
+
throw new DnsError("dns/transport-unavailable",
|
|
1001
|
+
"raw query requested DoT transport but useDnsOverTls() not configured");
|
|
1002
|
+
}
|
|
1003
|
+
return _withTimeout(_dotRawQuery(host, qtype), STATE.lookupTimeoutMs, host);
|
|
1004
|
+
}
|
|
1005
|
+
if (transport === "system") {
|
|
1006
|
+
return _withTimeout(_systemRawQuery(host, qtype), STATE.lookupTimeoutMs, host);
|
|
1007
|
+
}
|
|
1008
|
+
throw new DnsError("dns/bad-transport",
|
|
1009
|
+
"raw query: unknown transport '" + transport + "' (expected 'doh' | 'dot' | 'system')");
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// ---- SVCB / HTTPS RR (RFC 9460) --------------------------------------
|
|
1013
|
+
|
|
1014
|
+
var DNS_QTYPE_SVCB = 64; // allow:raw-byte-literal — RFC 9460 §14.1 SVCB record type code
|
|
1015
|
+
var DNS_QTYPE_HTTPS = 65; // allow:raw-byte-literal — RFC 9460 §14.1 HTTPS record type code
|
|
1016
|
+
|
|
1017
|
+
// SvcParamKey assignments (RFC 9460 §14.3.2 + IANA registry). Keys
|
|
1018
|
+
// past 7 are operator-extensible; we recognize the IETF-blessed set
|
|
1019
|
+
// and surface the rest as opaque buffers under params.unknown[<key>].
|
|
1020
|
+
var SVCB_KEY_MANDATORY = 0;
|
|
1021
|
+
var SVCB_KEY_ALPN = 1;
|
|
1022
|
+
var SVCB_KEY_NO_DEF_ALPN = 2;
|
|
1023
|
+
var SVCB_KEY_PORT = 3;
|
|
1024
|
+
var SVCB_KEY_IPV4HINT = 4;
|
|
1025
|
+
var SVCB_KEY_ECH = 5;
|
|
1026
|
+
var SVCB_KEY_IPV6HINT = 6;
|
|
1027
|
+
var SVCB_KEY_DOHPATH = 7; // allow:raw-byte-literal — RFC 9461 SvcParamKey
|
|
1028
|
+
|
|
1029
|
+
function _readCharString(buf, off, end) {
|
|
1030
|
+
if (off >= end) {
|
|
1031
|
+
throw new DnsError("dns/svcb-malformed", "alpn list truncated at char-string length");
|
|
1032
|
+
}
|
|
1033
|
+
var len = buf[off];
|
|
1034
|
+
if (off + 1 + len > end) {
|
|
1035
|
+
throw new DnsError("dns/svcb-malformed", "alpn char-string overflows alpn value");
|
|
1036
|
+
}
|
|
1037
|
+
return { value: buf.toString("utf8", off + 1, off + 1 + len), nextOff: off + 1 + len };
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function _parseSvcbRdata(msg, rdataOff, rdlen) {
|
|
1041
|
+
var end = rdataOff + rdlen;
|
|
1042
|
+
if (rdataOff + 2 > end) {
|
|
1043
|
+
throw new DnsError("dns/svcb-malformed", "SVCB rdata truncated before priority");
|
|
1044
|
+
}
|
|
1045
|
+
var priority = msg.readUInt16BE(rdataOff);
|
|
1046
|
+
var nameRes = _readDnsName(msg, rdataOff + 2);
|
|
1047
|
+
var target = nameRes.name === "" ? "." : nameRes.name;
|
|
1048
|
+
var off = nameRes.nextOff;
|
|
1049
|
+
var params = {};
|
|
1050
|
+
var prevKey = -1;
|
|
1051
|
+
while (off < end) {
|
|
1052
|
+
if (off + 4 > end) {
|
|
1053
|
+
throw new DnsError("dns/svcb-malformed", "SvcParam header truncated");
|
|
1054
|
+
}
|
|
1055
|
+
var key = msg.readUInt16BE(off); off += 2;
|
|
1056
|
+
var paramLen = msg.readUInt16BE(off); off += 2;
|
|
1057
|
+
if (off + paramLen > end) {
|
|
1058
|
+
throw new DnsError("dns/svcb-malformed", "SvcParam value overflows rdata");
|
|
1059
|
+
}
|
|
1060
|
+
if (key <= prevKey) {
|
|
1061
|
+
throw new DnsError("dns/svcb-malformed",
|
|
1062
|
+
"SvcParams not in ascending key order (key " + key + " after " + prevKey + ")");
|
|
1063
|
+
}
|
|
1064
|
+
prevKey = key;
|
|
1065
|
+
var paramEnd = off + paramLen;
|
|
1066
|
+
if (key === SVCB_KEY_MANDATORY) {
|
|
1067
|
+
if (paramLen % 2 !== 0) {
|
|
1068
|
+
throw new DnsError("dns/svcb-malformed", "mandatory SvcParam length not multiple of 2");
|
|
1069
|
+
}
|
|
1070
|
+
var mand = [];
|
|
1071
|
+
for (var mo = off; mo < paramEnd; mo += 2) {
|
|
1072
|
+
mand.push(msg.readUInt16BE(mo));
|
|
1073
|
+
}
|
|
1074
|
+
params.mandatory = mand;
|
|
1075
|
+
} else if (key === SVCB_KEY_ALPN) {
|
|
1076
|
+
var alpns = [];
|
|
1077
|
+
var ao = off;
|
|
1078
|
+
while (ao < paramEnd) {
|
|
1079
|
+
var cs = _readCharString(msg, ao, paramEnd);
|
|
1080
|
+
alpns.push(cs.value);
|
|
1081
|
+
ao = cs.nextOff;
|
|
1082
|
+
}
|
|
1083
|
+
params.alpn = alpns;
|
|
1084
|
+
} else if (key === SVCB_KEY_NO_DEF_ALPN) {
|
|
1085
|
+
if (paramLen !== 0) {
|
|
1086
|
+
throw new DnsError("dns/svcb-malformed", "no-default-alpn must have zero-length value");
|
|
1087
|
+
}
|
|
1088
|
+
params.noDefaultAlpn = true;
|
|
1089
|
+
} else if (key === SVCB_KEY_PORT) {
|
|
1090
|
+
if (paramLen !== 2) {
|
|
1091
|
+
throw new DnsError("dns/svcb-malformed", "port SvcParam must be 2 bytes");
|
|
1092
|
+
}
|
|
1093
|
+
params.port = msg.readUInt16BE(off);
|
|
1094
|
+
} else if (key === SVCB_KEY_IPV4HINT) {
|
|
1095
|
+
if (paramLen % 4 !== 0) {
|
|
1096
|
+
throw new DnsError("dns/svcb-malformed", "ipv4hint length not multiple of 4");
|
|
1097
|
+
}
|
|
1098
|
+
var v4 = [];
|
|
1099
|
+
for (var v4o = off; v4o < paramEnd; v4o += 4) {
|
|
1100
|
+
v4.push(msg[v4o] + "." + msg[v4o + 1] + "." + msg[v4o + 2] + "." + msg[v4o + 3]);
|
|
1101
|
+
}
|
|
1102
|
+
params.ipv4hint = v4;
|
|
1103
|
+
} else if (key === SVCB_KEY_ECH) {
|
|
1104
|
+
// ECHConfigList — opaque to the caller; surface as raw buffer.
|
|
1105
|
+
params.ech = Buffer.from(msg.slice(off, paramEnd));
|
|
1106
|
+
} else if (key === SVCB_KEY_IPV6HINT) {
|
|
1107
|
+
if (paramLen % IPV6_ADDR_BYTES !== 0) {
|
|
1108
|
+
throw new DnsError("dns/svcb-malformed", "ipv6hint length not multiple of 16");
|
|
1109
|
+
}
|
|
1110
|
+
var v6 = [];
|
|
1111
|
+
for (var v6o = off; v6o < paramEnd; v6o += IPV6_ADDR_BYTES) {
|
|
1112
|
+
var groups = [];
|
|
1113
|
+
for (var g = 0; g < IPV6_HEX_GROUPS; g++) {
|
|
1114
|
+
groups.push(msg.readUInt16BE(v6o + g * 2).toString(HEX_RADIX));
|
|
1115
|
+
}
|
|
1116
|
+
v6.push(groups.join(":"));
|
|
1117
|
+
}
|
|
1118
|
+
params.ipv6hint = v6;
|
|
1119
|
+
} else if (key === SVCB_KEY_DOHPATH) {
|
|
1120
|
+
params.dohpath = msg.toString("utf8", off, paramEnd);
|
|
1121
|
+
} else {
|
|
1122
|
+
// Unknown / future SvcParamKey — surface as opaque bytes so the
|
|
1123
|
+
// operator can still read it without us silently dropping the
|
|
1124
|
+
// record.
|
|
1125
|
+
if (!params.unknown) params.unknown = {};
|
|
1126
|
+
params.unknown[key] = Buffer.from(msg.slice(off, paramEnd));
|
|
1127
|
+
}
|
|
1128
|
+
off = paramEnd;
|
|
1129
|
+
}
|
|
1130
|
+
return { priority: priority, target: target, params: params };
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function _validateLdh(host, primitive) {
|
|
1134
|
+
if (typeof host !== "string" || host.length === 0 || host.length > 253) { // allow:raw-byte-literal — RFC 1035 hostname octet ceiling
|
|
1135
|
+
throw new DnsError("dns/bad-host",
|
|
1136
|
+
primitive + ": host must be a non-empty RFC 1035 LDH name (length 1..253)");
|
|
1137
|
+
}
|
|
1138
|
+
// Allow leading underscore on labels (SVCB / HTTPS query targets like
|
|
1139
|
+
// "_dns.resolver.arpa" require it).
|
|
1140
|
+
var labels = host.split(".");
|
|
1141
|
+
for (var li = 0; li < labels.length; li += 1) {
|
|
1142
|
+
var label = labels[li];
|
|
1143
|
+
if (label.length === 0 || label.length > 63) { // allow:raw-byte-literal — RFC 1035 max label length
|
|
1144
|
+
throw new DnsError("dns/bad-host",
|
|
1145
|
+
primitive + ": host label length must be 1..63");
|
|
1146
|
+
}
|
|
1147
|
+
if (!/^[A-Za-z0-9_](?:[A-Za-z0-9_-]*[A-Za-z0-9_])?$/.test(label)) {
|
|
1148
|
+
throw new DnsError("dns/bad-host",
|
|
1149
|
+
primitive + ": host label '" + label + "' violates LDH (allowed: letters/digits/underscore/hyphen, no leading/trailing hyphen)");
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
async function _querySvcbLike(host, qtype, opts) {
|
|
1155
|
+
opts = opts || {};
|
|
1156
|
+
validateOpts(opts, ["transport"], "dns.querySvcb");
|
|
1157
|
+
_validateLdh(host, "dns.querySvcb");
|
|
1158
|
+
if (opts.transport !== undefined && opts.transport !== "doh" &&
|
|
1159
|
+
opts.transport !== "dot" && opts.transport !== "system") {
|
|
1160
|
+
throw new DnsError("dns/bad-transport",
|
|
1161
|
+
"dns.querySvcb: transport must be 'doh' | 'dot' | 'system' | undefined");
|
|
1162
|
+
}
|
|
1163
|
+
_emitObs("network.dns.svcb.requested", { qtype: qtype, transport: opts.transport || "auto" });
|
|
1164
|
+
var startMs = _now();
|
|
1165
|
+
var reply;
|
|
1166
|
+
try {
|
|
1167
|
+
reply = await _rawQuery(host, qtype, opts.transport);
|
|
1168
|
+
} catch (e) {
|
|
1169
|
+
_emitObs("network.dns.svcb.failure", {
|
|
1170
|
+
latencyMs: _now() - startMs,
|
|
1171
|
+
code: e.code || "unknown",
|
|
1172
|
+
});
|
|
1173
|
+
throw e;
|
|
1174
|
+
}
|
|
1175
|
+
var decoded = _decodeDnsAnswerRaw(reply);
|
|
1176
|
+
var records = [];
|
|
1177
|
+
for (var i = 0; i < decoded.answers.length; i++) {
|
|
1178
|
+
var ans = decoded.answers[i];
|
|
1179
|
+
if (ans.rtype !== qtype) continue;
|
|
1180
|
+
records.push(_parseSvcbRdata(decoded.msg, ans.rdataOff, ans.rdlen));
|
|
1181
|
+
}
|
|
1182
|
+
records.sort(function (a, b) { return a.priority - b.priority; });
|
|
1183
|
+
_emitObs("network.dns.svcb.success", {
|
|
1184
|
+
latencyMs: _now() - startMs,
|
|
1185
|
+
count: records.length,
|
|
1186
|
+
qtype: qtype,
|
|
1187
|
+
});
|
|
1188
|
+
return records;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* @primitive b.network.dns.querySvcb
|
|
1193
|
+
* @signature b.network.dns.querySvcb(name, opts?)
|
|
1194
|
+
* @since 0.8.53
|
|
1195
|
+
* @status stable
|
|
1196
|
+
* @related b.network.dns.queryHttps, b.network.dns.discoverEncrypted
|
|
1197
|
+
*
|
|
1198
|
+
* Query SVCB records (RFC 9460 §2) for `name`. Returns an array of
|
|
1199
|
+
* `{ priority, target, params }` records sorted by priority. AliasMode
|
|
1200
|
+
* records (priority === 0) carry a `target` and empty `params` —
|
|
1201
|
+
* the caller chases the alias by re-querying the target. ServiceMode
|
|
1202
|
+
* records (priority > 0) carry SvcParams: `alpn` / `port` / `ipv4hint` /
|
|
1203
|
+
* `ipv6hint` / `ech` / `mandatory` / `dohpath`. Unknown SvcParamKeys
|
|
1204
|
+
* surface under `params.unknown[key]` as raw bytes — operators
|
|
1205
|
+
* implementing forward-compat can still read them. Malformed rdata
|
|
1206
|
+
* throws `DnsError` with code `dns/svcb-malformed`.
|
|
1207
|
+
*
|
|
1208
|
+
* @opts
|
|
1209
|
+
* {
|
|
1210
|
+
* transport: "doh" | "dot" | "system",
|
|
1211
|
+
* }
|
|
1212
|
+
*
|
|
1213
|
+
* @example
|
|
1214
|
+
* var b = require("@blamejs/core");
|
|
1215
|
+
* var rrs = await b.network.dns.querySvcb("_443._wss.example.com");
|
|
1216
|
+
*/
|
|
1217
|
+
async function querySvcb(name, opts) {
|
|
1218
|
+
return _querySvcbLike(name, DNS_QTYPE_SVCB, opts);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* @primitive b.network.dns.queryHttps
|
|
1223
|
+
* @signature b.network.dns.queryHttps(name, opts?)
|
|
1224
|
+
* @since 0.8.53
|
|
1225
|
+
* @status stable
|
|
1226
|
+
* @related b.network.dns.querySvcb
|
|
1227
|
+
*
|
|
1228
|
+
* Query HTTPS records (RFC 9460 §9). Identical to `querySvcb` except
|
|
1229
|
+
* the QTYPE is HTTPS (65) — the user-agent-facing variant of SVCB
|
|
1230
|
+
* for `https://` origins. Browsers query this for ECH discovery and
|
|
1231
|
+
* h3 advertisement; servers can call it to validate their own
|
|
1232
|
+
* published HTTPS RRset. Returns the same shape as `querySvcb`.
|
|
1233
|
+
*
|
|
1234
|
+
* @opts
|
|
1235
|
+
* {
|
|
1236
|
+
* transport: "doh" | "dot" | "system",
|
|
1237
|
+
* }
|
|
1238
|
+
*
|
|
1239
|
+
* @example
|
|
1240
|
+
* var b = require("@blamejs/core");
|
|
1241
|
+
* var rrs = await b.network.dns.queryHttps("example.com");
|
|
1242
|
+
*/
|
|
1243
|
+
async function queryHttps(name, opts) {
|
|
1244
|
+
return _querySvcbLike(name, DNS_QTYPE_HTTPS, opts);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// ---- DDR / DNR (RFC 9462 + RFC 9463) ---------------------------------
|
|
1248
|
+
|
|
1249
|
+
// Default DDR query target — RFC 9462 §3.
|
|
1250
|
+
var DDR_QUERY_NAME = "_dns.resolver.arpa";
|
|
1251
|
+
|
|
1252
|
+
// Operator-supplied designated resolver list. When set, the framework
|
|
1253
|
+
// prefers these over its own configured transport (subject to
|
|
1254
|
+
// `useDesignatedResolvers` having been called explicitly — never
|
|
1255
|
+
// silently overriding operator config).
|
|
1256
|
+
var _designatedResolvers = null;
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* @primitive b.network.dns.discoverEncrypted
|
|
1260
|
+
* @signature b.network.dns.discoverEncrypted(opts?)
|
|
1261
|
+
* @since 0.8.53
|
|
1262
|
+
* @status stable
|
|
1263
|
+
* @related b.network.dns.useDesignatedResolvers, b.network.dns.querySvcb
|
|
1264
|
+
*
|
|
1265
|
+
* RFC 9462 Discovery of Designated Resolvers. Queries
|
|
1266
|
+
* `_dns.resolver.arpa` for SVCB records that advertise encrypted DNS
|
|
1267
|
+
* alternatives (DoH / DoT) hosted by the network's currently-configured
|
|
1268
|
+
* Do53 resolver. Returns a list of resolver descriptors with
|
|
1269
|
+
* `{ transport, alpn, target, port, dohpath, ipv4hint, ipv6hint, priority }`.
|
|
1270
|
+
*
|
|
1271
|
+
* The discovery query goes through the system resolver by default
|
|
1272
|
+
* (RFC 9462 §4 — DDR validation requires the response to come from
|
|
1273
|
+
* the Do53 resolver whose IP we compare). Callers that already have
|
|
1274
|
+
* a trusted DoH / DoT transport configured can pass
|
|
1275
|
+
* `{ insecureSystemResolverOnly: false }` to allow DDR via the
|
|
1276
|
+
* encrypted transport too.
|
|
1277
|
+
*
|
|
1278
|
+
* Throws `DnsError` with code `dns/ddr-not-discovered` when the
|
|
1279
|
+
* resolver does not publish DDR records.
|
|
1280
|
+
*
|
|
1281
|
+
* @opts
|
|
1282
|
+
* {
|
|
1283
|
+
* name: string,
|
|
1284
|
+
* insecureSystemResolverOnly: boolean,
|
|
1285
|
+
* }
|
|
1286
|
+
*
|
|
1287
|
+
* @example
|
|
1288
|
+
* var b = require("@blamejs/core");
|
|
1289
|
+
* var resolvers = await b.network.dns.discoverEncrypted();
|
|
1290
|
+
*/
|
|
1291
|
+
async function discoverEncrypted(opts) {
|
|
1292
|
+
opts = opts || {};
|
|
1293
|
+
validateOpts(opts, ["name", "insecureSystemResolverOnly"], "dns.discoverEncrypted");
|
|
1294
|
+
var name = opts.name || DDR_QUERY_NAME;
|
|
1295
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
1296
|
+
throw new DnsError("dns/bad-host",
|
|
1297
|
+
"dns.discoverEncrypted: name must be a non-empty string");
|
|
1298
|
+
}
|
|
1299
|
+
var insecureOnly = opts.insecureSystemResolverOnly !== false;
|
|
1300
|
+
var transport = insecureOnly ? "system" : undefined;
|
|
1301
|
+
_validateLdh(name, "dns.discoverEncrypted");
|
|
1302
|
+
var startMs = _now();
|
|
1303
|
+
var records;
|
|
1304
|
+
try {
|
|
1305
|
+
records = await _querySvcbLike(name, DNS_QTYPE_SVCB, { transport: transport });
|
|
1306
|
+
} catch (e) {
|
|
1307
|
+
_emitObs("network.dns.ddr.failure", {
|
|
1308
|
+
latencyMs: _now() - startMs,
|
|
1309
|
+
code: e.code || "unknown",
|
|
1310
|
+
});
|
|
1311
|
+
if (e.code === "dns/no-result") {
|
|
1312
|
+
throw new DnsError("dns/ddr-not-discovered",
|
|
1313
|
+
"dns.discoverEncrypted: resolver did not publish DDR records at " + name);
|
|
1314
|
+
}
|
|
1315
|
+
throw e;
|
|
1316
|
+
}
|
|
1317
|
+
if (records.length === 0) {
|
|
1318
|
+
_emitObs("network.dns.ddr.empty", { latencyMs: _now() - startMs });
|
|
1319
|
+
throw new DnsError("dns/ddr-not-discovered",
|
|
1320
|
+
"dns.discoverEncrypted: resolver returned empty DDR record set at " + name);
|
|
1321
|
+
}
|
|
1322
|
+
var resolvers = [];
|
|
1323
|
+
for (var i = 0; i < records.length; i++) {
|
|
1324
|
+
var rec = records[i];
|
|
1325
|
+
if (rec.priority === 0) continue; // AliasMode — caller chases
|
|
1326
|
+
var alpn = (rec.params && rec.params.alpn) || [];
|
|
1327
|
+
var isDot = alpn.indexOf("dot") !== -1;
|
|
1328
|
+
var isDoh = alpn.indexOf("h2") !== -1 || alpn.indexOf("h3") !== -1 ||
|
|
1329
|
+
(rec.params && typeof rec.params.dohpath === "string");
|
|
1330
|
+
var transportKind = isDot ? "dot" : (isDoh ? "doh" : null);
|
|
1331
|
+
if (!transportKind) continue;
|
|
1332
|
+
resolvers.push({
|
|
1333
|
+
transport: transportKind,
|
|
1334
|
+
alpn: alpn,
|
|
1335
|
+
target: rec.target,
|
|
1336
|
+
port: (rec.params && rec.params.port) ||
|
|
1337
|
+
(transportKind === "dot" ? 853 : 443), // allow:raw-byte-literal — IANA-assigned DoT/HTTPS ports
|
|
1338
|
+
dohpath: (rec.params && rec.params.dohpath) || null,
|
|
1339
|
+
ipv4hint: (rec.params && rec.params.ipv4hint) || [],
|
|
1340
|
+
ipv6hint: (rec.params && rec.params.ipv6hint) || [],
|
|
1341
|
+
priority: rec.priority,
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
resolvers.sort(function (a, b) { return a.priority - b.priority; });
|
|
1345
|
+
if (resolvers.length === 0) {
|
|
1346
|
+
throw new DnsError("dns/ddr-not-discovered",
|
|
1347
|
+
"dns.discoverEncrypted: DDR records present but none advertised a recognized transport (alpn=dot/h2/h3)");
|
|
1348
|
+
}
|
|
1349
|
+
_emitObs("network.dns.ddr.success", {
|
|
1350
|
+
latencyMs: _now() - startMs,
|
|
1351
|
+
count: resolvers.length,
|
|
1352
|
+
});
|
|
1353
|
+
return resolvers;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* @primitive b.network.dns.useDesignatedResolvers
|
|
1358
|
+
* @signature b.network.dns.useDesignatedResolvers(list)
|
|
1359
|
+
* @since 0.8.53
|
|
1360
|
+
* @status stable
|
|
1361
|
+
* @related b.network.dns.discoverEncrypted, b.network.dns.querySvcb
|
|
1362
|
+
*
|
|
1363
|
+
* RFC 9463 Discovery of Network-designated Resolvers. The framework
|
|
1364
|
+
* doesn't run a DHCP / IPv6 RA client itself; an operator-side agent
|
|
1365
|
+
* (or the output of `discoverEncrypted()`) supplies the resolver list
|
|
1366
|
+
* and the framework swaps its transport over to the lowest-priority
|
|
1367
|
+
* entry. Items are tried in order: the first one that successfully
|
|
1368
|
+
* configures (DoH `useDnsOverHttps`, DoT `useDnsOverTls`) wins.
|
|
1369
|
+
*
|
|
1370
|
+
* Each entry shape:
|
|
1371
|
+
*
|
|
1372
|
+
* {
|
|
1373
|
+
* transport: "doh" | "dot",
|
|
1374
|
+
* url: string,
|
|
1375
|
+
* host: string,
|
|
1376
|
+
* port: number,
|
|
1377
|
+
* servername: string,
|
|
1378
|
+
* alpn: Array<string>,
|
|
1379
|
+
* ca: string|Buffer|Array,
|
|
1380
|
+
* }
|
|
1381
|
+
*
|
|
1382
|
+
* Throws `DnsError` with code `dns/dnr-no-resolvers` if `list` is
|
|
1383
|
+
* empty, and `dns/dnr-malformed` if an entry is missing its required
|
|
1384
|
+
* transport-specific fields.
|
|
1385
|
+
*
|
|
1386
|
+
* @example
|
|
1387
|
+
* var b = require("@blamejs/core");
|
|
1388
|
+
* var found = await b.network.dns.discoverEncrypted();
|
|
1389
|
+
* b.network.dns.useDesignatedResolvers(found.map(function (r) {
|
|
1390
|
+
* return r.transport === "doh"
|
|
1391
|
+
* ? { transport: "doh", url: "https://" + r.target + (r.dohpath || "/dns-query") }
|
|
1392
|
+
* : { transport: "dot", host: r.target, port: r.port, servername: r.target };
|
|
1393
|
+
* }));
|
|
1394
|
+
*/
|
|
1395
|
+
function useDesignatedResolvers(list) {
|
|
1396
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
1397
|
+
throw new DnsError("dns/dnr-no-resolvers",
|
|
1398
|
+
"dns.useDesignatedResolvers: expected non-empty array of resolver descriptors");
|
|
1399
|
+
}
|
|
1400
|
+
var validated = [];
|
|
1401
|
+
for (var i = 0; i < list.length; i++) {
|
|
1402
|
+
var entry = list[i];
|
|
1403
|
+
if (!entry || typeof entry !== "object") {
|
|
1404
|
+
throw new DnsError("dns/dnr-malformed",
|
|
1405
|
+
"dns.useDesignatedResolvers[" + i + "]: entry must be an object");
|
|
1406
|
+
}
|
|
1407
|
+
if (entry.transport !== "doh" && entry.transport !== "dot") {
|
|
1408
|
+
throw new DnsError("dns/dnr-malformed",
|
|
1409
|
+
"dns.useDesignatedResolvers[" + i + "]: transport must be 'doh' or 'dot'");
|
|
1410
|
+
}
|
|
1411
|
+
if (entry.transport === "doh") {
|
|
1412
|
+
if (typeof entry.url !== "string" || entry.url.indexOf("https://") !== 0) {
|
|
1413
|
+
throw new DnsError("dns/dnr-malformed",
|
|
1414
|
+
"dns.useDesignatedResolvers[" + i + "]: doh entry requires url starting with https://");
|
|
1415
|
+
}
|
|
1416
|
+
} else {
|
|
1417
|
+
if (typeof entry.host !== "string" || entry.host.length === 0) {
|
|
1418
|
+
throw new DnsError("dns/dnr-malformed",
|
|
1419
|
+
"dns.useDesignatedResolvers[" + i + "]: dot entry requires host");
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
validated.push(entry);
|
|
1423
|
+
}
|
|
1424
|
+
var lastErr = null;
|
|
1425
|
+
for (var j = 0; j < validated.length; j++) {
|
|
1426
|
+
var v = validated[j];
|
|
1427
|
+
try {
|
|
1428
|
+
if (v.transport === "doh") {
|
|
1429
|
+
useDnsOverHttps({ url: v.url, ca: v.ca || null, method: v.method });
|
|
1430
|
+
} else {
|
|
1431
|
+
useDnsOverTls({
|
|
1432
|
+
host: v.host,
|
|
1433
|
+
port: v.port || 853, // allow:raw-byte-literal — IANA-assigned DoT port
|
|
1434
|
+
servername: v.servername || v.host,
|
|
1435
|
+
ca: v.ca || null,
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
_designatedResolvers = validated.slice();
|
|
1439
|
+
_emitObs("network.dns.dnr.set", {
|
|
1440
|
+
count: validated.length,
|
|
1441
|
+
active: j,
|
|
1442
|
+
transport: v.transport,
|
|
1443
|
+
});
|
|
1444
|
+
return { active: j, count: validated.length };
|
|
1445
|
+
} catch (e) {
|
|
1446
|
+
lastErr = e;
|
|
1447
|
+
_emitObs("network.dns.dnr.entry_failed", {
|
|
1448
|
+
index: j,
|
|
1449
|
+
transport: v.transport,
|
|
1450
|
+
code: e.code || "unknown",
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
throw new DnsError("dns/dnr-no-resolvers",
|
|
1455
|
+
"dns.useDesignatedResolvers: no entry could be configured. Last error: " +
|
|
1456
|
+
((lastErr && lastErr.message) || "unknown"));
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function _designatedResolversForTest() { return _designatedResolvers; }
|
|
1460
|
+
|
|
674
1461
|
function _orderAddrs(addrs) {
|
|
675
1462
|
if (STATE.resultOrder === "ipv6first") {
|
|
676
1463
|
addrs.sort(function (a, b) { return (b.family || 0) - (a.family || 0); });
|
|
@@ -750,7 +1537,8 @@ async function lookup(host, opts) {
|
|
|
750
1537
|
}
|
|
751
1538
|
}
|
|
752
1539
|
|
|
753
|
-
async function _resolveProtocol(host, family) {
|
|
1540
|
+
async function _resolveProtocol(host, family, opts) {
|
|
1541
|
+
opts = opts || {};
|
|
754
1542
|
if (typeof host !== "string" || host.length === 0) {
|
|
755
1543
|
throw new DnsError("dns/bad-host", "dns.resolve" + family + ": host required");
|
|
756
1544
|
}
|
|
@@ -760,13 +1548,27 @@ async function _resolveProtocol(host, family) {
|
|
|
760
1548
|
}
|
|
761
1549
|
return [host];
|
|
762
1550
|
}
|
|
763
|
-
|
|
1551
|
+
if (opts.transport !== undefined && opts.transport !== "doh" &&
|
|
1552
|
+
opts.transport !== "dot" && opts.transport !== "system") {
|
|
1553
|
+
throw new DnsError("dns/bad-transport",
|
|
1554
|
+
"dns.resolve" + family + ": transport must be 'doh' | 'dot' | 'system' | undefined");
|
|
1555
|
+
}
|
|
1556
|
+
_emitObs("network.dns.resolve.requested", { family: family, transport: opts.transport || "auto" });
|
|
764
1557
|
var startMs = _now();
|
|
765
1558
|
try {
|
|
766
1559
|
var addrs;
|
|
767
|
-
|
|
1560
|
+
var forced = opts.transport;
|
|
1561
|
+
if (forced === "doh" || (!forced && STATE.doh)) {
|
|
1562
|
+
if (!STATE.doh) {
|
|
1563
|
+
throw new DnsError("dns/transport-unavailable",
|
|
1564
|
+
"dns.resolve" + family + ": transport 'doh' requested but useDnsOverHttps() not configured");
|
|
1565
|
+
}
|
|
768
1566
|
addrs = await _withTimeout(_dohLookup(host, family), STATE.lookupTimeoutMs, host);
|
|
769
|
-
} else if (STATE.dot) {
|
|
1567
|
+
} else if (forced === "dot" || (!forced && STATE.dot)) {
|
|
1568
|
+
if (!STATE.dot) {
|
|
1569
|
+
throw new DnsError("dns/transport-unavailable",
|
|
1570
|
+
"dns.resolve" + family + ": transport 'dot' requested but useDnsOverTls() not configured");
|
|
1571
|
+
}
|
|
770
1572
|
addrs = await _withTimeout(_dotLookup(host, family), STATE.lookupTimeoutMs, host);
|
|
771
1573
|
} else {
|
|
772
1574
|
var resolver = family === 6 ? dnsPromises.resolve6 : dnsPromises.resolve4;
|
|
@@ -787,9 +1589,59 @@ async function _resolveProtocol(host, family) {
|
|
|
787
1589
|
}
|
|
788
1590
|
}
|
|
789
1591
|
|
|
790
|
-
async function resolve4(host) { return _resolveProtocol(host, 4); }
|
|
791
|
-
async function resolve6(host) { return _resolveProtocol(host, 6); }
|
|
792
|
-
async function resolveAaaa(host) { return _resolveProtocol(host, 6); }
|
|
1592
|
+
async function resolve4(host, opts) { return _resolveProtocol(host, 4, opts); }
|
|
1593
|
+
async function resolve6(host, opts) { return _resolveProtocol(host, 6, opts); }
|
|
1594
|
+
async function resolveAaaa(host, opts) { return _resolveProtocol(host, 6, opts); }
|
|
1595
|
+
|
|
1596
|
+
// Generic resolve API surfacing the transport opt + record type.
|
|
1597
|
+
// `type` defaults to "A"; "AAAA" routes through resolve6; SVCB / HTTPS
|
|
1598
|
+
// types route through the new querySvcb / queryHttps primitives.
|
|
1599
|
+
async function resolve(host, type, opts) {
|
|
1600
|
+
type = (type || "A").toUpperCase();
|
|
1601
|
+
if (type === "A") return _resolveProtocol(host, 4, opts);
|
|
1602
|
+
if (type === "AAAA") return _resolveProtocol(host, 6, opts);
|
|
1603
|
+
if (type === "SVCB") return querySvcb(host, opts);
|
|
1604
|
+
if (type === "HTTPS") return queryHttps(host, opts);
|
|
1605
|
+
throw new DnsError("dns/unsupported-type",
|
|
1606
|
+
"dns.resolve: type must be 'A' | 'AAAA' | 'SVCB' | 'HTTPS' (got " + JSON.stringify(type) + ")");
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// PTR lookup — given a v4 or v6 IP literal, return the list of names
|
|
1610
|
+
// the in-addr.arpa / ip6.arpa zones map back to. Building block for
|
|
1611
|
+
// FCrDNS (forward-confirmed reverse DNS, RFC 8601 §3 lite) callers
|
|
1612
|
+
// and the outbound-mail iprev surface — the PTR query plus the
|
|
1613
|
+
// matching forward A/AAAA query share this DnsError class.
|
|
1614
|
+
//
|
|
1615
|
+
// dnsPromises.reverse() doesn't honor the DoH/DoT transports (those
|
|
1616
|
+
// transports query A/AAAA/TXT via wire format; PTR queries take a
|
|
1617
|
+
// separate code path). For now this routes through the system
|
|
1618
|
+
// resolver — operators who require ALL DNS over secure transport
|
|
1619
|
+
// wrap the surface with their own resolver.
|
|
1620
|
+
async function reverse(ip) {
|
|
1621
|
+
if (typeof ip !== "string" || ip.length === 0) {
|
|
1622
|
+
throw new DnsError("dns/bad-ip", "dns.reverse: ip must be a non-empty string");
|
|
1623
|
+
}
|
|
1624
|
+
if (!net.isIP(ip)) {
|
|
1625
|
+
throw new DnsError("dns/bad-ip",
|
|
1626
|
+
"dns.reverse: '" + ip + "' is not a valid IPv4 or IPv6 address");
|
|
1627
|
+
}
|
|
1628
|
+
_emitObs("network.dns.reverse.requested", { family: net.isIPv6(ip) ? 6 : 4 });
|
|
1629
|
+
var startMs = _now();
|
|
1630
|
+
try {
|
|
1631
|
+
var ptrs = await _withTimeout(dnsPromises.reverse(ip), STATE.lookupTimeoutMs, ip);
|
|
1632
|
+
_emitObs("network.dns.reverse.success", {
|
|
1633
|
+
latencyMs: _now() - startMs, count: Array.isArray(ptrs) ? ptrs.length : 0,
|
|
1634
|
+
});
|
|
1635
|
+
return Array.isArray(ptrs) ? ptrs : [];
|
|
1636
|
+
} catch (e) {
|
|
1637
|
+
_emitObs("network.dns.reverse.failure", {
|
|
1638
|
+
latencyMs: _now() - startMs, code: e.code || "unknown",
|
|
1639
|
+
});
|
|
1640
|
+
if (e instanceof DnsError) throw e;
|
|
1641
|
+
throw new DnsError("dns/reverse-failed",
|
|
1642
|
+
"dns.reverse of '" + ip + "' failed: " + (e.message || String(e)));
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
793
1645
|
|
|
794
1646
|
function nodeLookup(host, options, callback) {
|
|
795
1647
|
if (typeof options === "function") { callback = options; options = {}; }
|
|
@@ -813,28 +1665,39 @@ function _resetForTest() {
|
|
|
813
1665
|
STATE.servers = null; STATE.resultOrder = null; STATE.family = 0;
|
|
814
1666
|
STATE.lookupTimeoutMs = 0; STATE.cacheTtlMs = 0; STATE.cacheNegativeTtlMs = 0;
|
|
815
1667
|
STATE.doh = null; STATE.dot = null; STATE.systemResolver = false;
|
|
1668
|
+
_designatedResolvers = null;
|
|
816
1669
|
_clearCache();
|
|
817
1670
|
_resetDotPool();
|
|
818
1671
|
}
|
|
819
1672
|
|
|
820
1673
|
module.exports = {
|
|
821
|
-
setServers:
|
|
822
|
-
getServers:
|
|
823
|
-
setResultOrder:
|
|
824
|
-
setFamily:
|
|
825
|
-
setLookupTimeoutMs:
|
|
826
|
-
setCacheTtlMs:
|
|
827
|
-
useDnsOverHttps:
|
|
828
|
-
useDnsOverTls:
|
|
829
|
-
useSystemResolver:
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1674
|
+
setServers: setServers,
|
|
1675
|
+
getServers: getServers,
|
|
1676
|
+
setResultOrder: setResultOrder,
|
|
1677
|
+
setFamily: setFamily,
|
|
1678
|
+
setLookupTimeoutMs: setLookupTimeoutMs,
|
|
1679
|
+
setCacheTtlMs: setCacheTtlMs,
|
|
1680
|
+
useDnsOverHttps: useDnsOverHttps,
|
|
1681
|
+
useDnsOverTls: useDnsOverTls,
|
|
1682
|
+
useSystemResolver: useSystemResolver,
|
|
1683
|
+
useDesignatedResolvers: useDesignatedResolvers,
|
|
1684
|
+
discoverEncrypted: discoverEncrypted,
|
|
1685
|
+
lookup: lookup,
|
|
1686
|
+
resolve: resolve,
|
|
1687
|
+
resolve4: resolve4,
|
|
1688
|
+
resolve6: resolve6,
|
|
1689
|
+
resolveAaaa: resolveAaaa,
|
|
1690
|
+
resolveSecure: resolveSecure,
|
|
1691
|
+
reverse: reverse,
|
|
1692
|
+
querySvcb: querySvcb,
|
|
1693
|
+
queryHttps: queryHttps,
|
|
1694
|
+
nodeLookup: nodeLookup,
|
|
1695
|
+
clearCache: _clearCache,
|
|
1696
|
+
DnsError: DnsError,
|
|
1697
|
+
_parseSvcbRdata: _parseSvcbRdata,
|
|
1698
|
+
_decodeDnsAnswerRaw: _decodeDnsAnswerRaw,
|
|
1699
|
+
_readDnsName: _readDnsName,
|
|
1700
|
+
_stateForTest: _stateForTest,
|
|
1701
|
+
_resetForTest: _resetForTest,
|
|
1702
|
+
_designatedResolversForTest: _designatedResolversForTest,
|
|
840
1703
|
};
|