geiserx_ts_runtime 0.52.0

tailscale runtime
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
//! peerAPI DoH server (`/dns-query`) — the exit-node DNS-proxy half of Go tsnet's peerAPI.
//!
//! When this node is selected as another peer's exit node, that peer routes its non-MagicDNS
//! lookups to us over the overlay as RFC 8484 DNS-over-HTTPS (here plain HTTP/1.1 over the
//! encrypted WireGuard overlay — exactly what Go does: `http://<peer-ip>:<peerapi>/dns-query`). We
//! bind a TCP listener on this node's overlay IPv4 at [`Config::peerapi_port`][ts_control::Config::peerapi_port]
//! and answer those queries.
//!
//! ## What we answer
//!
//! The request DNS bytes are fed through the **same** [`decide`] used by the local MagicDNS
//! responder, so authoritative MagicDNS records (peer names, control-pushed `ExtraRecords`, PTR)
//! are answered locally here.
//!
//! ### Deliberate divergence from Go (and why blanket forward-only would be unsafe)
//!
//! Go's exit-node DNS proxy (`HandlePeerDNSQuery` in `net/dns/resolver/tsdns.go`) is a **pure
//! recursive forwarder**: after the filtered-set check it forwards every allowed name to the OS
//! stub resolver and never answers a name authoritatively from MagicDNS. This fork instead reuses
//! the shared `decide`, so for a routed peer it *does* answer the exit node's own peer
//! names / `ExtraRecords` / PTR locally. The only observable consequence is that a routed client
//! asking this exit node's DoH for one of the **exit node's own** tailnet peer names gets a positive
//! answer where Go would forward it to upstream — a narrow MagicDNS-namespace bleed across the exit
//! boundary (no leak: the answer comes from local netmap data and never touches a host socket).
//! A peer control has marked with the `dns-subdomain-resolve` node attribute widens that same bleed
//! by the names *under* its own name and nothing else, from the same netmap data (the walk in
//! `DnsView::subdomain_host_for`).
//!
//! Matching Go by making this path blanket forward-only would be a **regression in the unsafe
//! direction**: `decide`'s authoritative replies include this fork's anti-leak guards — a PTR for a
//! tailnet CGNAT IP (`100.64.0.0/10`) that misses the peer set, and any `ip6.arpa` reverse, are
//! answered `NXDOMAIN` precisely so a probed tailnet address is never relayed to an upstream
//! resolver. A forward-only rewrite that simply dropped the authoritative step would forward those
//! tailnet-reverse queries upstream and leak the probed IP. A faithful narrowing must therefore
//! forward only the *peer-name / ExtraRecord* authoritative cases while keeping the CGNAT/`ip6.arpa`
//! NXDOMAIN guards — tracked as a follow-up, not the blanket change. (Go's OS-stub-resolver forward
//! target is also unavailable here; this path forwards to the tailnet's configured resolvers.)
//!
//! ## Which peers we answer at all
//!
//! Before any of that: a peerAPI DNS query is refused `403` unless the querying peer would be
//! accepted by the live packet filter for TCP port 53 to an off-tailnet destination — Go's
//! `isPeerAPIDNSAllowed` (`ipn/ipnlocal/peerapi.go`), evaluated once per connection in
//! [`dns_source_allowed`]. It is a gate on *which peers*, orthogonal to the divergence above about
//! *which names*: control's ACL has to actually grant that peer internet access through this node
//! before we resolve anything for it, recursive or authoritative.
//!
//! Two more server-side rules, on the *names* rather than the peer, layer on top:
//!
//! - **Exit-node filtered set** ([`DnsConfig::exit_node_filters`]): a name in control's
//!   `ExitNodeFilteredSet` is `REFUSED` before anything else (Go `dnsConfigForNetmap`'s filter).
//! - **Recursive egress is gated** ([`Env::forward_exit_egress`]): a query that `decide` would
//!   forward to a real upstream resolver only proceeds when this node has explicitly opted into
//!   exit egress. Otherwise it is `REFUSED` — **fail-closed**. This is the same anti-leak opt-in
//!   that governs the TCP exit path: a cloud exit node (default `forward_exit_egress == false`)
//!   never resolves a peer's public name through its real host resolver, so the cloud host's real
//!   IP can't leak. A residential node that sets `forward_exit_egress = true` opts into serving
//!   recursion, and that recursion goes out over the **overlay** netstack (same as MagicDNS), never
//!   a bare host socket.
//!
//! ## Two paths for a forwarded client's DNS (tsr-c39)
//!
//! Exit-node DNS for a routed client is **client-side** in Go, and there are exactly two paths — we
//! implement both, neither requiring exit-side DNS interception:
//! 1. **DoH delegation (this server).** A modern client redirects its catch-all resolver to this
//!    node's `/dns-query` (Go `dnsConfigForNetmap`/`exitNodeCanProxyDNS`); we answer here.
//! 2. **Raw UDP:53 forwarding.** If the client instead emits a plain DNS datagram to a public
//!    resolver, it is just part of the `0.0.0.0/0` traffic the [`crate::forwarder`] forwards — the
//!    forwarder does **not** special-case port 53, so it egresses via the same dialer (host IP, or a
//!    residential proxy) as all other forwarded traffic, fail-closed under `DirectDialer`. So a
//!    forwarded client's DNS always shares the forwarded-traffic egress; there is no separate DNS
//!    egress that could leak the origin IP (asserted by `ts_forwarder`'s antileak_runtime tests).
//!
//! ## Anti-leak / IPv6-off
//!
//! The listener binds the overlay IPv4 only. Recursive forwarding reuses [`forward_query`], which
//! binds `0.0.0.0:0` on the overlay netstack — never a host socket. A saturated server drops the
//! flow (fail-closed). Requests are size-capped; one request is answered per connection then it is
//! closed.

use std::{
    net::{Ipv4Addr, SocketAddr},
    sync::Arc,
    time::Duration,
};

use base64::Engine;
use netstack::{CreateSocket, netcore::Channel, netsock::TcpStream};
use tokio::{
    io::{AsyncReadExt, AsyncWriteExt},
    sync::watch,
    time::timeout,
};
use ts_dns_wire::{Rcode, decode_query, encode_response};
use ts_packetfilter::FilterExt;

use crate::{
    magic_dns::{
        ClientTransport, Decision, DnsView, check_response_size_and_set_tc, decide, forward_query,
    },
    packetfilter::LiveFilterRx,
};

/// Largest HTTP request (headers + body) we will read for one DoH query. A DNS message is at most
/// 65,535 bytes, but a peerAPI DoH query is a single small question; cap well below that to bound
/// memory and reject abuse. Anything larger is answered `413` and the connection closed.
const MAX_REQUEST: usize = 8 * 1024;

/// How long the DoH *client* waits for the exit node to answer a delegated query before giving up
/// and returning the fallback NXDOMAIN. Matches the local UDP upstream timeout.
const CLIENT_TIMEOUT: Duration = Duration::from_secs(5);

/// The transport family this node answers a peer's DoH query as — Go's `family` argument, threaded
/// into `checkResponseSizeAndSetTC`. `Resolver.HandlePeerDNSQuery` forwards a peer's query with
/// `packet{q, "tcp", from}`, so the size check is a no-op for everything the DoH *server* returns.
///
/// It is not a claim that HTTP is TCP. It is a claim about *whose* datagram budget is at stake: the
/// peer that POSTed the query holds it, and runs its own `checkResponseSizeAndSetTC` over our answer
/// against its own client's family before relaying it onward. Marking the answer here as well would
/// set `TC` on a reply the peer's client can receive perfectly well, sending it off to a TCP retry
/// it has no reason to make.
const PEER_CLIENT_TRANSPORT: ClientTransport = ClientTransport::Tcp;

/// Cap on a DoH response body we read into memory from the exit node. A delegated answer is one DNS
/// message, and a DNS message is at most 65,535 bytes — that is all a DNS-over-TCP two-byte length
/// prefix (RFC 1035 §4.2.2) can frame. Capping here bounds the allocation a misbehaving/hostile exit
/// node can force *and* keeps every delegated answer relayable: a 65,536-byte body is not a DNS
/// message we could ever put back on the wire to a TCP stub resolver, so it is rejected at the read
/// rather than carried to [`crate::dns_over_tcp`] where the framing would fail and take the client's
/// connection down with it. Anything past this is treated as a failure (the caller's `fallback`).
const MAX_CLIENT_RESPONSE: usize = u16::MAX as usize;

/// Delegate a recursive DNS `query` to an exit node's peerAPI DoH endpoint at `doh_addr`, over the
/// overlay netstack `channel`. Returns the exit node's DNS answer bytes, or the caller-supplied
/// `fallback` buffer on any failure (connect, write, malformed HTTP, timeout) — **fail-closed,
/// never a fallback to a local resolver**: when an exit node is selected, recursive DNS must egress
/// from the exit node, so a failure here resolves to the `fallback` rather than silently leaking the
/// query (and this host's real IP) to a local upstream. The caller supplies the rcode: the client
/// recursive path passes a SERVFAIL (a forward failure is soft, not a cacheable non-existence).
///
/// Anti-leak: the connection is made over the overlay (`channel.tcp_connect(0.0.0.0:0, doh_addr)`),
/// so it rides the encrypted WireGuard tunnel to the peer — never a host socket. IPv4-only:
/// `doh_addr` is always the peer's tailnet IPv4 (see [`Node::peerapi_doh_addr`]).
///
/// When the delegated answer is relayed back to a **UDP** stub resolver it is marked truncated if
/// it exceeds the buffer size `query` advertised (see [`check_response_size_and_set_tc`]), exactly
/// as on the plain UDP forward. The DoH transport itself has no such limit; the limit belongs to the
/// client we answer, not to the hop we fetched over — which is why `client` selects it, and why a
/// [`ClientTransport::Tcp`] client (RFC 7766 §8: no message-size bound) is never marked. Upstream
/// does the same: Go's `forwarder.send` calls `checkResponseSizeAndSetTC(res, fq.packet, fq.family)`
/// on the `http://` (peerAPI DoH) branch, and `fq.family` is the *requesting client's* transport,
/// not HTTP.
pub(crate) async fn forward_doh(
    channel: &Channel,
    doh_addr: SocketAddr,
    query: &[u8],
    fallback: Vec<u8>,
    client: ClientTransport,
) -> Vec<u8> {
    match timeout(CLIENT_TIMEOUT, doh_round_trip(channel, doh_addr, query)).await {
        Ok(Ok(resp)) if !resp.is_empty() => check_response_size_and_set_tc(query, resp, client),
        Ok(Ok(_)) => {
            // A broken exit-node recursive resolver silently fails every delegated query, so
            // surface delegation failures at warn (default level) — the operator needs the signal.
            tracing::warn!(%doh_addr, "peerapi doh client: empty response from exit node");
            fallback
        }
        Ok(Err(e)) => {
            tracing::warn!(error = %e, %doh_addr, "peerapi doh client: delegation failed");
            fallback
        }
        Err(_) => {
            tracing::warn!(%doh_addr, "peerapi doh client: delegation timed out");
            fallback
        }
    }
}

/// Perform one DoH `POST /dns-query` round trip to `doh_addr` over the overlay and return the
/// response body (the DNS answer). Errors on connect/write/read failure or a malformed HTTP reply.
async fn doh_round_trip(
    channel: &Channel,
    doh_addr: SocketAddr,
    query: &[u8],
) -> std::io::Result<Vec<u8>> {
    let local = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), 0);
    let mut stream = channel
        .tcp_connect(local, doh_addr)
        .await
        .map_err(|e| std::io::Error::other(e.to_string()))?;

    let request = format!(
        "POST /dns-query HTTP/1.1\r\nHost: {doh_addr}\r\nContent-Type: application/dns-message\r\nAccept: application/dns-message\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
        query.len()
    );
    stream.write_all(request.as_bytes()).await?;
    stream.write_all(query).await?;
    stream.flush().await?;

    read_doh_response(&mut stream).await
}

/// Read an HTTP/1.1 DoH response from `stream` and return its body (the DNS answer). Requires a
/// `200` status; any other status, a missing/oversized body, or a malformed response is an error
/// (the caller maps that to NXDOMAIN — fail-closed). Reads the body by `Content-Length`.
async fn read_doh_response(stream: &mut TcpStream) -> std::io::Result<Vec<u8>> {
    let mut buf = Vec::with_capacity(1024);
    let mut tmp = [0u8; 1024];

    let header_end = loop {
        if let Some(pos) = find_header_end(&buf) {
            break pos;
        }
        if buf.len() > MAX_CLIENT_RESPONSE {
            return Err(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                "doh response headers too large",
            ));
        }
        let n = stream.read(&mut tmp).await?;
        if n == 0 {
            return Err(std::io::Error::new(
                std::io::ErrorKind::UnexpectedEof,
                "eof before doh response headers",
            ));
        }
        buf.extend_from_slice(&tmp[..n]);
    };

    let content_length = parse_response_head(&buf)?;

    let mut body = buf[header_end..].to_vec();
    while body.len() < content_length {
        let n = stream.read(&mut tmp).await?;
        if n == 0 {
            return Err(std::io::Error::new(
                std::io::ErrorKind::UnexpectedEof,
                "eof before doh response body complete",
            ));
        }
        body.extend_from_slice(&tmp[..n]);
        if body.len() > MAX_CLIENT_RESPONSE {
            return Err(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                "doh response body too large",
            ));
        }
    }
    body.truncate(content_length);
    Ok(body)
}

/// Parse an HTTP/1.1 DoH response head from `buf` (which must contain the full headers) and return
/// its declared `Content-Length`. Requires a `200` status and a parseable `Content-Length` within
/// [`MAX_CLIENT_RESPONSE`]; any other status, a missing/unparseable length, or an oversized body is
/// an error (the caller maps that to NXDOMAIN — fail-closed). Pure so it is unit-testable without a
/// live `TcpStream`.
fn parse_response_head(buf: &[u8]) -> std::io::Result<usize> {
    let mut headers = [httparse::EMPTY_HEADER; 32];
    let mut resp = httparse::Response::new(&mut headers);
    match resp.parse(buf) {
        Ok(httparse::Status::Complete(_)) => {}
        Ok(httparse::Status::Partial) | Err(_) => {
            return Err(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                "malformed doh response headers",
            ));
        }
    }

    if resp.code != Some(200) {
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            format!("doh response status {:?}", resp.code),
        ));
    }

    let content_length = resp
        .headers
        .iter()
        .find(|h| h.name.eq_ignore_ascii_case("content-length"))
        .and_then(|h| std::str::from_utf8(h.value).ok())
        .and_then(|v| v.trim().parse::<usize>().ok())
        .ok_or_else(|| {
            std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                "doh response missing length",
            )
        })?;

    if content_length > MAX_CLIENT_RESPONSE {
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            "doh response body too large",
        ));
    }

    Ok(content_length)
}

/// Service one DoH connection: read the HTTP request, resolve the DNS query, write the response.
/// Closes the connection afterward (one request per connection).
///
/// `seed` carries the header bytes the shared peerAPI router ([`crate::peerapi`]) already read off
/// the stream while it determined the route, and `header_end` is the offset just past the
/// `\r\n\r\n` header terminator within `seed`. The DoH parser resumes from there, so no bytes are
/// lost when the router hands the connection over.
///
/// The querying peer is gated once, here, before any resolution: see [`dns_source_allowed`], which
/// answers `403` for a peer control's ACL does not grant internet access through this node.
pub(crate) async fn handle_conn(
    mut stream: TcpStream,
    seed: Vec<u8>,
    header_end: usize,
    channel: &Channel,
    view_rx: &watch::Receiver<Arc<DnsView>>,
    filter_rx: &LiveFilterRx,
    forward_exit_egress: bool,
) -> std::io::Result<()> {
    let request = match read_request(&mut stream, seed, header_end).await? {
        Some(r) => r,
        None => return Ok(()),
    };

    let query = match request {
        DohRequest::TooLarge => {
            return write_status(&mut stream, "413 Payload Too Large").await;
        }
        DohRequest::BadRequest => {
            return write_status(&mut stream, "400 Bad Request").await;
        }
        DohRequest::NotFound => {
            return write_status(&mut stream, "404 Not Found").await;
        }
        DohRequest::Query(bytes) => bytes,
    };

    let view = view_rx.borrow().clone();
    // Both `borrow()` guards are dropped at the end of their own `let` — a `watch::Ref` is not
    // `Send`, and this task is spawned.
    let filter = filter_rx.borrow().clone();

    // The source gate, run once for this connection *before* anything is resolved — Go's
    // `handleDNSQuery` answering `403` when `isPeerAPIDNSAllowed` says no.
    let src = stream.remote_addr().ip();
    if !dns_source_allowed(&view, filter.as_ref().map(|f| &*f.0), src) {
        tracing::debug!(
            %src,
            "peerapi doh: 403, the ACL does not grant this peer internet access through us"
        );
        return write_status(&mut stream, "403 Forbidden").await;
    }

    let response = resolve(&view, &query, channel, forward_exit_egress).await;
    write_dns_response(&mut stream, &response).await
}

/// Go `packet.TCPSyn`: the SYN bit of a TCP flags byte. `CheckTCP` builds its probe packet with
/// exactly this flag set, so the probe is a connection *opening* — the one segment an inbound ACL
/// has any say over.
const TCP_SYN_FLAG: u8 = 0x02;

/// The destination a `CheckTCP` probe for a `src` of this family names — Go's
/// `isPeerAPIDNSAllowed`: `0.0.0.0` for an IPv4 peer, `2000::` for an IPv6 one. Both are
/// off-tailnet (outside `100.64.0.0/10` / `fd7a:115c:a1e0::/48`) and outside any private range, so
/// a rule matches them only if it grants this peer the *internet* through us — which is exactly the
/// question being asked. `2000::` is the base of the IANA global-unicast block, chosen upstream for
/// that reason.
fn internet_probe_dst(src: std::net::IpAddr) -> std::net::IpAddr {
    match src {
        std::net::IpAddr::V4(_) => std::net::Ipv4Addr::UNSPECIFIED.into(),
        std::net::IpAddr::V6(_) => std::net::Ipv6Addr::new(0x2000, 0, 0, 0, 0, 0, 0, 0).into(),
    }
}

/// Whether the peer at `src` may have its DNS queries answered by this node's peerAPI DoH server at
/// all — the port of Go's `isPeerAPIDNSAllowed` (`ipn/ipnlocal/peerapi.go`), which `handleDNSQuery`
/// runs ahead of any resolution and answers `403 DNS access denied` when it refuses.
///
/// Upstream needs this check *inside* the handler because peerAPI deliberately bypasses the packet
/// filter: `net/tstun/wrap.go` flips an ACL-refused inbound SYN back to `Accept` when its
/// destination is the peerAPI port ("Let peerapi through the filter; its ACLs are handled at L7, not
/// at the packet level"). This fork has no such packet-level carve-out — a peer only reaches this
/// port if the ACL already admits it there — but the common tailnet policy is permissive about
/// *ports* and specific about *internet access*, and it is internet access that a DNS proxy hands
/// out. So the L7 check is still the only thing that asks the right question, and without it any
/// peer that can open a TCP connection to the peerAPI port gets recursive resolution through this
/// node's resolvers (on an exit-egress node) and authoritative MagicDNS answers for this node's
/// tailnet names (on any node).
///
/// Two conditions, both fail-closed:
///
/// 1. `src` must resolve to a node in the current netmap — Go's peerAPI listener resolves the
///    connection's source to a peer (`WhoIs`) before a handler ever runs and drops the connection
///    when it cannot. Same lookup the Taildrop and ingress gates use
///    ([`PeerDb::get`][crate::peer_tracker::PeerDb::get] by tailnet IP, i.e.
///    `PeerTracker::peer_by_tailnet_ip`), so an unknown source is refused before the ACL is asked.
///    This node's *own* address is not special-cased the way the Taildrop and ingress gates do it:
///    nothing dials our own peerAPI DoH (the MagicDNS client delegates to the active exit node,
///    which is a peer), so admitting it would only widen the surface.
/// 2. The live packet filter must accept a TCP SYN from `src` to
///    [`internet_probe_dst`]`:53` — Go's `filter.CheckTCP(remoteIP, 0.0.0.0-or-2000::, 53) ==
///    Accept`. No filter yet (no netmap since start) is a refusal, as it is upstream (`f == nil`).
///
/// **Go's self arm is deliberately not ported.** Upstream short-circuits to allow when the peer is
/// untagged and owned by the same user as this node (`IsSelfUntagged`, tightened from a plain
/// `isSelf` by commit `a4c790224` so a *tagged* node no longer gets the shortcut). This fork carries
/// no self/owner notion on the peerAPI path — `Node::user_id` is present but nothing here
/// establishes "the same user" the way Go's profile state does — so a same-user peer is admitted by
/// the ACL like any other. That is the strictly safer reading of the two: the arm we skip only ever
/// *widens* upstream's answer.
///
/// Pure (no I/O) so both directions are unit-testable.
pub(crate) fn dns_source_allowed(
    view: &DnsView,
    filter: Option<&(dyn ts_packetfilter::Filter + Send + Sync)>,
    src: std::net::IpAddr,
) -> bool {
    if view.peers.as_ref().and_then(|p| p.get(&src)).is_none() {
        tracing::debug!(%src, "peerapi doh: source is not a known tailnet peer");
        return false;
    }

    // Go: `f := b.filterAtomic.Load(); if f == nil { return false }`.
    let Some(filter) = filter else {
        tracing::debug!(%src, "peerapi doh: no packet filter compiled yet; refusing");
        return false;
    };

    let info = ts_packetfilter::PacketInfo {
        src,
        dst: internet_probe_dst(src),
        ip_proto: ts_packetfilter::IpProto::TCP,
        port: 53,
        l4: ts_packetfilter::L4Header::Tcp {
            flags: TCP_SYN_FLAG,
        },
    };

    // Node capabilities are not threaded into the ACL match anywhere in this fork yet (the
    // dataplane's rule match passes an empty set too, `ts_dataplane::inbound_filter_verdict`), so a
    // policy that grants internet access by capability rather than by source prefix will refuse
    // here. Fail-closed, and it moves in step with the dataplane when caps are wired in.
    let caps = [];
    filter.can_access(&info, caps)
}

/// Resolve a DoH DNS query against `view`, applying the exit-node filtered set and the recursive
/// egress gate. Returns the DNS wire response bytes.
///
/// - Malformed query => `FORMERR` (we still answer something parseable to the peer, never hang).
/// - Filtered name => `REFUSED` (Go `ExitNodeFilteredSet`).
/// - Authoritative answer => returned as-is from [`decide`].
/// - Recursive forward when `forward_exit_egress` is false => `REFUSED` (fail-closed, no leak).
/// - Recursive forward when enabled => forwarded over the overlay via [`forward_query`].
///
/// A forwarded answer is **never** marked `TC` for size on this path. We are the DoH *server*, and
/// the peer that POSTed the query is the one holding the datagram budget: it runs its own
/// `checkResponseSizeAndSetTC` over our answer, against its own client's family, before relaying it
/// onward. Upstream draws the line the same way — `Resolver.HandlePeerDNSQuery` forwards with
/// `packet{q, "tcp", from}`, and the family guard at the top of `checkResponseSizeAndSetTC` makes
/// the check a no-op for it. Marking it here would set `TC` on an answer the peer's own client can
/// receive, telling it to retry over a transport it has no reason to reach for.
async fn resolve(
    view: &DnsView,
    query: &[u8],
    channel: &Channel,
    forward_exit_egress: bool,
) -> Vec<u8> {
    match server_decide(view, query, forward_exit_egress) {
        ServerDecision::Reply(resp) => resp,
        ServerDecision::Forward {
            upstreams,
            query,
            servfail,
        } => forward_query(channel, &upstreams, &query, servfail, PEER_CLIENT_TRANSPORT).await,
    }
}

/// The server-side decision for a DoH query: either a complete response (authoritative answer,
/// `REFUSED`, or `FORMERR`) or a request to forward over the overlay.
///
/// Unlike [`decide`], this also applies the two exit-node-server rules: the
/// [`ExitNodeFilteredSet`][DnsConfig::exit_node_filters] (`REFUSED`) and the recursive-egress gate
/// (`REFUSED` unless `forward_exit_egress`). Pure (no I/O) so both rules are unit-testable.
enum ServerDecision {
    Reply(Vec<u8>),
    Forward {
        upstreams: Vec<SocketAddr>,
        query: Vec<u8>,
        /// Fallback response if every upstream fails — a SERVFAIL, carried over from the shared
        /// [`Decision::Forward`]: an off-tailnet name the exit-node DoH server couldn't forward is a
        /// soft failure, not a cacheable non-existence.
        servfail: Vec<u8>,
    },
}

fn server_decide(view: &DnsView, query: &[u8], forward_exit_egress: bool) -> ServerDecision {
    let Ok(decoded) = decode_query(query) else {
        // We can't parse it; answer FORMERR with a best-effort echo of the id if present.
        let id = if query.len() >= 2 {
            u16::from_be_bytes([query[0], query[1]])
        } else {
            0
        };
        return ServerDecision::Reply(encode_formerr(id));
    };

    let canon = decoded.question.name.to_canon();

    // Server-side: a name in control's ExitNodeFilteredSet must never be answered by an exit-node
    // DNS proxy.
    if view.cfg.exit_node_filters(&canon) {
        return ServerDecision::Reply(encode_response(
            decoded.id,
            &decoded.question,
            decoded.recursion_desired,
            Rcode::Refused,
            &[],
            // No SOA: REFUSED asserts nothing about the name, so there is nothing to bound the
            // caching of, and we are not authoritative for a name we are declining to answer.
            None,
        ));
    }

    match decide(view, query) {
        // Malformed (already handled above) — decide drops it; answer FORMERR defensively.
        None => ServerDecision::Reply(encode_formerr(decoded.id)),
        Some(Decision::Reply(resp)) => ServerDecision::Reply(resp),
        Some(Decision::Forward {
            upstreams,
            query,
            servfail,
            // The exit-node DNS proxy resolves recursively itself; it never re-delegates to its own
            // exit node, so the client-side recursive flag is irrelevant here.
            recursive: _,
        }) => {
            // Recursive resolution to a real upstream. Gated behind the same anti-leak opt-in as
            // the TCP exit path: a node that hasn't opted into exit egress must NOT resolve a
            // peer's public name through its own resolver (would expose its real IP). Fail-closed.
            if !forward_exit_egress {
                return ServerDecision::Reply(encode_response(
                    decoded.id,
                    &decoded.question,
                    decoded.recursion_desired,
                    Rcode::Refused,
                    &[],
                    None,
                ));
            }
            ServerDecision::Forward {
                upstreams,
                query,
                servfail,
            }
        }
    }
}

/// A `FORMERR` (format error, RCODE 1) response carrying only the transaction id. Used when the
/// request body isn't a decodable DNS query, so the peer gets a definite (non-hanging) answer.
fn encode_formerr(id: u16) -> Vec<u8> {
    // 12-byte header: id, flags (QR=1, RCODE=1=FORMERR), zeroed counts.
    let mut msg = vec![0u8; 12];
    msg[0..2].copy_from_slice(&id.to_be_bytes());
    msg[2] = 0x80; // QR=1 (response)
    msg[3] = 0x01; // RCODE = FORMERR
    msg
}

/// The parsed outcome of reading one DoH HTTP request.
enum DohRequest {
    /// A valid DNS query body to resolve.
    Query(Vec<u8>),
    /// The request exceeded [`MAX_REQUEST`].
    TooLarge,
    /// The request was malformed, used an unsupported method, or had a bad `dns` parameter.
    BadRequest,
    /// The path was not `/dns-query`.
    NotFound,
}

/// Parse one DoH HTTP/1.1 request, given the header bytes already read by the shared peerAPI router
/// in `buf` (with `header_end` the offset just past `\r\n\r\n`). Supports `POST /dns-query` (body is
/// the raw DNS message, `Content-Type: application/dns-message`) and `GET /dns-query?dns=<base64url>`
/// (RFC 8484). Reads any remaining body bytes from `stream`. Returns `Ok(None)` if the peer closed
/// before sending a full request.
async fn read_request(
    stream: &mut TcpStream,
    buf: Vec<u8>,
    header_end: usize,
) -> std::io::Result<Option<DohRequest>> {
    let mut tmp = [0u8; 1024];

    let mut headers = [httparse::EMPTY_HEADER; 32];
    let mut req = httparse::Request::new(&mut headers);
    let parsed = match req.parse(&buf) {
        Ok(httparse::Status::Complete(n)) => n,
        // We already located \r\n\r\n, so a Partial here means malformed headers.
        Ok(httparse::Status::Partial) => return Ok(Some(DohRequest::BadRequest)),
        Err(_) => return Ok(Some(DohRequest::BadRequest)),
    };
    debug_assert_eq!(parsed, header_end);

    let method = req.method.unwrap_or("");
    let path = req.path.unwrap_or("");

    // Split path and query string.
    let (raw_path, query_str) = match path.split_once('?') {
        Some((p, q)) => (p, Some(q)),
        None => (path, None),
    };
    if raw_path != "/dns-query" {
        return Ok(Some(DohRequest::NotFound));
    }

    match method {
        "GET" => Ok(Some(parse_get(query_str))),
        "POST" => {
            let content_length =
                header_value(&req, "content-length").and_then(|v| v.trim().parse::<usize>().ok());
            let Some(len) = content_length else {
                return Ok(Some(DohRequest::BadRequest));
            };
            if len > MAX_REQUEST {
                return Ok(Some(DohRequest::TooLarge));
            }
            // The body must be application/dns-message.
            if !header_value(&req, "content-type")
                .is_some_and(|v| v.trim().eq_ignore_ascii_case("application/dns-message"))
            {
                return Ok(Some(DohRequest::BadRequest));
            }

            let mut body = buf[header_end..].to_vec();
            while body.len() < len {
                if buf.len() + tmp.len() > MAX_REQUEST + 1024 {
                    return Ok(Some(DohRequest::TooLarge));
                }
                let n = stream.read(&mut tmp).await?;
                if n == 0 {
                    return Ok(Some(DohRequest::BadRequest));
                }
                body.extend_from_slice(&tmp[..n]);
            }
            body.truncate(len);
            Ok(Some(DohRequest::Query(body)))
        }
        _ => Ok(Some(DohRequest::BadRequest)),
    }
}

/// Parse the `dns` query parameter of a `GET /dns-query` request (RFC 8484: base64url, no padding).
fn parse_get(query_str: Option<&str>) -> DohRequest {
    let Some(qs) = query_str else {
        return DohRequest::BadRequest;
    };
    let Some(dns_param) = qs.split('&').find_map(|kv| kv.strip_prefix("dns=")) else {
        return DohRequest::BadRequest;
    };
    match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(dns_param) {
        Ok(bytes) if bytes.len() <= MAX_REQUEST => DohRequest::Query(bytes),
        Ok(_) => DohRequest::TooLarge,
        Err(_) => DohRequest::BadRequest,
    }
}

/// Look up a request header value case-insensitively.
fn header_value<'a>(req: &'a httparse::Request<'_, '_>, name: &str) -> Option<&'a str> {
    req.headers
        .iter()
        .find(|h| h.name.eq_ignore_ascii_case(name))
        .and_then(|h| std::str::from_utf8(h.value).ok())
}

/// Find the byte offset just past the `\r\n\r\n` header terminator, if present. Shared with the
/// peerAPI router ([`crate::peerapi`]).
pub(crate) fn find_header_end(buf: &[u8]) -> Option<usize> {
    buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
}

/// Write a `200 OK` DoH response carrying `dns_msg` as `application/dns-message`.
async fn write_dns_response(stream: &mut TcpStream, dns_msg: &[u8]) -> std::io::Result<()> {
    let head = format!(
        "HTTP/1.1 200 OK\r\nContent-Type: application/dns-message\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
        dns_msg.len()
    );
    stream.write_all(head.as_bytes()).await?;
    stream.write_all(dns_msg).await?;
    stream.flush().await
}

/// Write a bodyless HTTP error response with the given status line (e.g. `"400 Bad Request"`).
/// Shared with the peerAPI router ([`crate::peerapi`]).
pub(crate) async fn write_status(stream: &mut TcpStream, status: &str) -> std::io::Result<()> {
    let head = format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
    stream.write_all(head.as_bytes()).await?;
    stream.flush().await
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn find_header_end_locates_terminator() {
        assert_eq!(find_header_end(b"GET / HTTP/1.1\r\n\r\n"), Some(18));
        assert_eq!(
            find_header_end(b"GET / HTTP/1.1\r\nX: 1\r\n\r\nBODY"),
            Some(24)
        );
        assert_eq!(find_header_end(b"GET / HTTP/1.1\r\n"), None);
    }

    #[test]
    fn parse_get_decodes_base64url_dns_param() {
        // base64url of a 4-byte placeholder query.
        let raw = [0xab, 0xcd, 0x01, 0x00];
        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
        match parse_get(Some(&format!("dns={encoded}"))) {
            DohRequest::Query(b) => assert_eq!(b, raw),
            _ => panic!("expected Query"),
        }
    }

    #[test]
    fn parse_get_rejects_missing_or_bad_param() {
        assert!(matches!(parse_get(None), DohRequest::BadRequest));
        assert!(matches!(parse_get(Some("foo=bar")), DohRequest::BadRequest));
        assert!(matches!(
            parse_get(Some("dns=!!!notbase64!!!")),
            DohRequest::BadRequest
        ));
    }

    #[test]
    fn parse_response_head_returns_content_length_on_200() {
        let head = b"HTTP/1.1 200 OK\r\nContent-Type: application/dns-message\r\nContent-Length: 42\r\nConnection: close\r\n\r\n";
        assert_eq!(parse_response_head(head).unwrap(), 42);
    }

    #[test]
    fn parse_response_head_rejects_non_200() {
        let head = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n";
        assert!(parse_response_head(head).is_err());
    }

    #[test]
    fn parse_response_head_rejects_missing_length() {
        let head = b"HTTP/1.1 200 OK\r\nContent-Type: application/dns-message\r\n\r\n";
        assert!(parse_response_head(head).is_err());
    }

    #[test]
    fn parse_response_head_rejects_oversized_body() {
        let head = format!(
            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
            MAX_CLIENT_RESPONSE + 1
        );
        assert!(parse_response_head(head.as_bytes()).is_err());
    }

    /// A DNS message is at most 65,535 bytes, because that is all a DNS-over-TCP length prefix can
    /// frame. A 65,536-byte delegated body is therefore not a DNS message at all: accept it and
    /// `dns_over_tcp::serve_conn` cannot put a `u16` prefix on it and drops the client's connection
    /// instead of answering. Reject it here, where the failure is a `fallback` the client still
    /// gets.
    #[test]
    fn parse_response_head_rejects_body_too_large_to_frame_over_tcp() {
        let head = b"HTTP/1.1 200 OK\r\nContent-Length: 65536\r\n\r\n";
        assert!(
            parse_response_head(head).is_err(),
            "a 65,536-byte body exceeds the DNS-over-TCP framing limit"
        );

        // ...and one byte less is the largest DNS message there is: still accepted, and it frames.
        let head = b"HTTP/1.1 200 OK\r\nContent-Length: 65535\r\n\r\n";
        let len = parse_response_head(head).expect("65,535 bytes is a legal DNS message");
        assert_eq!(len, 65_535);
        assert!(
            u16::try_from(len).is_ok(),
            "every accepted body must fit a two-byte length prefix"
        );
    }

    #[test]
    fn encode_formerr_sets_response_and_rcode() {
        let msg = encode_formerr(0x1234);
        assert_eq!(&msg[0..2], &[0x12, 0x34]);
        assert_eq!(msg[2] & 0x80, 0x80, "QR response bit set");
        assert_eq!(msg[3] & 0x0F, 0x01, "FORMERR rcode");
    }

    /// A peer [`Node`][ts_control::Node] named `<hostname>.user.ts.net` at the given tailnet
    /// prefixes, for the peer db the view resolves sources and names against.
    fn peer_node(hostname: &str, v4: &str, v6: &str) -> ts_control::Node {
        use ts_control::{Node, NodeCapMap, StableNodeId, TailnetAddress};
        Node {
            id: 1,
            stable_id: StableNodeId("n1".to_string()),
            hostname: hostname.to_string(),
            user_id: 0,
            tailnet: Some("user.ts.net".to_string()),
            tags: vec![],
            addresses: vec![v4.parse().unwrap(), v6.parse().unwrap()],
            tailnet_address: TailnetAddress {
                ipv4: v4.parse().unwrap(),
                ipv6: v6.parse().unwrap(),
            },
            node_key: [0u8; 32].into(),
            node_key_expiry: None,
            expired: false,
            online: None,
            last_seen: None,
            key_signature: vec![],
            machine_key: None,
            disco_key: None,
            accepted_routes: vec![],
            underlay_addresses: vec![],
            derp_region: None,
            cap: Default::default(),
            cap_map: NodeCapMap::new(),
            peerapi_port: None,
            peerapi_dns_proxy: false,
            is_wireguard_only: false,
            exit_node_dns_resolvers: vec![],
            peer_relay: false,
            ssh_host_keys: vec![],
            service_vips: Default::default(),
            unsigned_peer_api_only: false,
        }
    }

    use ts_control::DnsConfig;

    /// Build a raw DNS query buffer for `labels` (A/IN).
    fn query_for(id: u16, labels: &[&str]) -> Vec<u8> {
        let mut buf: Vec<u8> = Vec::new();
        buf.extend_from_slice(&id.to_be_bytes());
        buf.extend_from_slice(&0u16.to_be_bytes()); // flags: QR=0 (query)
        buf.extend_from_slice(&1u16.to_be_bytes()); // QDCOUNT
        buf.extend_from_slice(&0u16.to_be_bytes()); // ANCOUNT
        buf.extend_from_slice(&0u16.to_be_bytes()); // NSCOUNT
        buf.extend_from_slice(&0u16.to_be_bytes()); // ARCOUNT
        for label in labels {
            buf.push(label.len() as u8);
            buf.extend_from_slice(label.as_bytes());
        }
        buf.push(0); // root
        buf.extend_from_slice(&1u16.to_be_bytes()); // QTYPE A
        buf.extend_from_slice(&1u16.to_be_bytes()); // QCLASS IN
        buf
    }

    fn rcode(resp: &[u8]) -> u8 {
        resp[3] & 0x0F
    }

    /// A view with MagicDNS on, one upstream resolver, and the given filtered set.
    fn view(filtered: &[&str]) -> DnsView {
        DnsView {
            cfg: DnsConfig {
                magic_dns: true,
                search_domains: vec!["user.ts.net".to_string()],
                fallback_resolvers: vec![ts_control::DnsResolver {
                    transport: ts_control::ResolverTransport::Udp("9.9.9.9:53".parse().unwrap()),
                    use_with_exit_node: false,
                }],
                exit_node_filtered_set: filtered.iter().map(|s| s.to_string()).collect(),
                ..Default::default()
            },
            // The accept-dns gate defaults to `false` (Default); set it true so these DoH tests
            // exercise the serving/forwarding path, not the gated-off REFUSED path.
            accept_dns: true,
            ..Default::default()
        }
    }

    #[test]
    fn filtered_name_is_refused() {
        let v = view(&["blocked.example.com"]);
        let q = query_for(0x1, &["blocked", "example", "com"]);
        match server_decide(&v, &q, true) {
            ServerDecision::Reply(resp) => assert_eq!(rcode(&resp), 5, "REFUSED"),
            ServerDecision::Forward { .. } => panic!("filtered name must not forward"),
        }
    }

    #[test]
    fn recursive_query_refused_when_egress_disabled() {
        // A public name that would otherwise forward must be REFUSED when this node hasn't opted
        // into exit egress — fail-closed, no leak of the real host IP.
        let v = view(&[]);
        let q = query_for(0x2, &["example", "com"]);
        match server_decide(&v, &q, false) {
            ServerDecision::Reply(resp) => assert_eq!(rcode(&resp), 5, "REFUSED"),
            ServerDecision::Forward { .. } => panic!("must not forward when egress disabled"),
        }
    }

    #[test]
    fn recursive_query_forwards_when_egress_enabled() {
        let v = view(&[]);
        let q = query_for(0x3, &["example", "com"]);
        match server_decide(&v, &q, true) {
            ServerDecision::Forward { upstreams, .. } => {
                assert_eq!(upstreams, vec!["9.9.9.9:53".parse().unwrap()]);
            }
            ServerDecision::Reply(_) => panic!("expected forward when egress enabled"),
        }
    }

    #[test]
    fn authoritative_answer_is_not_gated() {
        // A tailnet name under a search domain is authoritative (NXDOMAIN here since no peer), and
        // is answered regardless of the egress gate — it never forwards.
        let v = view(&[]);
        let q = query_for(0x4, &["host", "user", "ts", "net"]);
        match server_decide(&v, &q, false) {
            ServerDecision::Reply(resp) => assert_eq!(rcode(&resp), 3, "NXDOMAIN, not REFUSED"),
            ServerDecision::Forward { .. } => panic!("tailnet name must not forward"),
        }
    }

    #[test]
    fn a_subdomain_host_answers_here_like_any_other_peer_name() {
        // The DoH server shares `decide`, so the `dns-subdomain-resolve` walk reaches this path
        // too: a subdomain of one of *this* node's peers is answered locally, from netmap data,
        // exactly as that peer's own name already is (the documented MagicDNS-namespace bleed at
        // the top of this module — it is the same bleed, one name wider, and it still never
        // touches a host socket). It is authoritative, so the egress gate does not apply.
        use std::sync::Arc;

        use crate::peer_tracker::PeerDb;

        let mut node = peer_node("host", "100.64.0.1/32", "fd7a::1/128");
        node.cap_map
            .insert("dns-subdomain-resolve".to_string(), vec![]);

        let mut db = PeerDb::default();
        db.upsert(&node);
        let mut v = view(&[]);
        v.peers = Some(Arc::new(db));

        let q = query_for(0x9, &["my", "host", "user", "ts", "net"]);
        match server_decide(&v, &q, false) {
            ServerDecision::Reply(resp) => {
                assert_eq!(rcode(&resp), 0, "NoError from the subdomain host");
                assert_eq!(
                    u16::from_be_bytes([resp[6], resp[7]]),
                    1,
                    "one A record, the peer's own address"
                );
                assert_eq!(&resp[resp.len() - 4..], &[100, 64, 0, 1]);
            }
            ServerDecision::Forward { .. } => {
                panic!("an authoritative subdomain answer must not forward")
            }
        }
    }

    /// The DoH *server* never marks a peer's answer truncated for size. Upstream's
    /// `HandlePeerDNSQuery` forwards with family `"tcp"` and the guard at the top of
    /// `checkResponseSizeAndSetTC` drops the check entirely; the peer that asked us runs the same
    /// check itself, against its own client's transport, before relaying our answer onward. Setting
    /// `TC` here would tell that peer's client to retry over a transport it never needed.
    #[test]
    fn a_peers_doh_answer_is_never_marked_truncated_for_size() {
        let query = query_for(0x5, &["example", "com"]);
        let mut answer = query.clone();
        answer[2] |= 0x80; // QR=1
        answer.resize(900, 0xAB); // over the 512 bytes a query with no OPT record advertises

        let out = check_response_size_and_set_tc(&query, answer.clone(), PEER_CLIENT_TRANSPORT);
        assert_eq!(
            out, answer,
            "the DoH server relays the peer's answer byte-for-byte"
        );
        assert_eq!(
            out[2] & 0x02,
            0,
            "TC is the requesting peer's call to make, not ours"
        );
    }

    #[test]
    fn unparseable_body_is_formerr() {
        match server_decide(&view(&[]), &[0xAB, 0xCD, 0xFF], true) {
            ServerDecision::Reply(resp) => {
                assert_eq!(&resp[0..2], &[0xAB, 0xCD]);
                assert_eq!(rcode(&resp), 1, "FORMERR");
            }
            ServerDecision::Forward { .. } => panic!("garbage must not forward"),
        }
    }

    // ---------------------------------------------------------------------------------------------
    // The source gate (Go `isPeerAPIDNSAllowed`).
    //
    // Both directions are tested on purpose: a gate that never refuses and a gate that always
    // refuses look identical from one side. Every refusal below is asserted with
    // `forward_exit_egress = true` and against a query the server *would* have resolved, so what is
    // being measured is the gate and not the egress opt-in.

    /// A ruleset granting `src_pfx` TCP access to `dst_pfx` on `ports`, as control compiles one.
    fn tcp_rule(
        src_pfx: &str,
        dst_pfx: &str,
        ports: std::ops::RangeInclusive<u16>,
    ) -> ts_packetfilter::Rule {
        ts_packetfilter::Rule {
            src: ts_packetfilter::SrcMatch {
                pfxs: vec![src_pfx.parse().unwrap()],
                caps: vec![],
            },
            protos: vec![ts_packetfilter::IpProto::TCP],
            dst: vec![ts_packetfilter::DstMatch {
                ports,
                ips: vec![dst_pfx.parse().unwrap()],
            }],
        }
    }

    /// A live filter carrying `rules`, in the shape the packet-filter updater publishes.
    fn filter_of(
        rules: Vec<ts_packetfilter::Rule>,
    ) -> Arc<dyn ts_packetfilter::Filter + Send + Sync> {
        let mut f = ts_packetfilter::HashbrownFilter::new();
        f.insert("acl".to_string(), rules);
        Arc::new(f)
    }

    /// A view holding one peer at `100.64.0.1`/`fd7a::1` plus one upstream resolver, so a public
    /// name is a genuine `Forward` and a refusal can only have come from the gate.
    fn view_with_peer() -> DnsView {
        let mut db = crate::peer_tracker::PeerDb::default();
        db.upsert(&peer_node("host", "100.64.0.1/32", "fd7a::1/128"));
        let mut v = view(&[]);
        v.peers = Some(Arc::new(db));
        v
    }

    /// What this server does with one `/dns-query` POST. Mirrors `handle_conn`'s sequence at the
    /// seams the types allow — the netstack `TcpStream` cannot be constructed in a unit test (the
    /// same limitation `crate::peerapi`'s tests record) — by calling the two production functions
    /// `handle_conn` calls, in its order: gate first, resolve only if the gate allowed.
    enum Served {
        /// The gate refused: `403`, and `server_decide` was never reached.
        Forbidden,
        /// The gate allowed, and this is what the server decided about the name.
        Dns(ServerDecision),
    }

    fn doh_request_path(
        view: &DnsView,
        filter: Option<&(dyn ts_packetfilter::Filter + Send + Sync)>,
        src: std::net::IpAddr,
        query: &[u8],
        forward_exit_egress: bool,
    ) -> Served {
        if !dns_source_allowed(view, filter, src) {
            return Served::Forbidden;
        }
        Served::Dns(server_decide(view, query, forward_exit_egress))
    }

    #[test]
    fn probe_destination_is_off_tailnet_per_family() {
        // Go: `netip.AddrFrom4([4]byte{})` for a v4 peer, `2000::` for a v6 one.
        assert_eq!(
            internet_probe_dst("100.64.0.1".parse().unwrap()),
            "0.0.0.0".parse::<std::net::IpAddr>().unwrap()
        );
        assert_eq!(
            internet_probe_dst("fd7a::1".parse().unwrap()),
            "2000::".parse::<std::net::IpAddr>().unwrap()
        );
    }

    #[test]
    fn a_peer_the_acl_grants_internet_is_answered() {
        // Control grants this peer the internet through us on port 53 — Go's
        // `CheckTCP(peer, 0.0.0.0, 53) == Accept`. The query is resolved as before.
        let v = view_with_peer();
        let filter = filter_of(vec![tcp_rule("100.64.0.1/32", "0.0.0.0/0", 53..=53)]);
        let src: std::net::IpAddr = "100.64.0.1".parse().unwrap();

        assert!(dns_source_allowed(&v, Some(&*filter), src));

        let q = query_for(0x10, &["example", "com"]);
        match doh_request_path(&v, Some(&*filter), src, &q, true) {
            Served::Dns(ServerDecision::Forward { .. }) => {}
            Served::Dns(ServerDecision::Reply(_)) => panic!("expected the forward it always got"),
            Served::Forbidden => panic!("an ACL-granted peer must still get its answer"),
        }
    }

    #[test]
    fn a_peer_the_acl_denies_gets_403_and_no_resolution() {
        // The common tailnet policy: this peer may reach every port of every tailnet node — which
        // is how it reached the peerAPI port at all — but control grants it no internet access
        // through us. Go refuses its DNS with `403`, and so do we.
        let v = view_with_peer();
        let filter = filter_of(vec![tcp_rule(
            "100.64.0.1/32",
            "100.64.0.0/10",
            0..=u16::MAX,
        )]);
        let src: std::net::IpAddr = "100.64.0.1".parse().unwrap();

        assert!(!dns_source_allowed(&v, Some(&*filter), src));

        // Exit egress is ON, so the refusal below is the source gate's and not the egress opt-in's:
        // ungated, this exact query forwards.
        let q = query_for(0x11, &["example", "com"]);
        assert!(
            matches!(server_decide(&v, &q, true), ServerDecision::Forward { .. }),
            "the name itself is one this node would have resolved"
        );
        assert!(matches!(
            doh_request_path(&v, Some(&*filter), src, &q, true),
            Served::Forbidden
        ));

        // And the authoritative half is refused too: the gate is about which peer asked, not which
        // name it asked for, so this peer does not get MagicDNS answers for our tailnet either.
        let tailnet_q = query_for(0x12, &["host", "user", "ts", "net"]);
        assert!(matches!(
            doh_request_path(&v, Some(&*filter), src, &tailnet_q, true),
            Served::Forbidden
        ));
    }

    #[test]
    fn a_source_that_is_no_known_peer_is_refused() {
        // Go's peerAPI resolves the connection's source to a peer before any handler runs and drops
        // the connection when it cannot. An allow-everything ACL does not rescue an unknown source.
        let v = view_with_peer();
        let filter = filter_of(vec![tcp_rule("0.0.0.0/0", "0.0.0.0/0", 0..=u16::MAX)]);

        assert!(!dns_source_allowed(
            &v,
            Some(&*filter),
            "198.51.100.7".parse().unwrap()
        ));
    }

    #[test]
    fn no_compiled_filter_yet_refuses() {
        // Go: `f := b.filterAtomic.Load(); if f == nil { return false }`. Before the first netmap
        // there is nothing to check the peer against, so it is a refusal — never an allow.
        let v = view_with_peer();
        assert!(!dns_source_allowed(&v, None, "100.64.0.1".parse().unwrap()));
    }

    #[test]
    fn an_ipv6_peer_is_checked_against_the_global_unicast_probe() {
        // Same two directions for the v6 arm, where Go probes `2000::` instead of `0.0.0.0`: a rule
        // covering global unicast admits, a tailnet-only rule does not.
        let v = view_with_peer();
        let src: std::net::IpAddr = "fd7a::1".parse().unwrap();

        let internet = filter_of(vec![tcp_rule("fd7a::1/128", "2000::/3", 53..=53)]);
        assert!(dns_source_allowed(&v, Some(&*internet), src));

        let tailnet_only = filter_of(vec![tcp_rule("fd7a::1/128", "fd7a::/48", 0..=u16::MAX)]);
        assert!(!dns_source_allowed(&v, Some(&*tailnet_only), src));
    }

    #[test]
    fn a_rule_on_another_port_does_not_open_dns() {
        // The gate asks about port 53 specifically (Go's `CheckTCP(..., 53)`): a peer granted the
        // internet on 443 alone is not granted this node's resolver.
        let v = view_with_peer();
        let filter = filter_of(vec![tcp_rule("100.64.0.1/32", "0.0.0.0/0", 443..=443)]);

        assert!(!dns_source_allowed(
            &v,
            Some(&*filter),
            "100.64.0.1".parse().unwrap()
        ));
    }
}