Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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)
Expand Down Expand Up @@ -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.

Expand Down
67 changes: 58 additions & 9 deletions agent-todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
71 changes: 56 additions & 15 deletions dns/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}

Expand All @@ -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 {
Expand Down
39 changes: 39 additions & 0 deletions dns/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(&registration{}); !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}
Expand Down
68 changes: 43 additions & 25 deletions dns/register_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
})
}
}

Expand Down
11 changes: 9 additions & 2 deletions docs/abi-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading