diff --git a/README.md b/README.md index d2af5f3..6955b8a 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,19 @@ per-instance service-work accounting. The bounded lneto-backed DNS engine uses generation-safe per-instance query handles with readiness, cancellation, timeout, quota, and lifecycle cleanup. It correlates exact echoed questions, emits only a unique reachable CNAME chain plus requested terminal A/AAAA records, rejects -conflicting chains and loops, and directly fuzzes compressed wire parsing. DNS is -UDP-only: truncated responses return `TEMPORARY_FAILURE` because DNS-over-TCP -fallback is not implemented. Privileged packet access remains absent and -unsupported. +conflicting chains and loops, and directly fuzzes compressed wire parsing. +Hosts may opt into `dns.EnableTCPFallback(maxResponseBytes, +maxServiceAttempts)`: a valid correlated truncated UDP response then transfers +the same query to one private length-prefixed TCP stream under exact response, +service-attempt, port, TCP-buffer, quota, cancellation, and teardown bounds. The +maximum fallback response is quota-reserved with the query, but its byte slice is +allocated only after a correlated truncation and is cleared and released as soon +as the query becomes terminal. Namespace-retained parser scratch stays at the UDP +response bound; larger TCP answers use temporary scratch sized by the bounded +answer count. The guest receives no raw TCP handle or capability, and raw-TCP +deny rules still constrain the configured resolver. Without the option, +truncation retains the original `TEMPORARY_FAILURE` behavior. Privileged packet access remains absent +and unsupported. The primary composition API selects only the protocols a runtime should expose: @@ -90,7 +99,10 @@ if err := udp.Register(network); err != nil { if err := tcp.Register(network); err != nil { return err } -if err := dns.Register(network, dns.Resolver("192.0.2.53")); err != nil { +if err := dns.Register(network, + dns.Resolver("192.0.2.53"), + dns.EnableTCPFallback(16<<10, 256), +); err != nil { return err } return wago.NewRuntime().Use(network) @@ -299,9 +311,12 @@ creates one shared lneto core per exact instance, installs selected adapters transactionally before publishing the namespace, and exposes them through an immutable protocol-neutral service composition. Failed assembly closes the core and every installed participant before any instance state is published. The root -imports no aggregate or protocol adapter package, and TCP-only, UDP-only, and -DNS-only fixtures compile only their selected public, binding, operation, -namespace-facet, ABI, and adapter packages. Protocol authority contributions are +imports no aggregate or protocol adapter package. TCP-only and UDP-only fixtures +compile only their selected implementation graphs. DNS-only additionally +contains the private lneto TCP adapter needed for optional truncation fallback, +but still excludes the public TCP facade, self-registration package, binding, +instance operations, ABI, namespace facet, capability, and guest imports. +Protocol authority contributions are deep-copied and composed once before manager construction; one immutable policy and quota domain remain shared per exact instance, with deny-wins behavior. diff --git a/agent-todo.md b/agent-todo.md index 652580b..220d3cb 100644 --- a/agent-todo.md +++ b/agent-todo.md @@ -131,9 +131,12 @@ with the selective submodule API. Acceptance checks must inspect both runtime registration and the Go dependency graph. TCP-only, UDP-only, DNS-only, and every supported combination must assert exact imports and capabilities. A TCP-only fixture must have no dependency on the -UDP/DNS public packages or lneto adapters; equivalent checks apply to the other -protocols. The root package must have no protocol-package dependency, and only -the explicit aggregate registration package may include all protocols. +UDP/DNS public packages or lneto adapters, and equivalent isolation applies to +UDP. DNS-only retains one reviewed internal exception for the private lneto TCP +adapter used by optional truncation fallback, while every public, binding, +instance, ABI, namespace-facet, capability, and guest-import TCP surface remains +absent. The root package must have no protocol-package dependency, and only the +explicit aggregate registration package may include all public protocols. ## Current architecture @@ -215,8 +218,10 @@ ownership, and synchronous close. Responses must echo the exact requested questions. Only a unique reachable CNAME chain and requested terminal A/AAAA records are emitted; irrelevant/unrequested/duplicate answers are ignored, conflicting chains and loops fail closed, and compressed wire parsing is directly -fuzzed. Truncated responses fail truthfully as temporary because TCP fallback is -not implemented. +fuzzed. Truncated responses fail truthfully as temporary unless the host enables +bounded private TCP fallback, in which case the same query handle uses one +length-prefixed stream to the configured resolver under exact byte, attempt, +port, policy, quota, cancellation, and teardown bounds. `internal/readiness` provides a finite coordinator per instance resource table. Registrations preserve exact handle kind, polls are level-triggered and bounded @@ -836,8 +841,9 @@ Arm64 execution is explicitly outside the current user-selected release profile. - DNS is finite, nonblocking, capability-gated, and fully registered. Responses are source, destination-port, transaction-ID, checksum, fragmentation, size, echoed-question, chain, record, and quota bounded. UDP truncation maps to - temporary failure because DNS-over-TCP fallback is intentionally not - implemented in ABI v1. + temporary failure by default. Hosts may opt into bounded private TCP fallback + without changing ABI v1 or exposing raw TCP authority/imports; raw-TCP deny + rules still constrain the exact configured resolver. - lneto's high-level TCP/UDP `Read`, `Write`, `ReadFrom`, and `WriteTo` use backoff loops and may block. The concrete namespace imports none of them. UDP uses adapter-owned bounded queues and lneto frame codecs. TCP is safely serialized @@ -1105,8 +1111,8 @@ Remaining risks are publishing the exact production Wago merge without rewriting its two parent histories; publishing the exact current-main Wago/networking review subjects before adoption; the absence of a separately reviewable pool plugin; lneto lacking a public immediate accepted-entry detach API so safe TCP slot reuse -remains one explicitly charged service probe after close; the intentionally -unsupported DNS-over-TCP fallback; and hosted release automation after immutable +remains one explicitly charged service probe after close; and hosted release +automation after immutable Wago publication. The release gate documents, machine-records, and narrowly checks the unchanged WASI native preview-1 exception rather than hiding it. The review bundle carries complete @@ -1736,3 +1742,46 @@ No repository-owned workstream or completion criterion from this hardening reque a zero-downtime handoff claim. - Standard Go passes across the complete repository, and all 17 TLS signoff package runs now resolve and pass 164 named test targets. + +## Bounded DNS-over-TCP fallback — July 26, 2026 + +- Added opt-in `dns.EnableTCPFallback(maxResponseBytes, maxServiceAttempts)` + without changing the six-function DNS ABI. The default remains UDP-only and + still reports a truncated response as temporary failure. +- A valid source/port/checksum/transaction/question-correlated UDP truncation + response now retires its UDP lease and can start exactly one private TCP + stream to the configured resolver. The state machine bounds connect checks, + partial length/query writes, two-byte response framing, partial response + reads, retained response bytes, and total service attempts. +- DNS authority continues to select the configured resolver and query name. + Raw-TCP allow authority is not required or exposed, but every matching + raw-TCP deny still constrains the private stream. DNS-only registration keeps + `net.tcp`, `wago_net_tcp`, the public TCP facade, binding, instance operations, + ABI, and namespace facet absent; only the internal lneto TCP adapter is a + reviewed private dependency. +- Private TCP buffers/resources/ports use the existing exact quota and shared + port domains. Cancellation, timeout, malformed/oversized length, premature + EOF, parser failure, query close, and namespace teardown close the private + stream, clear retained response bytes, and release ownership deterministically. + Aggregate compatibility capacity now includes both public TCP ports and the + maximum simultaneous DNS fallback streams with checked `uint16` arithmetic. +- Live two-core integration proves UDP truncation followed by a real lneto TCP + handshake, length-prefixed query/response exchange, unchanged A/AAAA/CNAME + iteration, and zero residual quota. Separate tests prove raw-deny rejection, + response/attempt/aggregate bounds, EOF cleanup, standard-Go race safety, and + TinyGo execution of the DNS backend and public package. +- Follow-up allocation hardening keeps the quota reservation unchanged but + defers the maximum TCP response slice until correlated truncation, zeroes and + drops it at the terminal transition instead of guest close, and sizes eager + parser scratch only to the UDP response limit. With a 65,535-byte TCP bound + and 512-byte UDP bound this removes about 554 KiB of always-retained parser + scratch per namespace and eliminates the prior 16 KiB allocation from every + non-truncated query in the documented example configuration. +- Quota accounting, packet, and inline-record retention now use detached cleared + slots rather than embedding 1,501 bytes in every query object. Steady + resolve/close allocation falls from 1,792 to 288 bytes per query (83.9%) while + controlled latency drops from about 614 ns to 478 ns (22.1%) with the same one + allocation. Charges are released/reset before reuse. The adapter caches only + one accounting slot, one packet slot, one inline slot, and at most one overflow + slot of 256 records; larger overflow buffers are released instead of becoming + an uncharged high-water memory pool. diff --git a/dns/dns.go b/dns/dns.go index 7e3173e..5d1d70b 100644 --- a/dns/dns.go +++ b/dns/dns.go @@ -22,9 +22,10 @@ var ( ) // Config fixes DNS resolver authority, concurrent queries, retained records, -// response bytes, and deterministic retry bounds. MaxQueries limits live guest -// query handles until close even after a terminal query has retired its -// transport state. Zero disables queries. +// UDP/TCP response bytes, and deterministic retry bounds. MaxQueries limits live +// guest query handles until close even after a terminal query has retired its +// transport state. TCP fallback is disabled unless both TCP fields are nonzero. +// Zero MaxQueries disables queries. type Config = dnsbackend.Config // DefaultConfig returns finite A/AAAA client storage for one explicit resolver. @@ -45,12 +46,15 @@ type optionFunc func(*registration) error func (option optionFunc) applyDNS(config *registration) error { return option(config) } type registration struct { - config Config - configSet bool - resolver netip.Addr - resolverSet bool - defaultAuthority bool - authorityAdditions policy.Config + config Config + configSet bool + resolver netip.Addr + resolverSet bool + tcpFallbackSet bool + maxTCPResponseBytes int + maxTCPServiceAttempts uint16 + defaultAuthority bool + authorityAdditions policy.Config } // WithConfig supplies the advanced exact DNS resolver and storage configuration. @@ -77,6 +81,23 @@ func Resolver(server string) Option { }) } +// EnableTCPFallback permits one private, non-guest-visible TCP connection to +// the configured resolver after a valid correlated UDP response sets the DNS +// truncation bit. Response retention and maintenance attempts remain exact and +// finite; raw-TCP allow authority is not granted and raw-TCP denies still apply. +func EnableTCPFallback(maxResponseBytes int, maxServiceAttempts uint16) Option { + return optionFunc(func(target *registration) error { + if target.tcpFallbackSet || maxResponseBytes < 12 || maxResponseBytes > dnsbackend.MaximumTCPResponseBytes || + maxServiceAttempts == 0 || maxServiceAttempts > dnsbackend.MaximumTCPServiceAttempts { + return ErrInvalidOption + } + target.tcpFallbackSet = true + target.maxTCPResponseBytes = maxResponseBytes + target.maxTCPServiceAttempts = maxServiceAttempts + return nil + }) +} + // WithPolicy adds advanced raw DNS-name policy rules. func WithPolicy(config wagonet.PolicyConfig) Option { return optionFunc(func(target *registration) error { @@ -127,13 +148,17 @@ func (r registration) authority() policy.Config { func (r registration) finalConfig() Config { config := r.config - if !r.resolverSet { - return config + if r.resolverSet { + if !r.configSet { + config = DefaultConfig(r.resolver) + } else { + config.Server = r.resolver + } } - if !r.configSet { - return DefaultConfig(r.resolver) + if r.tcpFallbackSet { + config.MaxTCPResponseBytes = r.maxTCPResponseBytes + config.MaxTCPServiceAttempts = r.maxTCPServiceAttempts } - config.Server = r.resolver return config } @@ -151,7 +176,23 @@ func Register(network *wagonet.Network, options ...Option) error { } } resolvedConfig := config.finalConfig() - backend := plugin.NewBackend(plugin.BackendLnetoV1, nil, + if config.tcpFallbackSet && !config.resolverSet && (!config.configSet || !resolvedConfig.Server.IsValid()) { + return ErrInvalidResolver + } + backend := plugin.NewBackend(plugin.BackendLnetoV1, func(base any) error { + common, ok := base.(*lnetocore.Config) + if !ok { + return plugin.ErrInvalidBackend + } + if resolvedConfig.MaxTCPResponseBytes != 0 { + ports := uint32(common.MaxActiveTCPPorts) + uint32(resolvedConfig.MaxQueries) + if ports > uint32(^uint16(0)) { + return plugin.ErrInvalidBackend + } + common.MaxActiveTCPPorts = uint16(ports) + } + return nil + }, func(base any) (nscore.Service, error) { common, ok := base.(*lnetocore.Namespace) if !ok { diff --git a/dns/options_test.go b/dns/options_test.go index 7b04e70..0ddbc41 100644 --- a/dns/options_test.go +++ b/dns/options_test.go @@ -66,6 +66,45 @@ func TestAllowAllStillHonorsRawDenyRules(t *testing.T) { } } +func TestTCPFallbackOptionIsFiniteAndOrderIndependent(t *testing.T) { + resolver := netip.MustParseAddr("192.0.2.53") + for _, options := range [][]Option{ + {Resolver(resolver.String()), EnableTCPFallback(16<<10, 128)}, + {EnableTCPFallback(16<<10, 128), Resolver(resolver.String())}, + {WithConfig(Config{Server: resolver, MaxQueries: 2, MaxRecords: 4, MaxResponseBytes: 512, MaxAttempts: 1, RetryServiceAttempts: 1}), EnableTCPFallback(16<<10, 128)}, + } { + config := registration{defaultAuthority: true} + for _, option := range options { + if err := option.applyDNS(&config); err != nil { + t.Fatal(err) + } + } + resolved := config.finalConfig() + if resolved.MaxTCPResponseBytes != 16<<10 || resolved.MaxTCPServiceAttempts != 128 { + t.Fatalf("TCP fallback config = %+v", resolved) + } + } + for name, option := range map[string]Option{ + "short response": EnableTCPFallback(11, 1), + "large response": EnableTCPFallback(65536, 1), + "zero attempts": EnableTCPFallback(512, 0), + "many attempts": EnableTCPFallback(512, 4097), + } { + t.Run(name, func(t *testing.T) { + if err := option.applyDNS(®istration{}); !errors.Is(err, ErrInvalidOption) { + t.Fatalf("invalid fallback = %v", err) + } + }) + } + config := registration{} + if err := EnableTCPFallback(512, 1).applyDNS(&config); err != nil { + t.Fatal(err) + } + if err := EnableTCPFallback(512, 1).applyDNS(&config); !errors.Is(err, ErrInvalidOption) { + t.Fatalf("duplicate fallback = %v", err) + } +} + func TestResolverAndConfigComposeIndependentOfOptionOrder(t *testing.T) { resolver := netip.MustParseAddr("192.0.2.53") custom := Config{MaxQueries: 3, MaxRecords: 4, MaxResponseBytes: 640, MaxAttempts: 5, RetryServiceAttempts: 6} diff --git a/dns/register_test.go b/dns/register_test.go index 6bff854..14ca8ae 100644 --- a/dns/register_test.go +++ b/dns/register_test.go @@ -199,33 +199,51 @@ func TestDefaultDNSStorageFitsSharedDefaultsAndStopsAtEightQueries(t *testing.T) } func TestDNSRegistrationLeavesTCPAndUDPImportsUnresolved(t *testing.T) { - network := wagonet.New() - if err := dns.Register(network); err != nil { - t.Fatalf("Register: %v", err) - } - runtime := wago.NewRuntime() - if err := runtime.Use(network); err != nil { - t.Fatalf("Use: %v", err) - } - - for _, test := range []struct { - module string - capability wago.Capability - }{ - {module: wagonet.TCPModule, capability: wagonet.CapTCP}, - {module: wagonet.UDPModule, capability: wagonet.CapUDP}, + for name, options := range map[string][]dns.Option{ + "udp only": {dns.Resolver("192.0.2.53")}, + "tcp fallback": {dns.Resolver("192.0.2.53"), dns.EnableTCPFallback(16<<10, 128)}, } { - module, err := runtime.Compile(namespaceImportModule(test.module)) - if err == nil { - var instance *wago.Instance - instance, err = runtime.Instantiate(context.Background(), module, wago.WithPolicy(wago.Policy{AllowedCapabilities: []wago.Capability{test.capability}})) - if instance != nil { - _ = instance.Close() + t.Run(name, func(t *testing.T) { + network := wagonet.New(wagonet.WithConfig(wagonet.Config{StaticIPv4: selectiveStaticIPv4()})) + if err := dns.Register(network, options...); err != nil { + t.Fatalf("Register: %v", err) } - } - if err == nil { - t.Fatalf("unregistered %s import unexpectedly resolved", test.module) - } + runtime := wago.NewRuntime() + if err := runtime.Use(network); err != nil { + t.Fatalf("Use: %v", err) + } + empty, err := runtime.Compile([]byte{0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00}) + if err != nil { + t.Fatal(err) + } + instance, err := runtime.Instantiate(context.Background(), empty) + if err != nil { + t.Fatalf("instantiate DNS-only runtime: %v", err) + } + if err := instance.Close(); err != nil { + t.Fatal(err) + } + + for _, test := range []struct { + module string + capability wago.Capability + }{ + {module: wagonet.TCPModule, capability: wagonet.CapTCP}, + {module: wagonet.UDPModule, capability: wagonet.CapUDP}, + } { + module, err := runtime.Compile(namespaceImportModule(test.module)) + if err == nil { + var instance *wago.Instance + instance, err = runtime.Instantiate(context.Background(), module, wago.WithPolicy(wago.Policy{AllowedCapabilities: []wago.Capability{test.capability}})) + if instance != nil { + _ = instance.Close() + } + } + if err == nil { + t.Fatalf("unregistered %s import unexpectedly resolved", test.module) + } + } + }) } } diff --git a/docs/abi-v1.md b/docs/abi-v1.md index 4e825f7..532b1d4 100644 --- a/docs/abi-v1.md +++ b/docs/abi-v1.md @@ -258,8 +258,15 @@ Irrelevant records, unrequested address types, and semantic duplicates are ignored. Conflicting CNAME targets, CNAME loops, malformed compression, malformed resources, and retention-limit overflow fail closed. A successful response may contain no relevant records, in which case `next` returns `EOF`. -Truncated UDP responses return `TEMPORARY_FAILURE`; ABI v1 does not implement -DNS-over-TCP fallback. +TCP fallback is a host registration option and does not change the six-function +ABI. When disabled, truncated UDP responses return `TEMPORARY_FAILURE`. When +`dns.EnableTCPFallback` is enabled, a valid correlated truncation response +retires the UDP transport and starts one private TCP stream to the same resolver. +The backend uses DNS's two-byte length framing and the existing query handle, +record iterator, cancellation, close, and poll operations. Exact TCP response +bytes, service attempts, TCP buffers, port ownership, quota, and teardown are +bounded; raw-TCP denies apply, while no `net.tcp` capability or `wago_net_tcp` +import is exposed. ## ICMPv4 module, signatures, and layouts diff --git a/docs/architecture.md b/docs/architecture.md index 7031e21..97023ab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -269,29 +269,49 @@ defense conflict causes reconfiguration, and fails `INVALID_STATE` without mutation if another dynamic contributor owns the domain. No lneto blocking, deadline, sleep, retry/backoff, goroutine, or retained guest-slice API is used. `internal/backend/lneto/dns` owns immediate IPv4 UDP queries plus lneto DNS -codecs, finite query/record/response bounds, -policy and quota ownership, deterministic service-attempt retransmission and -timeout, semantic RCode mapping, and copied A/AAAA/CNAME records. Each query has -an active transport phase (UDP source-port lease, `byPort` dispatch entry, retry -state) and a guest-visible terminal phase (handle, retained records or failure, -quota until close). Successful completion, timeout, cancellation, parser -failure, and other terminal failures retire the transport phase before the query +codecs, finite query/record/response bounds, policy and quota ownership, +deterministic service-attempt retransmission and timeout, semantic RCode mapping, +and copied A/AAAA/CNAME records. Hosts may opt into bounded TCP fallback. A valid +correlated UDP truncation response then retires the UDP source-port lease and +uses one private `internal/backend/lneto/tcp` stream to the same configured +resolver. The adapter writes and reads DNS's two-byte length framing under exact +response-byte and service-attempt limits; no raw TCP handle, capability, binding, +instance operation, ABI, or namespace facet is exposed. Raw-TCP denies still +constrain the private connection without requiring a raw-TCP allow rule. Each +query has an active transport phase (UDP source-port lease and retry state, or a +single private TCP stream) and a guest-visible terminal phase (handle, retained +records or failure, quota until close). The maximum TCP response remains +quota-reserved from query creation, but the response byte slice is allocated only +when a correlated truncation starts fallback and is zeroed and dropped at the +terminal transition rather than being retained until guest close. Namespace +parser scratch is eagerly bounded only by the UDP response limit; a larger TCP +answer uses temporary candidate/name scratch capped by both the actual frame and +its declared answer count. Query objects lease separately pooled quota-accounting, +packet, and eight-record inline slots, reducing per-query garbage without making +stale handles alias a new query. Charges are released and reset before reuse. +Only one accounting slot, one cleared packet slot, one inline record slot, and at +most one overflow slot of 256 records may remain cached; larger overflow storage +is dropped at close. Successful +completion, timeout, cancellation, parser +failure, and other terminal failures retire the active transport before the query publishes its terminal result, so late packets cannot mutate committed records. Responses must echo the exact requested names/classes/types. Only a unique CNAME chain reachable from the requested name and requested A/AAAA records at its terminal name are emitted; irrelevant and duplicate answers are ignored, while conflicts and loops fail closed. Compressed names and resource framing have - direct fuzz coverage. Truncated UDP responses map to temporary failure because -DNS-over-TCP fallback is not implemented. `MaxQueries` still limits live guest -query handles until close even after terminal transport retirement. UDP sockets +direct fuzz coverage. Truncated UDP responses map to temporary failure when TCP +fallback is disabled; when enabled they can complete only through the bounded +private TCP state machine. `MaxQueries` still limits live guest query handles +until close even after terminal transport retirement. UDP sockets and DNS queries reserve local ports through one protocol-neutral core lease domain, preserving exact collision, release, deterministic allocation, and close behavior without moving datagrams or DNS records into core. Root namespace construction imports only the shared lneto core. Root, single-protocol, pair, and all-protocol dependency fixtures require exactly the selected -adapters/facets and reject every omitted one plus the aggregate assembler, -completing the Stage 4 compile-isolation boundary; runtime composition separately -covers all 4096 selective registrations. +adapters/facets and reject every omitted one plus the aggregate assembler. The +reviewed exception is DNS's private lneto TCP adapter: DNS-only graphs include +that adapter but reject every public TCP layer and guest-facing TCP import. +Runtime composition separately covers all 4096 selective registrations. Granular `tcp/register`, `udp/register`, `dns/register`, `icmpv4/register`, `ntp/register`, `mdns/register`, `dhcpv4/register`, `linklocal4/register`, `ipv6/register`, `icmpv6/register`, and `dhcpv6/register` packages own only their diff --git a/docs/protocol-submodule-migration.md b/docs/protocol-submodule-migration.md index 135e8d7..708d068 100644 --- a/docs/protocol-submodule-migration.md +++ b/docs/protocol-submodule-migration.md @@ -95,8 +95,11 @@ policy or quota construction, while advanced callers retain exact control. plus the shared `wago_net.abi_version` core import. Unregistered protocols are absent from Wago inspection and fail ordinary Wasm import resolution. 4. A TCP-only dependency graph contains no plugin UDP/DNS public, binding, - instance-operation, or lneto-adapter package. Equivalent isolation holds for - UDP-only and DNS-only clients. + instance-operation, or lneto-adapter package, and equivalent isolation holds + for UDP-only clients. DNS-only clients exclude every public TCP facade, + register package, binding, instance operation, ABI, namespace facet, + capability, and guest import; they may include the private lneto TCP adapter + used by opt-in DNS truncation fallback. 5. Shared lifecycle hooks, exact caller identity, resource-table identity, policy composition, quotas, readiness, namespace ownership, reset safety, and deterministic cleanup are installed exactly once per composed network. @@ -309,7 +312,10 @@ advanced compatibility section for raw configuration. Small root, TCP-only, UDP-only, DNS-only, pair, and aggregate fixtures now gate exact runtime capability/import sets under standard Go and TinyGo. Their standard-Go `go list -deps` gate rejects every omitted public protocol and -binding package plus accidental aggregate-package dependencies. The fixtures +binding package plus accidental aggregate-package dependencies. DNS-only graphs +have one explicit internal exception for the private lneto TCP adapter used by +bounded fallback; every public and guest-facing TCP layer remains rejected. The +fixtures require `internal/instance/core`, `internal/abi/core`, `internal/namespace/core`, and `internal/backend/lneto/core` in every graph. They require only selected `internal/instance/{tcp,udp,dns}` operations, diff --git a/docs/release-signoff.md b/docs/release-signoff.md index 9399590..0038be2 100644 --- a/docs/release-signoff.md +++ b/docs/release-signoff.md @@ -28,6 +28,19 @@ as `unsupported-explicit`: no stub, guest module, or fake engine exists. TLS mus not be called production-ready until the complete strict gate passes for the final subject and arm64 TLS execution is retained from a suitable runner. +## DNS private-transport signoff status + +DNS retains the existing six-function `wago_net_dns` ABI and `net.dns` +capability. Opt-in `dns.EnableTCPFallback` adds no public/raw TCP capability or +import: a correlated truncated UDP response may use one private lneto TCP stream +to the configured resolver under exact response-byte, service-attempt, port, +buffer, quota, cancellation, and teardown bounds. Standard-Go, race, and TinyGo +backend tests exercise the real two-core UDP-to-TCP exchange plus deny, timeout, +oversized-length, premature-EOF, and cancellation cleanup. Dependency inspection +permits the lneto TCP adapter in DNS-only graphs while continuing to reject the +public TCP facade, register package, binding, instance operations, ABI, namespace +facet, capability, and guest imports. + ## Pinned inputs The script refuses revision drift before doing work: diff --git a/internal/backend/lneto/dns/benchmark_test.go b/internal/backend/lneto/dns/benchmark_test.go index f01b6a1..705702e 100644 --- a/internal/backend/lneto/dns/benchmark_test.go +++ b/internal/backend/lneto/dns/benchmark_test.go @@ -8,6 +8,7 @@ import ( lnetodns "github.com/soypat/lneto/dns" nscore "github.com/wago-org/net/internal/namespace/core" dnsns "github.com/wago-org/net/internal/namespace/dns" + "github.com/wago-org/net/internal/quota" ) var ( @@ -218,6 +219,27 @@ func BenchmarkAdapterTryResolveClose(b *testing.B) { } } +func BenchmarkAdapterTryResolveCloseTCPFallback(b *testing.B) { + config := dnsTestConfig(b, 88) + config.MaxActiveTCPPorts = 1 + config.DNS.MaxQueries = 1 + config.DNS.MaxTCPResponseBytes = 16 << 10 + config.DNS.MaxTCPServiceAttempts = 128 + config.Quotas = quota.NewAccount(quota.Limits{Resources: 4, TCPResources: 2, DNSResources: 2, QueuedBytes: 1 << 20, DNSWork: 4}) + ns := newTestNamespace(b, config) + request := dnsns.Request{Name: "service.api.example.com", Types: dnsns.RecordsA | dnsns.RecordsAAAA} + b.ReportAllocs() + for b.Loop() { + value, progress, err := ns.adapter.TryResolve(request) + if err != nil || progress != nscore.ProgressInProgress { + b.Fatalf("resolve = %T, %v, %v", value, progress, err) + } + if err := value.Close(); err != nil { + b.Fatal(err) + } + } +} + func BenchmarkQueryTryNext(b *testing.B) { config := dnsTestConfig(b, 86) ns := newTestNamespace(b, config) diff --git a/internal/backend/lneto/dns/dns.go b/internal/backend/lneto/dns/dns.go index 84d4d64..b350e81 100644 --- a/internal/backend/lneto/dns/dns.go +++ b/internal/backend/lneto/dns/dns.go @@ -3,6 +3,7 @@ package dns import ( "encoding/binary" "errors" + "io" "net" "net/netip" @@ -12,24 +13,34 @@ import ( "github.com/soypat/lneto/ipv4" lnetoudp "github.com/soypat/lneto/udp" lnetocore "github.com/wago-org/net/internal/backend/lneto/core" + tcpbackend "github.com/wago-org/net/internal/backend/lneto/tcp" nscore "github.com/wago-org/net/internal/namespace/core" dnsns "github.com/wago-org/net/internal/namespace/dns" "github.com/wago-org/net/internal/policy" "github.com/wago-org/net/internal/quota" + "github.com/wago-org/net/internal/resource" ) var _ dnsns.Query = (*dnsQuery)(nil) const ( - firstEphemeralDNSPort uint16 = 53000 - dnsQueryPacketCapacity = lnetodns.SizeHeader + 2*(253+2+4) + 11 - inlineDNSRecordCapacity = 8 + firstEphemeralDNSPort uint16 = 53000 + dnsQueryPacketCapacity = lnetodns.SizeHeader + 2*(253+2+4) + 11 + inlineDNSRecordCapacity = 8 + maximumCachedDNSRecordOverflow = 256 + dnsTCPReceiveBytes = 4 << 10 + dnsTCPTransmitBytes = 1 << 10 + dnsTCPTransmitPackets = 8 + MaximumTCPResponseBytes = int(^uint16(0)) + MaximumTCPServiceAttempts = 4096 + MaximumAggregateTCPResponseBytes = 64 << 20 ) var ( - errPolicyDenied = errors.New("net: endpoint policy denied operation") - errCanceled = errors.New("DNS query canceled") - errResponseLimit = errors.New("DNS response service-attempt limit reached") + errPolicyDenied = errors.New("net: endpoint policy denied operation") + errCanceled = errors.New("DNS query canceled") + errResponseLimit = errors.New("DNS response service-attempt limit reached") + errTCPFallbackLimit = errors.New("DNS TCP fallback service-attempt limit reached") ) const ( @@ -37,21 +48,24 @@ const ( closeOrder = 10 ) -// Config fixes resolver authority, response retention, concurrency, and -// deterministic retransmission work. Zero MaxQueries disables DNS truthfully. -// MaxQueries continues to limit live guest query handles until they are closed, -// even after a terminal query has already retired its transport state. +// Config fixes resolver authority, UDP/TCP response retention, concurrency, +// and deterministic retransmission work. TCP fallback is disabled unless both +// TCP fields are nonzero. Zero MaxQueries disables DNS truthfully. MaxQueries +// continues to limit live guest query handles until they are closed, even after +// a terminal query has already retired its transport state. type Config struct { - Server netip.Addr - MaxQueries uint16 - MaxRecords uint16 - MaxResponseBytes int - MaxAttempts uint16 - RetryServiceAttempts uint16 + Server netip.Addr + MaxQueries uint16 + MaxRecords uint16 + MaxResponseBytes int + MaxAttempts uint16 + RetryServiceAttempts uint16 + MaxTCPResponseBytes int + MaxTCPServiceAttempts uint16 } -// Adapter owns DNS query state, wire codecs, retries, response retention, and -// UDP service participation over one shared lneto core. +// Adapter owns DNS query state, wire codecs, retries, response retention, UDP +// service participation, and optional private TCP fallback over one shared core. type Adapter struct { core *lnetocore.Namespace config Config @@ -59,8 +73,12 @@ type Adapter struct { gatewayHardwareAddress [6]byte policy *policy.Policy quotas *quota.Account + tcp *tcpbackend.Adapter queries []*dnsQuery - freeRecordOverflow [][]dnsns.Record + freeAccounting *dnsQueryAccounting + freePacket *[dnsQueryPacketCapacity]byte + freeRecordInline *[inlineDNSRecordCapacity]dnsns.Record + freeRecordOverflow []dnsns.Record byPort map[uint16]*dnsQuery candidates []dnsns.Record names []string @@ -92,12 +110,17 @@ func New(common *lnetocore.Namespace, config Config) (*Adapter, error) { } n.queries = make([]*dnsQuery, 0, config.MaxQueries) n.byPort = make(map[uint16]*dnsQuery, config.MaxQueries) - n.candidates = make([]dnsns.Record, config.MaxResponseBytes/11) - n.names = make([]string, 2*(config.MaxResponseBytes/11)) - if int(config.MaxRecords) > inlineDNSRecordCapacity { - n.freeRecordOverflow = make([][]dnsns.Record, 0, config.MaxQueries) - } + parserRecords := config.MaxResponseBytes / 11 + n.candidates = make([]dnsns.Record, parserRecords) + n.names = make([]string, 2*parserRecords) common.Unlock() + if tcpFallbackEnabled(config) { + privateTCP, err := tcpbackend.New(common, dnsTCPConfig(config)) + if err != nil { + return nil, err + } + n.tcp = privateTCP + } if err := common.Install(lnetocore.Participant{ IngressOrder: serviceOrder, Ingress: n.ingressLocked, @@ -107,6 +130,11 @@ func New(common *lnetocore.Namespace, config Config) (*Adapter, error) { CloseOrder: closeOrder, Close: n.CloseLocked, }); err != nil { + if n.tcp != nil { + common.Lock() + n.tcp.CloseLocked() + common.Unlock() + } return nil, err } return n, nil @@ -117,41 +145,119 @@ type dnsQueryState uint8 const ( dnsQueryPending dnsQueryState = iota + 1 dnsQueryWaiting + dnsQueryTCPConnecting + dnsQueryTCPWriting + dnsQueryTCPReadingLength + dnsQueryTCPReadingResponse dnsQueryDone dnsQueryFailed dnsQueryClosed ) +type lockedTCPStream interface { + TryFinishConnectLocked() (nscore.Progress, error) + TryReadLocked([]byte) (nscore.IOResult, error) + TryWriteLocked([]byte) (nscore.IOResult, error) + CloseLocked() error +} + +type dnsQueryAccounting struct { + retained quota.Charge + work quota.Charge +} + type dnsQuery struct { - owner *Adapter - request dnsns.Request - localPort uint16 - txid uint16 - packet []byte - packetStorage [dnsQueryPacketCapacity]byte - records []dnsns.Record - recordStorage [inlineDNSRecordCapacity]dnsns.Record - recordOverflow []dnsns.Record - cursor int - attempts uint16 - retry uint16 - state dnsQueryState - failure error - - portLease lnetocore.UDPPortLease - retained quota.Charge - work quota.Charge + owner *Adapter + request dnsns.Request + localPort uint16 + txid uint16 + packet []byte + packetStorage *[dnsQueryPacketCapacity]byte + tcpStream lockedTCPStream + tcpPrefix [2]byte + tcpPrefixBytes int + tcpPacketBytes int + tcpResponse []byte + tcpResponseBytes int + tcpServiceAttempts uint16 + records []dnsns.Record + recordStorage *[inlineDNSRecordCapacity]dnsns.Record + recordOverflow []dnsns.Record + cursor int + attempts uint16 + retry uint16 + state dnsQueryState + failure error + + portLease lnetocore.UDPPortLease + accounting *dnsQueryAccounting +} + +func (n *Adapter) acquireAccountingLocked() *dnsQueryAccounting { + if n == nil || n.freeAccounting == nil { + return new(dnsQueryAccounting) + } + accounting := n.freeAccounting + n.freeAccounting = nil + return accounting +} + +func (n *Adapter) recycleAccountingLocked(accounting *dnsQueryAccounting) { + if n == nil || accounting == nil { + return + } + if n.freeAccounting == nil { + n.freeAccounting = accounting + } +} + +func (n *Adapter) acquirePacketLocked() *[dnsQueryPacketCapacity]byte { + if n == nil || n.freePacket == nil { + return new([dnsQueryPacketCapacity]byte) + } + packet := n.freePacket + n.freePacket = nil + return packet +} + +func (n *Adapter) recyclePacketLocked(packet *[dnsQueryPacketCapacity]byte) { + if n == nil || packet == nil { + return + } + clear(packet[:]) + if n.freePacket == nil { + n.freePacket = packet + } +} + +func (n *Adapter) acquireRecordInlineLocked() *[inlineDNSRecordCapacity]dnsns.Record { + if n == nil || n.freeRecordInline == nil { + return new([inlineDNSRecordCapacity]dnsns.Record) + } + records := n.freeRecordInline + n.freeRecordInline = nil + return records +} + +func (n *Adapter) recycleRecordInlineLocked(records *[inlineDNSRecordCapacity]dnsns.Record) { + if n == nil || records == nil { + return + } + clear(records[:]) + if n.freeRecordInline == nil { + n.freeRecordInline = records + } } func (n *Adapter) acquireRecordOverflowLocked() []dnsns.Record { if n == nil { return nil } - if len(n.freeRecordOverflow) == 0 { + if n.freeRecordOverflow == nil { return make([]dnsns.Record, 0, n.config.MaxRecords) } - records := n.freeRecordOverflow[len(n.freeRecordOverflow)-1] - n.freeRecordOverflow = n.freeRecordOverflow[:len(n.freeRecordOverflow)-1] + records := n.freeRecordOverflow + n.freeRecordOverflow = nil return records } @@ -160,7 +266,9 @@ func (n *Adapter) recycleRecordOverflowLocked(records []dnsns.Record) { return } clear(records) - n.freeRecordOverflow = append(n.freeRecordOverflow, records[:0:cap(records)]) + if n.freeRecordOverflow == nil && cap(records) <= maximumCachedDNSRecordOverflow { + n.freeRecordOverflow = records[:0:cap(records)] + } } func (n *Adapter) TryResolve(request dnsns.Request) (nscore.Resource, nscore.Progress, error) { @@ -184,17 +292,23 @@ func (n *Adapter) TryResolve(request dnsns.Request) (nscore.Resource, nscore.Pro if len(n.queries) == int(n.config.MaxQueries) { return nil, 0, nscore.Fail(nscore.FailureResourceLimit, lneto.ErrExhausted) } - query := &dnsQuery{owner: n, request: request, txid: n.nextTxID} - if int(n.config.MaxRecords) <= len(query.recordStorage) { + query := &dnsQuery{owner: n, request: request, txid: n.nextTxID, accounting: n.acquireAccountingLocked(), packetStorage: n.acquirePacketLocked()} + if int(n.config.MaxRecords) <= inlineDNSRecordCapacity { + query.recordStorage = n.acquireRecordInlineLocked() query.records = query.recordStorage[:0:n.config.MaxRecords] } else { query.recordOverflow = n.acquireRecordOverflowLocked() if query.recordOverflow == nil { + n.recycleAccountingLocked(query.accounting) + n.recyclePacketLocked(query.packetStorage) return nil, 0, nscore.Fail(nscore.FailureResourceLimit, lneto.ErrExhausted) } query.records = query.recordOverflow[:0:n.config.MaxRecords] } if !n.allocatePortLocked(&query.portLease) { + n.recycleAccountingLocked(query.accounting) + n.recyclePacketLocked(query.packetStorage) + n.recycleRecordInlineLocked(query.recordStorage) n.recycleRecordOverflowLocked(query.recordOverflow) return nil, 0, nscore.Fail(nscore.FailureResourceLimit, lneto.ErrExhausted) } @@ -202,13 +316,19 @@ func (n *Adapter) TryResolve(request dnsns.Request) (nscore.Resource, nscore.Pro packet, err := buildDNSQueryPacketInto(query.packetStorage[:], request, n.nextTxID, n.config.MaxResponseBytes) if err != nil { query.portLease.ReleaseLocked() + n.recycleAccountingLocked(query.accounting) + n.recyclePacketLocked(query.packetStorage) + n.recycleRecordInlineLocked(query.recordStorage) n.recycleRecordOverflowLocked(query.recordOverflow) return nil, 0, lnetocore.MapError(err) } query.packet = packet query.state = dnsQueryPending - if err := n.quotas.AcquireResourceAndQueuedBytes(&query.retained, quota.ResourceDNS, 1, dnsRetainedBytes(n.config)); err != nil { + if err := n.quotas.AcquireResourceAndQueuedBytes(&query.accounting.retained, quota.ResourceDNS, 1, dnsRetainedBytes(n.config)); err != nil { query.portLease.ReleaseLocked() + n.recycleAccountingLocked(query.accounting) + n.recyclePacketLocked(query.packetStorage) + n.recycleRecordInlineLocked(query.recordStorage) n.recycleRecordOverflowLocked(query.recordOverflow) return nil, 0, lnetocore.MapError(err) } @@ -216,10 +336,13 @@ func (n *Adapter) TryResolve(request dnsns.Request) (nscore.Resource, nscore.Pro if request.Types == dnsns.RecordsA|dnsns.RecordsAAAA { workUnits = 2 } - if err := n.quotas.AcquireDNSWork(&query.work, workUnits); err != nil { - query.retained.Release() - query.retained.ResetReleased() + if err := n.quotas.AcquireDNSWork(&query.accounting.work, workUnits); err != nil { + query.accounting.retained.Release() + query.accounting.retained.ResetReleased() query.portLease.ReleaseLocked() + n.recycleAccountingLocked(query.accounting) + n.recyclePacketLocked(query.packetStorage) + n.recycleRecordInlineLocked(query.recordStorage) n.recycleRecordOverflowLocked(query.recordOverflow) return nil, 0, lnetocore.MapError(err) } @@ -311,25 +434,36 @@ func (q *dnsQuery) closeLocked() error { removeQuery(q.owner, q) } clear(q.packet) - clear(q.packetStorage[:]) q.packet = nil + clear(q.tcpPrefix[:]) + clear(q.tcpResponse) + q.tcpResponse = nil + q.tcpPrefixBytes = 0 + q.tcpPacketBytes = 0 + q.tcpResponseBytes = 0 + q.tcpServiceAttempts = 0 for i := range q.records { q.records[i] = dnsns.Record{} } - clear(q.recordStorage[:]) q.records = nil q.cursor = 0 q.request = dnsns.Request{} q.failure = nil q.releaseQuotaLocked() if q.owner != nil { + q.owner.recycleAccountingLocked(q.accounting) + q.owner.recyclePacketLocked(q.packetStorage) + q.owner.recycleRecordInlineLocked(q.recordStorage) q.owner.recycleRecordOverflowLocked(q.recordOverflow) } + q.accounting = nil + q.packetStorage = nil + q.recordStorage = nil q.recordOverflow = nil return nil } -func (q *dnsQuery) retireTransportLocked() { +func (q *dnsQuery) retireUDPTransportLocked() { if q == nil { return } @@ -339,6 +473,31 @@ func (q *dnsQuery) retireTransportLocked() { q.portLease.ReleaseLocked() q.localPort = 0 q.retry = 0 +} + +func (q *dnsQuery) retireTCPTransportLocked() { + if q == nil { + return + } + if q.tcpStream != nil { + _ = q.tcpStream.CloseLocked() + q.tcpStream = nil + } + q.tcpPrefixBytes = 0 + q.tcpPacketBytes = 0 + q.tcpResponseBytes = 0 + q.tcpServiceAttempts = 0 + clear(q.tcpPrefix[:]) + clear(q.tcpResponse) + q.tcpResponse = nil +} + +func (q *dnsQuery) retireTransportLocked() { + if q == nil { + return + } + q.retireUDPTransportLocked() + q.retireTCPTransportLocked() q.txid = 0 } @@ -364,14 +523,20 @@ func (q *dnsQuery) completeLocked(records []dnsns.Record) { } func (q *dnsQuery) releaseWorkLocked() { - q.work.Release() - q.work.ResetReleased() + if q.accounting == nil { + return + } + q.accounting.work.Release() + q.accounting.work.ResetReleased() } func (q *dnsQuery) releaseQuotaLocked() { + if q.accounting == nil { + return + } q.releaseWorkLocked() - q.retained.Release() - q.retained.ResetReleased() + q.accounting.retained.Release() + q.accounting.retained.ResetReleased() } // CloseLocked releases every DNS query and retained allocation. The caller @@ -386,10 +551,17 @@ func (n *Adapter) CloseLocked() { clear(n.byPort) clear(n.candidates) clear(n.names) - for i := range n.freeRecordOverflow { - clear(n.freeRecordOverflow[i]) + if n.freePacket != nil { + clear(n.freePacket[:]) + } + if n.freeRecordInline != nil { + clear(n.freeRecordInline[:]) } + clear(n.freeRecordOverflow) n.byPort = nil + n.freeAccounting = nil + n.freePacket = nil + n.freeRecordInline = nil n.freeRecordOverflow = nil n.queries = nil n.candidates = nil @@ -399,13 +571,24 @@ func (n *Adapter) CloseLocked() { func (n *Adapter) hasWorkLocked() bool { for _, query := range n.queries { - if query != nil && (query.state == dnsQueryPending || query.state == dnsQueryWaiting) { + if query != nil && query.hasServiceWorkLocked() { return true } } return false } +func (q *dnsQuery) hasServiceWorkLocked() bool { + if q == nil { + return false + } + return q.state == dnsQueryPending || q.state == dnsQueryWaiting || q.tcpFallbackStateLocked() +} + +func (q *dnsQuery) tcpFallbackStateLocked() bool { + return q != nil && q.state >= dnsQueryTCPConnecting && q.state <= dnsQueryTCPReadingResponse +} + // egressLocked performs one bounded query operation. worked may be true // with a zero packet when one retry countdown or timeout transition completed. func (n *Adapter) egressLocked(dst []byte) (written int, worked bool, err error) { @@ -418,9 +601,16 @@ func (n *Adapter) egressLocked(dst []byte) (written int, worked bool, err error) index -= len(n.queries) } query := n.queries[index] - if query == nil || (query.state != dnsQueryPending && query.state != dnsQueryWaiting) { + if query == nil || !query.hasServiceWorkLocked() { continue } + if query.tcpFallbackStateLocked() { + n.cursor = index + 1 + if n.cursor == len(n.queries) { + n.cursor = 0 + } + return 0, true, query.serviceTCPFallbackLocked() + } if query.state == dnsQueryWaiting { n.cursor = index + 1 if n.cursor == len(n.queries) { @@ -479,6 +669,155 @@ func (n *Adapter) egressLocked(dst []byte) (written int, worked bool, err error) return 0, false, nil } +func (q *dnsQuery) beginTCPFallbackLocked() { + if q == nil || q.owner == nil || q.owner.tcp == nil || !tcpFallbackEnabled(q.owner.config) || q.tcpFallbackStateLocked() { + if q != nil { + q.failLocked(nscore.FailureTemporary, lneto.ErrTruncatedFrame) + } + return + } + q.retireUDPTransportLocked() + q.tcpResponse = make([]byte, q.owner.config.MaxTCPResponseBytes) + remote := nscore.Endpoint{Address: q.owner.config.Server, Port: lnetodns.ServerPort} + private, progress, err := q.owner.tcp.TryConnectAuthorizedLocked(remote, func(compiled *policy.Policy, endpoint nscore.Endpoint) error { + if endpoint != remote || !compiled.AllowsPrivateTCPTransport(policy.DirectionOutbound, endpoint.Address, endpoint.Port) { + return nscore.Fail(nscore.FailureAccessDenied, errPolicyDenied) + } + return nil + }) + if err != nil { + q.failLocked(dnsTCPFailure(err), err) + return + } + stream, ok := private.(lockedTCPStream) + if !ok || resource.IsNil(private) || (progress != nscore.ProgressDone && progress != nscore.ProgressInProgress) { + if closer, closeOK := private.(interface{ CloseLocked() error }); closeOK && !resource.IsNil(private) { + _ = closer.CloseLocked() + } + q.failLocked(nscore.FailureIO, lneto.ErrBadState) + return + } + q.tcpStream = stream + binary.BigEndian.PutUint16(q.tcpPrefix[:], uint16(len(q.packet))) + q.tcpPrefixBytes = 0 + q.tcpPacketBytes = 0 + q.tcpResponseBytes = 0 + q.tcpServiceAttempts = 0 + q.state = dnsQueryTCPConnecting +} + +func (q *dnsQuery) serviceTCPFallbackLocked() error { + if q == nil || q.owner == nil || q.tcpStream == nil || !q.tcpFallbackStateLocked() { + if q != nil { + q.failLocked(nscore.FailureIO, lneto.ErrBadState) + } + return nil + } + if q.tcpServiceAttempts >= q.owner.config.MaxTCPServiceAttempts { + q.failLocked(nscore.FailureTimedOut, errTCPFallbackLimit) + return nil + } + q.tcpServiceAttempts++ + switch q.state { + case dnsQueryTCPConnecting: + progress, err := q.tcpStream.TryFinishConnectLocked() + if err != nil { + q.failLocked(dnsTCPFailure(err), err) + return nil + } + if progress == nscore.ProgressDone { + q.state = dnsQueryTCPWriting + } + return nil + case dnsQueryTCPWriting: + var pending []byte + if q.tcpPrefixBytes < len(q.tcpPrefix) { + pending = q.tcpPrefix[q.tcpPrefixBytes:] + } else { + pending = q.packet[q.tcpPacketBytes:] + } + result, err := q.tcpStream.TryWriteLocked(pending) + if err != nil { + q.failLocked(dnsTCPFailure(err), err) + return nil + } + if q.tcpPrefixBytes < len(q.tcpPrefix) { + q.tcpPrefixBytes += result.Bytes + } else { + q.tcpPacketBytes += result.Bytes + } + if q.tcpPrefixBytes == len(q.tcpPrefix) && q.tcpPacketBytes == len(q.packet) { + clear(q.tcpPrefix[:]) + q.tcpPrefixBytes = 0 + q.state = dnsQueryTCPReadingLength + } + return nil + case dnsQueryTCPReadingLength: + result, err := q.tcpStream.TryReadLocked(q.tcpPrefix[q.tcpPrefixBytes:]) + if err != nil { + q.failLocked(dnsTCPFailure(err), err) + return nil + } + if result.State == nscore.IOEOF { + q.failLocked(nscore.FailureTemporary, io.ErrUnexpectedEOF) + return nil + } + q.tcpPrefixBytes += result.Bytes + if q.tcpPrefixBytes == len(q.tcpPrefix) { + responseBytes := int(binary.BigEndian.Uint16(q.tcpPrefix[:])) + if responseBytes == 0 { + q.failLocked(nscore.FailureIO, lneto.ErrInvalidLengthField) + } else if responseBytes > len(q.tcpResponse) { + q.failLocked(nscore.FailureMessageTooLarge, lneto.ErrShortBuffer) + } else { + q.state = dnsQueryTCPReadingResponse + } + } + return nil + case dnsQueryTCPReadingResponse: + expected := int(binary.BigEndian.Uint16(q.tcpPrefix[:])) + result, err := q.tcpStream.TryReadLocked(q.tcpResponse[q.tcpResponseBytes:expected]) + if err != nil { + q.failLocked(dnsTCPFailure(err), err) + return nil + } + if result.State == nscore.IOEOF { + q.failLocked(nscore.FailureTemporary, io.ErrUnexpectedEOF) + return nil + } + q.tcpResponseBytes += result.Bytes + if q.tcpResponseBytes != expected { + return nil + } + candidates, names := q.owner.parserScratchLocked(q.tcpResponse[:expected]) + records, response, failure, err := parseDNSResponseInto(q.records[:0], candidates, names, q.tcpResponse[:expected], q.txid, q.request, int(q.owner.config.MaxRecords)) + if !response { + q.failLocked(nscore.FailureIO, lneto.ErrMismatch) + } else if err != nil { + q.failLocked(failure, err) + } else { + q.completeLocked(records) + } + return nil + default: + q.failLocked(nscore.FailureIO, lneto.ErrBadState) + return nil + } +} + +func dnsTCPFailure(err error) nscore.Failure { + failure, ok := nscore.FailureOf(err) + if !ok { + return nscore.FailureTemporary + } + switch failure { + case nscore.FailureAccessDenied, nscore.FailureResourceLimit, nscore.FailureTimedOut, nscore.FailureCanceled: + return failure + default: + return nscore.FailureTemporary + } +} + func (n *Adapter) ingressLocked(frame []byte) (bool, error) { ethernetFrame, err := ethernet.NewFrame(frame) if err != nil || ethernetFrame.EtherTypeOrSize() != ethernet.TypeIPv4 { @@ -544,6 +883,10 @@ func (n *Adapter) ingressLocked(frame []byte) (bool, error) { return true, nil } if err != nil { + if errors.Is(err, lneto.ErrTruncatedFrame) && tcpFallbackEnabled(n.config) { + query.beginTCPFallbackLocked() + return true, nil + } query.failLocked(failure, err) return true, nil } @@ -634,7 +977,7 @@ func appendDNSQuestion(packet []byte, offset int, name string, typ lnetodns.Type func parseDNSResponse(payload []byte, txid uint16, request dnsns.Request, maxRecords int) ([]dnsns.Record, bool, nscore.Failure, error) { records := make([]dnsns.Record, 0, maxRecords) - candidates := make([]dnsns.Record, len(payload)/11) + candidates := make([]dnsns.Record, dnsCandidateScratchCount(payload)) names := make([]string, 2*len(candidates)) return parseDNSResponseInto(records, candidates, names, payload, txid, request, maxRecords) } @@ -1010,7 +1353,39 @@ func decodeDNSNameInto(decoded, message []byte, offset int) (int, int, error) { } func dnsRetainedBytes(config Config) uint64 { - return uint64(config.MaxResponseBytes) + uint64(config.MaxRecords)*(2*254+16) + 2*254 + return uint64(config.MaxResponseBytes) + uint64(config.MaxTCPResponseBytes) + uint64(config.MaxRecords)*(2*254+16) + 2*254 +} + +func dnsCandidateScratchCount(payload []byte) int { + candidateCount := len(payload) / 11 + if len(payload) >= lnetodns.SizeHeader { + candidateCount = min(candidateCount, int(binary.BigEndian.Uint16(payload[6:8]))) + } + return candidateCount +} + +func (n *Adapter) parserScratchLocked(payload []byte) ([]dnsns.Record, []string) { + candidateCount := dnsCandidateScratchCount(payload) + if candidateCount <= len(n.candidates) { + return n.candidates[:candidateCount], n.names[:2*candidateCount] + } + return make([]dnsns.Record, candidateCount), make([]string, 2*candidateCount) +} + +func tcpFallbackEnabled(config Config) bool { + return config.MaxTCPResponseBytes != 0 && config.MaxTCPServiceAttempts != 0 +} + +func dnsTCPConfig(config Config) tcpbackend.Config { + if !tcpFallbackEnabled(config) { + return tcpbackend.Config{} + } + return tcpbackend.Config{ + MaxOutboundStreams: config.MaxQueries, + ReceiveBytes: dnsTCPReceiveBytes, + TransmitBytes: dnsTCPTransmitBytes, + TransmitPackets: dnsTCPTransmitPackets, + } } // ValidConfig validates DNS-local resolver, storage, retry, and authority bounds. @@ -1021,8 +1396,12 @@ func ValidConfig(config Config, mtu int, compiled *policy.Policy, account *quota if requireAuthority && (compiled == nil || account == nil) { return false } + tcpDisabled := config.MaxTCPResponseBytes == 0 && config.MaxTCPServiceAttempts == 0 + tcpEnabled := config.MaxTCPResponseBytes >= lnetodns.SizeHeader && config.MaxTCPResponseBytes <= MaximumTCPResponseBytes && + config.MaxTCPServiceAttempts > 0 && config.MaxTCPServiceAttempts <= MaximumTCPServiceAttempts && + uint64(config.MaxQueries)*uint64(config.MaxTCPResponseBytes) <= MaximumAggregateTCPResponseBytes return validResolver(config.Server) && config.MaxRecords > 0 && config.MaxResponseBytes >= lnetodns.MaxSizeUDP && config.MaxResponseBytes <= mtu-28 && - config.MaxResponseBytes <= int(^uint16(0)) && config.MaxAttempts > 0 && config.RetryServiceAttempts > 0 + config.MaxResponseBytes <= int(^uint16(0)) && config.MaxAttempts > 0 && config.RetryServiceAttempts > 0 && (tcpDisabled || tcpEnabled) } func validResolver(address netip.Addr) bool { diff --git a/internal/backend/lneto/dns/dns_test.go b/internal/backend/lneto/dns/dns_test.go index 7261df0..2a7b7a2 100644 --- a/internal/backend/lneto/dns/dns_test.go +++ b/internal/backend/lneto/dns/dns_test.go @@ -4,8 +4,10 @@ import ( "bytes" "encoding/binary" "errors" + "fmt" "net/netip" "reflect" + "runtime" "strings" "sync" "testing" @@ -16,7 +18,9 @@ import ( "github.com/soypat/lneto/ipv4" lnetoudp "github.com/soypat/lneto/udp" lnetocore "github.com/wago-org/net/internal/backend/lneto/core" + tcpbackend "github.com/wago-org/net/internal/backend/lneto/tcp" "github.com/wago-org/net/internal/namespace" + tcpns "github.com/wago-org/net/internal/namespace/tcp" "github.com/wago-org/net/internal/packetlink" "github.com/wago-org/net/internal/policy" "github.com/wago-org/net/internal/quota" @@ -32,6 +36,33 @@ func TestConfigRejectsNonWireResolvers(t *testing.T) { if !ValidConfig(base, 1500, compiled, account, true) { t.Fatal("valid unicast resolver rejected") } + fallback := base + fallback.MaxTCPResponseBytes = 16 << 10 + fallback.MaxTCPServiceAttempts = 128 + if !ValidConfig(fallback, 1500, compiled, account, true) { + t.Fatal("valid bounded TCP fallback rejected") + } + for name, mutate := range map[string]func(*Config){ + "bytes without attempts": func(config *Config) { config.MaxTCPResponseBytes = 512 }, + "attempts without bytes": func(config *Config) { config.MaxTCPServiceAttempts = 1 }, + "response too short": func(config *Config) { + config.MaxTCPResponseBytes, config.MaxTCPServiceAttempts = 11, 1 + }, + "too many attempts": func(config *Config) { + config.MaxTCPResponseBytes, config.MaxTCPServiceAttempts = 512, MaximumTCPServiceAttempts+1 + }, + "aggregate response retention": func(config *Config) { + config.MaxQueries, config.MaxTCPResponseBytes, config.MaxTCPServiceAttempts = 1025, MaximumTCPResponseBytes, 1 + }, + } { + t.Run(name, func(t *testing.T) { + invalid := base + mutate(&invalid) + if ValidConfig(invalid, 1500, compiled, account, true) { + t.Fatalf("invalid TCP fallback accepted: %+v", invalid) + } + }) + } for name, server := range map[string]netip.Addr{ "loopback": netip.MustParseAddr("127.0.0.1"), "multicast": netip.MustParseAddr("224.0.0.251"), @@ -135,6 +166,498 @@ func TestBuildDNSQueryPacketDirectEncoding(t *testing.T) { } } +func TestDNSTruncatedUDPUsesBoundedPrivateTCPFallback(t *testing.T) { + clientAddress := netip.MustParseAddr("192.0.2.61") + serverAddress := netip.MustParseAddr("192.0.2.53") + clientMAC := [6]byte{0x02, 0, 0, 0, 0, 61} + serverMAC := [6]byte{0x02, 0, 0, 0, 0, 53} + clientPolicy, err := policy.Compile(policy.Config{Rules: []policy.Rule{{ + Action: policy.ActionAllow, Transports: []policy.Transport{policy.TransportDNS}, + Directions: []policy.Direction{policy.DirectionOutbound}, DNSSuffixes: []string{"example.com"}, + }}}) + if err != nil { + t.Fatal(err) + } + serverPolicy, err := policy.Compile(policy.Config{ + Rules: []policy.Rule{{ + Action: policy.ActionAllow, Transports: []policy.Transport{policy.TransportTCP}, + Directions: []policy.Direction{policy.DirectionInbound}, Prefixes: []netip.Prefix{netip.PrefixFrom(serverAddress, 32)}, + Ports: []policy.PortRange{{First: lnetodns.ServerPort, Last: lnetodns.ServerPort}}, + }}, + PrivilegedBindTransports: []policy.Transport{policy.TransportTCP}, + }) + if err != nil { + t.Fatal(err) + } + limits := quota.Limits{Resources: 16, TCPResources: 8, DNSResources: 4, QueuedBytes: 1 << 20, DNSWork: 8} + clientAccount := quota.NewAccount(limits) + serverAccount := quota.NewAccount(limits) + mtu := uint16(ethernet.MaxMTU) + newCore := func(hostname string, seed int64, address netip.Addr, hardware, gateway [6]byte, compiled *policy.Policy, account *quota.Account) *lnetocore.Namespace { + core, err := lnetocore.New(lnetocore.Config{ + Hostname: hostname, RandSeed: seed, HardwareAddress: hardware, GatewayHardwareAddress: gateway, + IPv4Address: address, MTU: mtu, MaxActiveTCPPorts: 4, Policy: compiled, Quotas: account, + Link: packetlink.Config{MaxFrameBytes: int(mtu) + 14, IngressFrames: 32, EgressFrames: 32}, + }) + if err != nil { + t.Fatal(err) + } + return core + } + clientCore := newCore("dns-tcp-client", 61, clientAddress, clientMAC, serverMAC, clientPolicy, clientAccount) + serverCore := newCore("dns-tcp-server", 53, serverAddress, serverMAC, clientMAC, serverPolicy, serverAccount) + t.Cleanup(func() { + _ = clientCore.Close() + _ = serverCore.Close() + }) + dnsConfig := Config{ + Server: serverAddress, MaxQueries: 2, MaxRecords: 4, MaxResponseBytes: 512, + MaxAttempts: 1, RetryServiceAttempts: 2, MaxTCPResponseBytes: 2048, MaxTCPServiceAttempts: 512, + } + clientDNS, err := New(clientCore, dnsConfig) + if err != nil { + t.Fatal(err) + } + serverTCP, err := tcpbackend.New(serverCore, tcpbackend.Config{ + MaxListeners: 1, AcceptBacklog: 1, ReceiveBytes: 4 << 10, TransmitBytes: 4 << 10, TransmitPackets: 8, + }) + if err != nil { + t.Fatal(err) + } + listenerValue, progress, err := serverTCP.TryListen(namespace.Endpoint{Address: serverAddress, Port: lnetodns.ServerPort}) + if err != nil || progress != namespace.ProgressDone { + t.Fatalf("TCP DNS listen = %T, %v, %v", listenerValue, progress, err) + } + listener := listenerValue.(tcpns.Listener) + request := namespace.DNSRequest{Name: "example.com", Types: namespace.DNSRecordsA | namespace.DNSRecordsAAAA} + queryValue, progress, err := clientDNS.TryResolve(request) + if err != nil || progress != namespace.ProgressInProgress { + t.Fatalf("DNS query = %T, %v, %v", queryValue, progress, err) + } + query := queryValue.(*dnsQuery) + clientNS := &testNamespace{core: clientCore, adapter: clientDNS, requiredFrameBytes: int(mtu) + 14} + udpQuery := serviceDNSPacket(t, clientNS) + txid, localPort := dnsPacketIdentity(t, udpQuery) + questionName := lnetodns.MustNewName(request.Name) + truncatedMessage := lnetodns.Message{Questions: []lnetodns.Question{ + {Name: questionName, Type: lnetodns.TypeA, Class: lnetodns.ClassINET}, + {Name: questionName, Type: lnetodns.TypeAAAA, Class: lnetodns.ClassINET}, + }} + responseConfig := namespaceTestConfig{ + HardwareAddress: clientMAC, GatewayHardwareAddress: serverMAC, IPv4Address: clientAddress, DNS: dnsConfig, + } + truncated := buildDNSFrame(t, responseConfig, txid, localPort, truncatedMessage, lnetodns.HeaderFlags(1<<15|1<<9|1<<8|1<<7)) + serviceDNSIngressFrame(t, clientNS, truncated) + if query.state != dnsQueryTCPConnecting || query.localPort != 0 || query.tcpStream == nil { + t.Fatalf("fallback transition = state:%v port:%d stream:%T", query.state, query.localPort, query.tcpStream) + } + + var serverStream tcpns.Stream + var requestWire, responseWire []byte + responseOffset := 0 + for attempt := 0; attempt < 20000 && query.state != dnsQueryDone; attempt++ { + relayDNSCore(t, clientCore, serverCore) + relayDNSCore(t, serverCore, clientCore) + if serverStream == nil && listener.Readiness()&namespace.ReadyAccept != 0 { + accepted, acceptProgress, acceptErr := listener.TryAccept() + if acceptErr != nil || acceptProgress != namespace.ProgressDone { + t.Fatalf("TCP DNS accept = %T, %v, %v", accepted, acceptProgress, acceptErr) + } + serverStream = accepted.(tcpns.Stream) + } + if serverStream != nil { + buffer := make([]byte, 257) + result, readErr := serverStream.TryRead(buffer) + if readErr != nil { + t.Fatal(readErr) + } + requestWire = append(requestWire, buffer[:result.Bytes]...) + if responseWire == nil && len(requestWire) >= 2 { + requestBytes := int(binary.BigEndian.Uint16(requestWire[:2])) + if len(requestWire) >= 2+requestBytes { + if got := binary.BigEndian.Uint16(requestWire[2:4]); got != txid { + t.Fatalf("TCP query txid = %d, want %d", got, txid) + } + udpResponse := buildDNSResponseFrame(t, responseConfig, txid, localPort, request.Name) + ethernetFrame, _ := ethernet.NewFrame(udpResponse) + ipFrame, _ := ipv4.NewFrame(ethernetFrame.Payload()) + udpFrame, _ := lnetoudp.NewFrame(ipFrame.Payload()) + payload := append([]byte(nil), udpFrame.RawData()[8:udpFrame.Length()]...) + responseWire = make([]byte, 2+len(payload)) + binary.BigEndian.PutUint16(responseWire[:2], uint16(len(payload))) + copy(responseWire[2:], payload) + } + } + if responseOffset < len(responseWire) { + result, writeErr := serverStream.TryWrite(responseWire[responseOffset:]) + if writeErr != nil { + t.Fatal(writeErr) + } + responseOffset += result.Bytes + } + } + runtime.Gosched() + } + if query.state != dnsQueryDone || query.tcpStream != nil || query.tcpResponse != nil || query.txid != 0 { + t.Fatalf("TCP fallback completion = state:%v stream:%T response:%d txid:%d failure:%v", query.state, query.tcpStream, len(query.tcpResponse), query.txid, query.failure) + } + var records []namespace.DNSRecord + for { + record, next, err := query.TryNext() + if err != nil { + t.Fatal(err) + } + if next == namespace.DNSNextEOF { + break + } + if next != namespace.DNSNextReady { + t.Fatalf("TCP DNS next = %v", next) + } + records = append(records, record) + } + if len(records) != 3 { + t.Fatalf("TCP fallback records = %+v", records) + } + if err := query.Close(); err != nil { + t.Fatal(err) + } + if serverStream != nil { + _ = serverStream.Close() + } + if err := listener.Close(); err != nil { + t.Fatal(err) + } + if usage, _ := clientAccount.Snapshot(); usage != (quota.Usage{}) { + t.Fatalf("client fallback quota = %+v", usage) + } + if usage, _ := serverAccount.Snapshot(); usage != (quota.Usage{}) { + t.Fatalf("server fallback quota = %+v", usage) + } +} + +func TestDNSQueryReusesClearedPacketAndInlineRecordStorage(t *testing.T) { + config := dnsTestConfig(t, 60) + config.DNS.MaxQueries = 2 + config.DNS.MaxRecords = inlineDNSRecordCapacity + ns := newTestNamespace(t, config) + request := namespace.DNSRequest{Name: "example.com", Types: namespace.DNSRecordsA} + value, _, err := ns.TryResolve(request) + if err != nil { + t.Fatal(err) + } + first := value.(*dnsQuery) + accounting := first.accounting + packetStorage := first.packetStorage + recordStorage := first.recordStorage + first.records = first.recordStorage[:1] + first.records[0] = namespace.DNSRecord{Name: request.Name, Type: namespace.DNSRecordA, TTLSeconds: 60, Address: netip.MustParseAddr("192.0.2.60")} + if err := first.Close(); err != nil { + t.Fatal(err) + } + if first.accounting != nil || first.packetStorage != nil || first.recordStorage != nil || ns.adapter.freeAccounting == nil || ns.adapter.freePacket == nil || ns.adapter.freeRecordInline == nil { + t.Fatalf("closed query storage = accounting:%p packet:%p records:%p free-accounting:%p free-packet:%p free-records:%p", first.accounting, first.packetStorage, first.recordStorage, ns.adapter.freeAccounting, ns.adapter.freePacket, ns.adapter.freeRecordInline) + } + if ns.adapter.freeAccounting != accounting { + t.Fatalf("recycled accounting = got:%p want:%p", ns.adapter.freeAccounting, accounting) + } + if ns.adapter.freePacket != packetStorage || ns.adapter.freePacket[0] != 0 { + t.Fatalf("cleared packet storage = stored:%p want:%p first:%d", ns.adapter.freePacket, packetStorage, ns.adapter.freePacket[0]) + } + if ns.adapter.freeRecordInline != recordStorage || ns.adapter.freeRecordInline[0] != (namespace.DNSRecord{}) { + t.Fatalf("cleared inline storage = stored:%p want:%p record:%+v", ns.adapter.freeRecordInline, recordStorage, ns.adapter.freeRecordInline[0]) + } + value, _, err = ns.TryResolve(request) + if err != nil { + t.Fatal(err) + } + second := value.(*dnsQuery) + if second.accounting != accounting || second.packetStorage != packetStorage || second.recordStorage != recordStorage || ns.adapter.freeAccounting != nil || ns.adapter.freePacket != nil || ns.adapter.freeRecordInline != nil { + t.Fatalf("reused query storage = accounting:%p want:%p packet:%p want:%p records:%p want:%p free-accounting:%p free-packet:%p free-records:%p", second.accounting, accounting, second.packetStorage, packetStorage, second.recordStorage, recordStorage, ns.adapter.freeAccounting, ns.adapter.freePacket, ns.adapter.freeRecordInline) + } + if err := second.Close(); err != nil { + t.Fatal(err) + } + ns.core.Lock() + ns.adapter.CloseLocked() + ns.core.Unlock() + if ns.adapter.freeAccounting != nil || ns.adapter.freePacket != nil || ns.adapter.freeRecordInline != nil { + t.Fatalf("namespace close retained query storage accounting=%p packet=%p records=%p", ns.adapter.freeAccounting, ns.adapter.freePacket, ns.adapter.freeRecordInline) + } +} + +func TestDNSRecordOverflowCacheHasOneFiniteCeiling(t *testing.T) { + for index, maxRecords := range []uint16{maximumCachedDNSRecordOverflow, maximumCachedDNSRecordOverflow + 1} { + t.Run(fmt.Sprintf("records=%d", maxRecords), func(t *testing.T) { + config := dnsTestConfig(t, byte(90+index)) + config.DNS.MaxQueries = 2 + config.DNS.MaxRecords = maxRecords + config.Quotas = quota.NewAccount(quota.Limits{Resources: 4, DNSResources: 4, QueuedBytes: 1 << 20, DNSWork: 4}) + ns := newTestNamespace(t, config) + request := namespace.DNSRequest{Name: "example.com", Types: namespace.DNSRecordsA} + for range 2 { + value, _, err := ns.TryResolve(request) + if err != nil { + t.Fatal(err) + } + if err := value.Close(); err != nil { + t.Fatal(err) + } + } + if maxRecords <= maximumCachedDNSRecordOverflow { + if cap(ns.adapter.freeRecordOverflow) != int(maxRecords) { + t.Fatalf("cached overflow capacity = %d, want %d", cap(ns.adapter.freeRecordOverflow), maxRecords) + } + } else if ns.adapter.freeRecordOverflow != nil { + t.Fatalf("oversized overflow capacity %d was retained", cap(ns.adapter.freeRecordOverflow)) + } + }) + } +} + +func TestDNSTCPFallbackDefersResponseStorageUntilCorrelatedTruncation(t *testing.T) { + config := dnsTestConfig(t, 61) + config.MaxActiveTCPPorts = 1 + config.DNS.MaxTCPResponseBytes = 16 << 10 + config.DNS.MaxTCPServiceAttempts = 32 + baseRetained := dnsRetainedBytes(config.DNS) + tcpStorage := uint64(dnsTCPReceiveBytes + dnsTCPTransmitBytes) + config.Quotas = quota.NewAccount(quota.Limits{ + Resources: 4, TCPResources: 2, DNSResources: 2, + QueuedBytes: baseRetained + tcpStorage, DNSWork: 4, + }) + ns := newTestNamespace(t, config) + if got, want := len(ns.adapter.candidates), config.DNS.MaxResponseBytes/11; got != want { + t.Fatalf("eager parser candidate storage = %d, want UDP-only bound %d", got, want) + } + if got, want := len(ns.adapter.names), 2*(config.DNS.MaxResponseBytes/11); got != want { + t.Fatalf("eager parser name storage = %d, want UDP-only bound %d", got, want) + } + largeTCPResponse := make([]byte, 2048) + binary.BigEndian.PutUint16(largeTCPResponse[6:8], 60) + candidates, names := ns.adapter.parserScratchLocked(largeTCPResponse) + if len(candidates) != 60 || len(names) != 120 || len(ns.adapter.candidates) != config.DNS.MaxResponseBytes/11 { + t.Fatalf("temporary TCP parser scratch = candidates:%d names:%d eager:%d", len(candidates), len(names), len(ns.adapter.candidates)) + } + request := namespace.DNSRequest{Name: "example.com", Types: namespace.DNSRecordsA | namespace.DNSRecordsAAAA} + value, _, err := ns.TryResolve(request) + if err != nil { + t.Fatal(err) + } + query := value.(*dnsQuery) + if query.tcpResponse != nil { + t.Fatal("resolve eagerly allocated TCP fallback response storage") + } + if usage, _ := config.Quotas.Snapshot(); usage.QueuedBytes != baseRetained || usage.DNSWork != 2 { + t.Fatalf("pre-fallback quota = %+v, want queued=%d work=2", usage, baseRetained) + } + outgoing := serviceDNSPacket(t, ns) + txid, localPort := dnsPacketIdentity(t, outgoing) + name := lnetodns.MustNewName(request.Name) + truncated := buildDNSFrame(t, config, txid, localPort, lnetodns.Message{Questions: []lnetodns.Question{ + {Name: name, Type: lnetodns.TypeA, Class: lnetodns.ClassINET}, + {Name: name, Type: lnetodns.TypeAAAA, Class: lnetodns.ClassINET}, + }}, lnetodns.HeaderFlags(1<<15|1<<9|1<<8|1<<7)) + serviceDNSIngressFrame(t, ns, truncated) + if query.state != dnsQueryTCPConnecting || len(query.tcpResponse) != config.DNS.MaxTCPResponseBytes || query.tcpStream == nil { + t.Fatalf("started fallback = state:%v response:%d stream:%T", query.state, len(query.tcpResponse), query.tcpStream) + } + if usage, _ := config.Quotas.Snapshot(); usage.QueuedBytes != baseRetained+tcpStorage || usage.DNSWork != 2 || usage.TCPResources != 1 { + t.Fatalf("active fallback quota = %+v", usage) + } + if err := query.Cancel(); err != nil { + t.Fatal(err) + } + if query.tcpResponse != nil || query.tcpStream != nil { + t.Fatalf("canceled fallback retained response=%d stream=%T", len(query.tcpResponse), query.tcpStream) + } + if usage, _ := config.Quotas.Snapshot(); usage.QueuedBytes != baseRetained || usage.DNSWork != 0 || usage.TCPResources != 0 { + t.Fatalf("canceled fallback quota = %+v, want only query retention", usage) + } + if err := query.Close(); err != nil { + t.Fatal(err) + } + if usage, _ := config.Quotas.Snapshot(); usage != (quota.Usage{}) { + t.Fatalf("closed fallback retained quota = %+v", usage) + } +} + +func TestDNSTCPFallbackHonorsRawTCPDenyWithoutLeakingTransport(t *testing.T) { + config := dnsTestConfig(t, 62) + config.MaxActiveTCPPorts = 2 + config.DNS.MaxTCPResponseBytes = 2048 + config.DNS.MaxTCPServiceAttempts = 32 + config.Quotas = quota.NewAccount(quota.Limits{Resources: 8, TCPResources: 4, DNSResources: 4, QueuedBytes: 1 << 20, DNSWork: 8}) + compiled, err := policy.Compile(policy.Config{Rules: []policy.Rule{ + {Action: policy.ActionAllow, Transports: []policy.Transport{policy.TransportDNS}, Directions: []policy.Direction{policy.DirectionOutbound}, DNSSuffixes: []string{"example.com"}}, + {Action: policy.ActionDeny, Transports: []policy.Transport{policy.TransportTCP}, Directions: []policy.Direction{policy.DirectionOutbound}, Prefixes: []netip.Prefix{netip.PrefixFrom(config.DNS.Server, 32)}, Ports: []policy.PortRange{{First: lnetodns.ServerPort, Last: lnetodns.ServerPort}}}, + }}) + if err != nil { + t.Fatal(err) + } + config.Policy = compiled + ns := newTestNamespace(t, config) + request := namespace.DNSRequest{Name: "example.com", Types: namespace.DNSRecordsA | namespace.DNSRecordsAAAA} + value, _, err := ns.TryResolve(request) + if err != nil { + t.Fatal(err) + } + query := value.(*dnsQuery) + outgoing := serviceDNSPacket(t, ns) + txid, localPort := dnsPacketIdentity(t, outgoing) + questionName := lnetodns.MustNewName(request.Name) + truncatedMessage := lnetodns.Message{Questions: []lnetodns.Question{ + {Name: questionName, Type: lnetodns.TypeA, Class: lnetodns.ClassINET}, + {Name: questionName, Type: lnetodns.TypeAAAA, Class: lnetodns.ClassINET}, + }} + truncated := buildDNSFrame(t, config, txid, localPort, truncatedMessage, lnetodns.HeaderFlags(1<<15|1<<9|1<<8|1<<7)) + serviceDNSIngressFrame(t, ns, truncated) + if query.state != dnsQueryFailed || requireFailure(t, query.failure) != namespace.FailureAccessDenied || query.localPort != 0 || query.tcpStream != nil { + t.Fatalf("denied fallback = state:%v failure:%v port:%d stream:%T", query.state, query.failure, query.localPort, query.tcpStream) + } + ns.core.Lock() + leases := ns.core.TCPPortLeaseCountLocked() + ns.core.Unlock() + if leases != 0 { + t.Fatalf("denied fallback retained %d TCP leases", leases) + } + if err := query.Close(); err != nil { + t.Fatal(err) + } + if usage, _ := config.Quotas.Snapshot(); usage != (quota.Usage{}) { + t.Fatalf("denied fallback retained quota = %+v", usage) + } +} + +func TestDNSTCPFallbackCancellationClosesPrivateStreamAndClearsRetention(t *testing.T) { + config := dnsTestConfig(t, 63) + config.MaxActiveTCPPorts = 2 + config.DNS.MaxTCPResponseBytes = 2048 + config.DNS.MaxTCPServiceAttempts = 32 + config.Quotas = quota.NewAccount(quota.Limits{Resources: 8, TCPResources: 4, DNSResources: 4, QueuedBytes: 1 << 20, DNSWork: 8}) + ns := newTestNamespace(t, config) + request := namespace.DNSRequest{Name: "example.com", Types: namespace.DNSRecordsA | namespace.DNSRecordsAAAA} + value, _, err := ns.TryResolve(request) + if err != nil { + t.Fatal(err) + } + query := value.(*dnsQuery) + outgoing := serviceDNSPacket(t, ns) + txid, localPort := dnsPacketIdentity(t, outgoing) + name := lnetodns.MustNewName(request.Name) + truncated := buildDNSFrame(t, config, txid, localPort, lnetodns.Message{Questions: []lnetodns.Question{ + {Name: name, Type: lnetodns.TypeA, Class: lnetodns.ClassINET}, + {Name: name, Type: lnetodns.TypeAAAA, Class: lnetodns.ClassINET}, + }}, lnetodns.HeaderFlags(1<<15|1<<9|1<<8|1<<7)) + serviceDNSIngressFrame(t, ns, truncated) + if query.state != dnsQueryTCPConnecting || query.tcpStream == nil { + t.Fatalf("fallback before cancel = state:%v stream:%T", query.state, query.tcpStream) + } + if err := query.Cancel(); err != nil { + t.Fatal(err) + } + if query.state != dnsQueryFailed || requireFailure(t, query.failure) != namespace.FailureCanceled || query.tcpStream != nil || query.txid != 0 { + t.Fatalf("canceled fallback = state:%v failure:%v stream:%T txid:%d", query.state, query.failure, query.tcpStream, query.txid) + } + if !bytes.Equal(query.tcpResponse, make([]byte, len(query.tcpResponse))) { + t.Fatal("canceled fallback retained response bytes") + } + ns.core.Lock() + leases := ns.core.TCPPortLeaseCountLocked() + ns.core.Unlock() + if leases != 0 { + t.Fatalf("canceled fallback retained %d TCP leases", leases) + } + if err := query.Close(); err != nil { + t.Fatal(err) + } + if usage, _ := config.Quotas.Snapshot(); usage != (quota.Usage{}) { + t.Fatalf("canceled fallback retained quota = %+v", usage) + } +} + +func TestDNSTCPFallbackBoundsLengthTimeoutAndEOFCleanup(t *testing.T) { + for _, test := range []struct { + name string + prepare func(*dnsQuery, *scriptedLockedTCPStream) + want namespace.Failure + }{ + { + name: "oversized response", + prepare: func(query *dnsQuery, stream *scriptedLockedTCPStream) { + query.state = dnsQueryTCPReadingLength + stream.reads = [][]byte{{0x08, 0x01}} + }, + want: namespace.FailureMessageTooLarge, + }, + { + name: "service timeout", + prepare: func(query *dnsQuery, _ *scriptedLockedTCPStream) { + query.state = dnsQueryTCPConnecting + query.tcpServiceAttempts = query.owner.config.MaxTCPServiceAttempts + }, + want: namespace.FailureTimedOut, + }, + { + name: "premature EOF", + prepare: func(query *dnsQuery, stream *scriptedLockedTCPStream) { + query.state = dnsQueryTCPReadingLength + stream.eof = true + }, + want: namespace.FailureTemporary, + }, + } { + t.Run(test.name, func(t *testing.T) { + stream := &scriptedLockedTCPStream{} + query := &dnsQuery{ + owner: &Adapter{config: Config{MaxTCPResponseBytes: 2048, MaxTCPServiceAttempts: 4}}, + tcpStream: stream, tcpResponse: make([]byte, 2048), txid: 1, + } + test.prepare(query, stream) + if err := query.serviceTCPFallbackLocked(); err != nil { + t.Fatal(err) + } + if query.state != dnsQueryFailed || requireFailure(t, query.failure) != test.want || query.tcpStream != nil || stream.closeCalls != 1 { + t.Fatalf("bounded fallback = state:%v failure:%v stream:%T closes:%d", query.state, query.failure, query.tcpStream, stream.closeCalls) + } + if !bytes.Equal(query.tcpResponse, make([]byte, len(query.tcpResponse))) { + t.Fatal("failed fallback retained response bytes") + } + }) + } +} + +type scriptedLockedTCPStream struct { + reads [][]byte + eof bool + closeCalls int +} + +func (stream *scriptedLockedTCPStream) TryFinishConnectLocked() (namespace.Progress, error) { + return namespace.ProgressInProgress, nil +} + +func (stream *scriptedLockedTCPStream) TryReadLocked(dst []byte) (namespace.IOResult, error) { + if len(stream.reads) != 0 { + value := stream.reads[0] + stream.reads = stream.reads[1:] + count := copy(dst, value) + return namespace.IOResult{Bytes: count, State: namespace.IOReady}, nil + } + if stream.eof { + return namespace.IOResult{State: namespace.IOEOF}, nil + } + return namespace.IOResult{State: namespace.IOWouldBlock}, nil +} + +func (stream *scriptedLockedTCPStream) TryWriteLocked([]byte) (namespace.IOResult, error) { + return namespace.IOResult{State: namespace.IOWouldBlock}, nil +} + +func (stream *scriptedLockedTCPStream) CloseLocked() error { + stream.closeCalls++ + return nil +} + func TestBuildDNSQueryPacketIntoUsesCallerStorage(t *testing.T) { request := namespace.DNSRequest{Name: "service.api.example.com", Types: namespace.DNSRecordsA | namespace.DNSRecordsAAAA} var storage [dnsQueryPacketCapacity]byte @@ -339,7 +862,8 @@ func TestDNSBoundedQueryRecordsAndQuotaLifecycle(t *testing.T) { if usage.DNSWork != 0 || usage.Resources != 1 || usage.DNSResources != 1 || usage.QueuedBytes == 0 { t.Fatalf("completed quota = %+v", usage) } - workReset := query.work.ResetReleased() + accounting := query.accounting + workReset := accounting.work.ResetReleased() if workReset { t.Fatalf("completed query retained work graph state: reset=%v", workReset) } @@ -349,9 +873,9 @@ func TestDNSBoundedQueryRecordsAndQuotaLifecycle(t *testing.T) { if usage, _ := config.Quotas.Snapshot(); usage != (quota.Usage{}) { t.Fatalf("closed query retained quota = %+v", usage) } - retainedReset := query.retained.ResetReleased() - workReset = query.work.ResetReleased() - if retainedReset || workReset || query.request != (namespace.DNSRequest{}) || query.packet != nil || query.records != nil || query.failure != nil || query.cursor != 0 { + retainedReset := accounting.retained.ResetReleased() + workReset = accounting.work.ResetReleased() + if retainedReset || workReset || query.accounting != nil || query.request != (namespace.DNSRequest{}) || query.packet != nil || query.records != nil || query.failure != nil || query.cursor != 0 { t.Fatalf("closed query retained graph state: retained_reset=%v work_reset=%v request=%+v packet=%v records=%v failure=%v cursor=%d", retainedReset, workReset, query.request, query.packet != nil, query.records != nil, query.failure, query.cursor) } if got := query.Readiness(); got != namespace.ReadyClosed { @@ -1305,6 +1829,7 @@ func TestDNSConcurrentOperationsAndNamespaceClose(t *testing.T) { type namespaceTestConfig struct { Hostname string RandSeed int64 + MaxActiveTCPPorts uint16 HardwareAddress [6]byte GatewayHardwareAddress [6]byte IPv4Address netip.Addr @@ -1326,7 +1851,7 @@ func newTestNamespace(t testing.TB, config namespaceTestConfig) *testNamespace { common, err := lnetocore.New(lnetocore.Config{ Hostname: config.Hostname, RandSeed: config.RandSeed, HardwareAddress: config.HardwareAddress, GatewayHardwareAddress: config.GatewayHardwareAddress, - IPv4Address: config.IPv4Address, MTU: config.MTU, Link: config.Link, + IPv4Address: config.IPv4Address, MTU: config.MTU, MaxActiveTCPPorts: config.MaxActiveTCPPorts, Link: config.Link, Policy: config.Policy, Quotas: config.Quotas, }) if err != nil { @@ -1397,6 +1922,40 @@ func dnsTestConfig(t testing.TB, id byte) namespaceTestConfig { return config } +func relayDNSCore(t testing.TB, from, to *lnetocore.Namespace) bool { + t.Helper() + from.Lock() + from.SetNextIngressLocked(false) + required := from.RequiredFrameBytesLocked() + from.Unlock() + budget := namespace.ServiceBudget{Packets: 1, Bytes: uint32(required), Operations: 1} + report, progress, err := from.TryService(budget) + if err != nil || !report.ValidResult(budget, progress) { + t.Fatalf("DNS TCP egress service = %+v, %v, %v", report, progress, err) + } + if report.Packets == 0 { + return false + } + frame := make([]byte, from.Link().MaxFrameBytes()) + result, err := from.Link().TryDequeue(packetlink.Egress, frame) + if err != nil || !result.Ready || result.Truncated || result.FrameBytes == 0 { + t.Fatalf("DNS TCP egress dequeue = %+v, %v", result, err) + } + if err := to.Link().TryEnqueue(packetlink.Ingress, frame[:result.FrameBytes]); err != nil { + t.Fatal(err) + } + to.Lock() + to.SetNextIngressLocked(true) + required = to.RequiredFrameBytesLocked() + to.Unlock() + budget = namespace.ServiceBudget{Packets: 1, Bytes: uint32(required), Operations: 1} + report, progress, err = to.TryService(budget) + if err != nil || report.Packets != 1 || !report.ValidResult(budget, progress) { + t.Fatalf("DNS TCP ingress service = %+v, %v, %v", report, progress, err) + } + return true +} + func serviceDNSPacket(t testing.TB, ns *testNamespace) []byte { t.Helper() setNextIngress(ns, false) diff --git a/internal/backend/lneto/namespace.go b/internal/backend/lneto/namespace.go index c4307e9..6d6be62 100644 --- a/internal/backend/lneto/namespace.go +++ b/internal/backend/lneto/namespace.go @@ -103,6 +103,7 @@ func New(config Config) (*Namespace, error) { } func coreConfig(config Config) lnetocore.Config { + activeTCPPorts, _ := aggregateActiveTCPPorts(config) return lnetocore.Config{ Hostname: config.Hostname, RandSeed: config.RandSeed, @@ -111,7 +112,7 @@ func coreConfig(config Config) lnetocore.Config { IPv4Address: config.IPv4Address, MTU: config.MTU, Link: config.Link, - MaxActiveTCPPorts: config.TCP.MaxListeners + config.TCP.MaxOutboundStreams, + MaxActiveTCPPorts: activeTCPPorts, Policy: config.Policy, Quotas: config.Quotas, } @@ -178,6 +179,9 @@ func (n *Namespace) TryService(budget nscore.ServiceBudget) (nscore.ServiceRepor } func validConfig(config Config, requireAuthority bool) bool { + if _, ok := aggregateActiveTCPPorts(config); !ok { + return false + } if tcpbackend.ValidConfig(config.TCP, config.Policy, config.Quotas, requireAuthority) == false || udpbackend.ValidConfig(config.UDP, int(config.MTU), config.Policy, config.Quotas, requireAuthority) == false || dnsbackend.ValidConfig(config.DNS, int(config.MTU), config.Policy, config.Quotas, requireAuthority) == false { @@ -186,4 +190,15 @@ func validConfig(config Config, requireAuthority bool) bool { return lnetocore.ValidateConfig(coreConfig(config)) == nil } +func aggregateActiveTCPPorts(config Config) (uint16, bool) { + ports := uint32(config.TCP.MaxListeners) + uint32(config.TCP.MaxOutboundStreams) + if config.DNS.MaxTCPResponseBytes != 0 && config.DNS.MaxTCPServiceAttempts != 0 { + ports += uint32(config.DNS.MaxQueries) + } + if ports > uint32(^uint16(0)) { + return 0, false + } + return uint16(ports), true +} + func mapError(err error) error { return lnetocore.MapError(err) } diff --git a/internal/backend/lneto/namespace_test.go b/internal/backend/lneto/namespace_test.go index a6cecaa..3a3d5bb 100644 --- a/internal/backend/lneto/namespace_test.go +++ b/internal/backend/lneto/namespace_test.go @@ -17,6 +17,20 @@ import ( "github.com/wago-org/net/internal/quota" ) +func TestAggregateTCPPortCapacityIncludesPrivateDNSTCPFallback(t *testing.T) { + config := Config{ + TCP: TCPConfig{MaxListeners: 2, MaxOutboundStreams: 3}, + DNS: DNSConfig{MaxQueries: 4, MaxTCPResponseBytes: 2048, MaxTCPServiceAttempts: 32}, + } + if got, ok := aggregateActiveTCPPorts(config); !ok || got != 9 { + t.Fatalf("aggregate active TCP ports = %d, %v", got, ok) + } + config.TCP.MaxListeners = ^uint16(0) + if got, ok := aggregateActiveTCPPorts(config); ok || got != 0 { + t.Fatalf("overflowed aggregate active TCP ports = %d, %v", got, ok) + } +} + func TestNamespacesExchangePacketsDeterministically(t *testing.T) { aConfig := testConfig(1) bConfig := testConfig(2) diff --git a/internal/backend/lneto/tcp/tcp.go b/internal/backend/lneto/tcp/tcp.go index 84ab8e0..206bfd0 100644 --- a/internal/backend/lneto/tcp/tcp.go +++ b/internal/backend/lneto/tcp/tcp.go @@ -644,7 +644,14 @@ func (n *Adapter) TryConnectAuthorized(remote nscore.Endpoint, authorize Connect } n.core.Lock() defer n.core.Unlock() - if n.core.ClosedLocked() || n.stack == nil { + return n.TryConnectAuthorizedLocked(remote, authorize) +} + +// TryConnectAuthorizedLocked is the shared-core-lock variant used by bounded +// protocol participants that privately layer over TCP. The caller must hold +// n's exact core lock for the entire call. +func (n *Adapter) TryConnectAuthorizedLocked(remote nscore.Endpoint, authorize ConnectAuthorizer) (nscore.Resource, nscore.Progress, error) { + if n == nil || n.core == nil || n.core.ClosedLocked() || n.stack == nil { return nil, 0, nscore.Fail(nscore.FailureClosed, net.ErrClosed) } if !remote.Valid() || remote.Address.IsUnspecified() || remote.Port == 0 || (!remote.Address.Is4() && !remote.Address.Is6()) { @@ -900,7 +907,13 @@ func (s *tcpStream) TryFinishConnect() (nscore.Progress, error) { } s.owner.core.Lock() defer s.owner.core.Unlock() - if s.closed || s.owner.core.ClosedLocked() { + return s.TryFinishConnectLocked() +} + +// TryFinishConnectLocked is the shared-core-lock variant for private protocol +// participants. The caller must hold the exact owner core lock. +func (s *tcpStream) TryFinishConnectLocked() (nscore.Progress, error) { + if s == nil || s.owner == nil || s.closed || s.owner.core.ClosedLocked() { return 0, nscore.Fail(nscore.FailureClosed, net.ErrClosed) } if s.terminal || s.conn == nil { @@ -929,7 +942,13 @@ func (s *tcpStream) TryRead(dst []byte) (nscore.IOResult, error) { } s.owner.core.Lock() defer s.owner.core.Unlock() - if s.closed || s.owner.core.ClosedLocked() { + return s.TryReadLocked(dst) +} + +// TryReadLocked is the shared-core-lock variant for private protocol +// participants. The caller must hold the exact owner core lock. +func (s *tcpStream) TryReadLocked(dst []byte) (nscore.IOResult, error) { + if s == nil || s.owner == nil || s.closed || s.owner.core.ClosedLocked() { return nscore.IOResult{}, nscore.Fail(nscore.FailureClosed, net.ErrClosed) } if len(dst) == 0 { @@ -963,7 +982,13 @@ func (s *tcpStream) TryWrite(src []byte) (nscore.IOResult, error) { } s.owner.core.Lock() defer s.owner.core.Unlock() - if s.closed || s.owner.core.ClosedLocked() { + return s.TryWriteLocked(src) +} + +// TryWriteLocked is the shared-core-lock variant for private protocol +// participants. The caller must hold the exact owner core lock. +func (s *tcpStream) TryWriteLocked(src []byte) (nscore.IOResult, error) { + if s == nil || s.owner == nil || s.closed || s.owner.core.ClosedLocked() { return nscore.IOResult{}, nscore.Fail(nscore.FailureClosed, net.ErrClosed) } if s.terminal || s.conn == nil { @@ -1028,6 +1053,14 @@ func (s *tcpStream) Close() error { return s.closeLocked() } +// CloseLocked closes a private stream while its exact shared core lock is held. +func (s *tcpStream) CloseLocked() error { + if s == nil || s.owner == nil { + return nil + } + return s.closeLocked() +} + func (s *tcpStream) closeLocked() error { if s.closed { return nil diff --git a/internal/dependencytest/dependencies_test.go b/internal/dependencytest/dependencies_test.go index 88bddc5..0a29247 100644 --- a/internal/dependencytest/dependencies_test.go +++ b/internal/dependencytest/dependencies_test.go @@ -81,12 +81,15 @@ func TestFixtureDependencyBoundaries(t *testing.T) { } continue } - if protocol == "tcp" && test.selected["tls"] { + if protocol == "tcp" && (test.selected["tls"] || test.selected["dns"]) { if dependencies[dependency.public] || dependencies[dependency.binding] || dependencies[dependency.operation] || dependencies[dependency.abi] { - t.Fatalf("TLS-only reached raw TCP facade: public=%v binding=%v operation=%v ABI=%v", dependencies[dependency.public], dependencies[dependency.binding], dependencies[dependency.operation], dependencies[dependency.abi]) + t.Fatalf("private TCP transport reached raw facade: public=%v binding=%v operation=%v ABI=%v", dependencies[dependency.public], dependencies[dependency.binding], dependencies[dependency.operation], dependencies[dependency.abi]) } - if !dependencies[dependency.namespace] || !dependencies[dependency.adapter] { - t.Fatal("TLS private transport seam is incomplete") + if !dependencies[dependency.adapter] { + t.Fatal("selected private TCP transport omitted the lneto TCP adapter") + } + if test.selected["tls"] && !dependencies[dependency.namespace] { + t.Fatal("TLS private transport seam omitted the TCP namespace facet") } continue } @@ -147,12 +150,15 @@ func TestSelfRegisterPackageDependencyBoundaries(t *testing.T) { } continue } - if protocol == "tcp" && test.selected["tls"] { + if protocol == "tcp" && (test.selected["tls"] || test.selected["dns"]) { if dependencies[dependency.public] || dependencies[dependency.register] || dependencies[dependency.binding] || dependencies[dependency.operation] || dependencies[dependency.abi] { - t.Fatal("TLS self-register graph reached raw TCP facade") + t.Fatal("private TCP self-register graph reached raw TCP facade") + } + if !dependencies[dependency.adapter] { + t.Fatal("private TCP self-register graph omitted the lneto TCP adapter") } - if !dependencies[dependency.namespace] || !dependencies[dependency.adapter] { - t.Fatal("TLS self-register graph omitted private TCP transport") + if test.selected["tls"] && !dependencies[dependency.namespace] { + t.Fatal("TLS self-register graph omitted the TCP namespace facet") } continue } diff --git a/internal/policy/policy.go b/internal/policy/policy.go index a958160..7d05e12 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -237,6 +237,18 @@ func (p *Policy) CheckAddress(operation Operation, address netip.Addr) bool { return p.decide(query{transport: transport, direction: direction, address: address}) } +// AllowsPrivateTCPTransport reports whether a protocol-owned private TCP +// connection remains permitted by raw-TCP deny rules. It deliberately does not +// require a raw-TCP allow rule: the selecting protocol must establish its own +// authority and endpoint-class gates before calling this method. +func (p *Policy) AllowsPrivateTCPTransport(direction Direction, address netip.Addr, port uint16) bool { + if p == nil || (direction != DirectionOutbound && direction != DirectionInbound) || + !address.IsValid() || address.Is4In6() || address.IsUnspecified() || port == 0 { + return false + } + return !p.denied(query{transport: TransportTCP, direction: direction, address: address, port: port, hasPort: true}) +} + func (p *Policy) CheckPortAllocation(operation Operation, address netip.Addr, actualPort uint16) bool { if actualPort == 0 || !p.CheckEndpoint(operation, address, 0) { return false diff --git a/internal/policy/tls_test.go b/internal/policy/tls_test.go index cf2ed62..7a61e09 100644 --- a/internal/policy/tls_test.go +++ b/internal/policy/tls_test.go @@ -47,6 +47,38 @@ func TestTLSServerAuthorityIsDistinctAndHonorsRawTCPDeny(t *testing.T) { } } +func TestPrivateTCPTransportRequiresProtocolAuthorityAndHonorsRawDeny(t *testing.T) { + denied := netip.MustParseAddr("192.0.2.9") + compiled, err := Compile(Config{Rules: []Rule{{ + Action: ActionDeny, Transports: []Transport{TransportTCP}, Directions: []Direction{DirectionOutbound}, + Prefixes: []netip.Prefix{netip.PrefixFrom(denied, 32)}, Ports: []PortRange{{First: 53, Last: 53}}, + }}}) + if err != nil { + t.Fatal(err) + } + if !compiled.AllowsPrivateTCPTransport(DirectionOutbound, netip.MustParseAddr("192.0.2.8"), 53) { + t.Fatal("unmatched private transport was treated as requiring raw-TCP allow authority") + } + if compiled.AllowsPrivateTCPTransport(DirectionOutbound, denied, 53) { + t.Fatal("raw-TCP deny did not constrain private transport") + } + for _, invalid := range []struct { + direction Direction + address netip.Addr + port uint16 + }{ + {Direction(99), netip.MustParseAddr("192.0.2.8"), 53}, + {DirectionOutbound, netip.Addr{}, 53}, + {DirectionOutbound, netip.IPv4Unspecified(), 53}, + {DirectionOutbound, netip.MustParseAddr("::ffff:192.0.2.8"), 53}, + {DirectionOutbound, netip.MustParseAddr("192.0.2.8"), 0}, + } { + if compiled.AllowsPrivateTCPTransport(invalid.direction, invalid.address, invalid.port) { + t.Fatalf("invalid private transport allowed: %+v", invalid) + } + } +} + func TestTLSSpecialClassesRemainTLSScoped(t *testing.T) { compiled, err := Compile(Config{ Rules: []Rule{{Action: ActionAllow, Transports: []Transport{TransportTLS}, Directions: []Direction{DirectionOutbound}}}, diff --git a/net.go b/net.go index 5354d42..b3cfda7 100644 --- a/net.go +++ b/net.go @@ -149,17 +149,20 @@ type TCPConfig struct { } // DNSConfig fixes one static IPv4 recursive resolver plus finite query, -// response, retry, and record-retention bounds. MaxQueries limits live guest -// query handles until close even after a terminal query has already retired its -// transport state. Zero MaxQueries disables DNS operations truthfully while -// leaving the capability-gated module inspectable. +// UDP/TCP response, retry, and record-retention bounds. MaxQueries limits live +// guest query handles until close even after a terminal query has already +// retired its transport state. TCP fallback is disabled unless both TCP fields +// are nonzero. Zero MaxQueries disables DNS operations truthfully while leaving +// the capability-gated module inspectable. type DNSConfig struct { - Server netip.Addr - MaxQueries uint16 - MaxRecords uint16 - MaxResponseBytes int - MaxAttempts uint16 - RetryServiceAttempts uint16 + Server netip.Addr + MaxQueries uint16 + MaxRecords uint16 + MaxResponseBytes int + MaxAttempts uint16 + RetryServiceAttempts uint16 + MaxTCPResponseBytes int + MaxTCPServiceAttempts uint16 } // StaticIPv4Config configures one isolated lneto-backed IPv4 namespace per