mirror of
https://github.com/soypat/lneto.git
synced 2026-07-29 03:58:49 +00:00
Compare commits
26 Commits
dhcp-allocator
...
tls
| Author | SHA1 | Date | |
|---|---|---|---|
| 9758d48696 | |||
| 8873b55e5d | |||
| 41c7e3c445 | |||
| 1bbfd5eac3 | |||
| 1a474154cb | |||
| 347cef9ba5 | |||
| 157a8537a2 | |||
| 766e03e90e | |||
| ab1a0c735a | |||
| 3c1f0e0281 | |||
| 99e9d90a60 | |||
| 4fc53a84cd | |||
| 104a2b0b26 | |||
| e5f8ac0687 | |||
| 3b5b010673 | |||
| 4d6363da00 | |||
| cdc99fb9ff | |||
| 96a02e7d2c | |||
| b795d6a437 | |||
| fa609ea54f | |||
| a42f0b404c | |||
| ef279863a9 | |||
| d5ea2efdf4 | |||
| a0a5336108 | |||
| 28addf329a | |||
| 860d6d00bb |
@@ -0,0 +1,90 @@
|
||||
name: Benchmark Comment
|
||||
|
||||
# Rationale: This more privileged workflow runs in the base repo's
|
||||
# context (with a write token) only after the 'untrusted' CI workflow finishes.
|
||||
# It does NOT check out or execute PR code; it only consumes the benchmark
|
||||
# report artifact as inert, validated data.
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
# Only for PR-triggered CI runs that succeeded to suppress noise
|
||||
if: >
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # needed to comment
|
||||
steps:
|
||||
- name: Download generated benchmark report
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.1
|
||||
with:
|
||||
name: bench-report
|
||||
path: bench-report
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Validate report and upsert PR comment
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
HEAD_OWNER: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
report="bench-report/bench-report.md"
|
||||
marker='<!-- lneto-bench -->'
|
||||
|
||||
# Treat the artifact as untrusted input: it was produced by a job that
|
||||
# ran PR code. Basic validation before posting. TODO: Make more robust.
|
||||
test -f "$report"
|
||||
size="$(wc -c < "$report")"
|
||||
if [ "$size" -le 0 ] || [ "$size" -gt 1000000 ]; then
|
||||
echo "::error::benchmark report has unexpected size: ${size} bytes"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qF "$marker" "$report"; then
|
||||
echo "::error::benchmark report missing marker ${marker}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Prefer the PR number from the workflow_run payload, which ties this
|
||||
# privileged workflow to the PR-triggered CI run. Fall back to the
|
||||
# run's head SHA/branch only when GitHub does not populate it.
|
||||
pr="$PR_NUMBER"
|
||||
if [ -z "$pr" ]; then
|
||||
pr="$(gh api "repos/${REPO}/commits/${HEAD_SHA}/pulls" \
|
||||
--jq 'map(select(.state == "open")) | .[0].number // empty')"
|
||||
fi
|
||||
if [ -z "$pr" ]; then
|
||||
pr="$(gh api --method GET "repos/${REPO}/pulls" \
|
||||
-f state=open \
|
||||
-f head="${HEAD_OWNER}:${HEAD_BRANCH}" \
|
||||
--jq '.[0].number // empty')"
|
||||
fi
|
||||
if [ -z "$pr" ]; then
|
||||
echo "No open PR found for ${HEAD_SHA}; nothing to comment."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
comment_id="$(
|
||||
gh api "repos/${REPO}/issues/${pr}/comments" --paginate \
|
||||
--jq ".[] | select(.body | contains(\"${marker}\")) | .id" | head -n1
|
||||
)"
|
||||
if [ -n "$comment_id" ]; then
|
||||
gh api --method PATCH "repos/${REPO}/issues/comments/${comment_id}" \
|
||||
--input - < <(jq -n --rawfile body "$report" '{body: $body}')
|
||||
else
|
||||
gh api --method POST "repos/${REPO}/issues/${pr}/comments" \
|
||||
--input - < <(jq -n --rawfile body "$report" '{body: $body}')
|
||||
fi
|
||||
+16
-16
@@ -20,9 +20,9 @@ jobs:
|
||||
|
||||
- name: gofmt
|
||||
run: |
|
||||
unformatted=$(gofmt -l .)
|
||||
unformatted=$(gofmt -l -d . 2>&1) || true
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "::error::Files not formatted with gofmt:"
|
||||
echo "::error::File(s) not formatted with gofmt:"
|
||||
echo "$unformatted"
|
||||
exit 1
|
||||
fi
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
needs: [test]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -115,20 +115,20 @@ jobs:
|
||||
- name: Run benchmarks
|
||||
run: go test -bench=. -benchmem -count=5 -shuffle=on -run='^$' -timeout=15m ./... | tee bench-results.txt
|
||||
|
||||
# Fails for a weird reason, will be investigated in the future
|
||||
# - name: Report benchmark regressions
|
||||
# uses: benchmark-action/github-action-benchmark@a887eba2af2000fda74e733fcf952aa823b65916 # v1.21.0
|
||||
# with:
|
||||
# tool: go
|
||||
# output-file-path: bench-results.txt
|
||||
# summary-always: true
|
||||
# comment-on-alert: true
|
||||
# alert-threshold: "120%"
|
||||
# fail-on-alert: false
|
||||
# auto-push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
# github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Generate benchmark report
|
||||
run: |
|
||||
go run ./internal/benchci -current bench-results.txt -out bench-report.md
|
||||
cat bench-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload benchmark results
|
||||
- name: Upload generated benchmark report
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: bench-report
|
||||
path: bench-report.md
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload raw benchmark results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
+10
-1
@@ -1,3 +1,12 @@
|
||||
# Ignore files with no extensions trick:
|
||||
# First ignore all.
|
||||
*
|
||||
# Unignore directories since they usually have no extensions
|
||||
!*/
|
||||
# Unignore all with extensions.
|
||||
!*.*
|
||||
!LICENSE
|
||||
|
||||
# Go workspaces.
|
||||
go.work
|
||||
go.work.sum
|
||||
@@ -22,7 +31,7 @@ vendor/
|
||||
*.dylib
|
||||
*.hex
|
||||
*.wasm
|
||||
|
||||
/http-linux
|
||||
# Profiling
|
||||
*.pprof
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ go run ./examples/gen/gen-binary-bench # generate table
|
||||
|
||||
| Program | Extra Protocols | Packet capture printing | amd64 Go | WASM Go | amd64 TinyGo | WASM TinyGo | Pico TinyGo |
|
||||
|---|:---:|:---:|---|---|---|---|---|
|
||||
| [Lneto MWE](./examples/min-working-example/) | DNS,NTP,DHCP | ✅ | 3.9MB | 4.4MB | 1.6MB | 1.2MB | 189kB |
|
||||
| [Lneto MWE](./examples/min-working-example/) | DNS,NTP,DHCP | ✅ | 3.9MB | 4.5MB | 1.6MB | 1.2MB | 192kB |
|
||||
| [Gvisor MWE w/ go-net](./examples/_import_examples/gvisor-mwe/) | None | ❌ | 6.6MB | 7.5MB | DNC | DNC | DNC |
|
||||
## `xcurl` example
|
||||
You may try lneto out on linux with the [xcurl example](./examples/xcurl/) which gets an HTTP page by doing all the low-level networking part using absolutely no standard library.
|
||||
@@ -49,6 +49,7 @@ You may try lneto out on linux with the [xcurl example](./examples/xcurl/) which
|
||||
- HTTP over TCP/IPv4/Ethernet connection using
|
||||
- NTP time check (optional)
|
||||
- Print packet captures using lneto's [internet/pcap](./internet/pcap) package
|
||||
- Optional fixed TCP source port with `-port`, useful when debugging raw-socket interactions with the host OS
|
||||
|
||||
See Developing section below for more information.
|
||||
|
||||
@@ -108,7 +109,9 @@ ok github.com/soypat/lneto/x/xnet 2.926s
|
||||
| Ethernet PHY/MDIO | IEEE 802.3 cl.22/45 | ✅ | `phy` | — | Bare-metal PHY management via MDIO |
|
||||
| IPv6 | RFC 8200 | ✅ | `ipv6` | — | Frame parsing and stack handling |
|
||||
| ICMPv6 | RFC 4443 | ✅ | `ipv6/icmpv6` | — | Echo+NDP frame parsing and stack handling |
|
||||
| DHCPv6 | RFC 8415 | 🟡 | `dhcp/dhcpv6` | — | Frame parsing and standalone handling |
|
||||
| DHCPv6 DNS Configuration | RFC 3646 | 🟡 | `dhcp/dhcpv6` | — | Recursive DNS server and domain search options parsed and exposed |
|
||||
| DHCPv6 NTP Configuration | RFC 5908 | ✅ | `dhcp/dhcpv6` | — | NTP server address, multicast address, and FQDN suboptions parsed and exposed |
|
||||
| DHCPv6 | RFC 8415 | 🟡 | `dhcp/dhcpv6` | — | Client IA_NA/IA_PD request handling and reconfigure-renew; no relay agent or dynamic server pools |
|
||||
| TLS 1.3 | RFC 8446 | ❌ | — | — | Not implemented |
|
||||
|
||||
¹ `BenchmarkARPExchange` — full ARP request/response exchange over Ethernet: **0 B/op, 0 allocs/op**
|
||||
@@ -171,6 +174,19 @@ sudo ethtool -K <network-interface> gro off
|
||||
|
||||
Depending on the interface and driver, other offloading features may also need to be disabled.
|
||||
|
||||
When running `xcurl` directly on a normal Linux interface (for example `-i wlan0` or `-i eth0`), lneto shares the host interface IP and MAC address. The Linux TCP stack also sees incoming packets for that IP. If xcurl's TCP handshake succeeds but the HTTP request is followed by a TCP RST from the server, the host kernel may have sent its own outbound RST for xcurl's raw TCP flow because no kernel socket owns that source port.
|
||||
|
||||
For a short validation test, use a fixed xcurl source port and temporarily drop kernel-generated outbound RSTs for that port:
|
||||
|
||||
```sh
|
||||
go build -o examples/xcurl/xcurl ./examples/xcurl
|
||||
sudo iptables -I OUTPUT -p tcp --sport 2300 --tcp-flags RST RST -j DROP
|
||||
sudo ./examples/xcurl/xcurl -i <network-interface> -port 2300 -host example.com
|
||||
sudo iptables -D OUTPUT -p tcp --sport 2300 --tcp-flags RST RST -j DROP
|
||||
```
|
||||
|
||||
This is a debugging workaround, not the preferred operating mode. Prefer `-ihttp`, a TAP interface, a network namespace, or an otherwise isolated interface/IP when testing xcurl without host TCP stack interference.
|
||||
|
||||
- [`examples/httptap`](./examples/httptap) (linux only, root privilidges required) Program opens a TAP interface and assigns an IP address to it and exposes the interface via a HTTP interface. This program is run with root privilidges to facilitate debugging of lneto since no root privilidges are required to interact with the HTTP interface exposed.
|
||||
- `POST http://127.0.0.1:7070/send`: Receives a POST with request body containing JSON string of data to send over TAP interface. Response contains only status code.
|
||||
- `GET http://127.0.0.1:7070/recv`: Receives a GET request. Response contains a JSON string of oldest unread TAP interface packet. If string is empty then there is no more data to read.
|
||||
|
||||
+22
-1
@@ -152,7 +152,20 @@ func (sv *Server) Demux(carrierData []byte, frameOffset int) error {
|
||||
var client serverEntry
|
||||
var clientExists bool
|
||||
if len(clientID) == 0 {
|
||||
client, clientIDRaw, clientExists = sv.getClientByIP(*dfrm.CIAddr())
|
||||
// No explicit client identifier: use chaddr as the stable lookup key.
|
||||
// This lets the server correlate Discover→Offer→Request even when
|
||||
// ciaddr=0.0.0.0 (client has no IP yet), which is the normal case
|
||||
// for first-time lease acquisition (RFC 2131 §4.3.1).
|
||||
chaddr := *dfrm.CHAddrAs6()
|
||||
copy(clientIDRaw[:], chaddr[:])
|
||||
client, clientExists = sv.getClient(clientIDRaw)
|
||||
if !clientExists {
|
||||
// Fallback: look up by ciaddr for clients that did send ciaddr.
|
||||
ciaddr := *dfrm.CIAddr()
|
||||
if ciaddr != ([4]byte{}) {
|
||||
client, clientIDRaw, clientExists = sv.getClientByIP(ciaddr)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
copy(clientIDRaw[:], clientID)
|
||||
client, clientExists = sv.getClient(clientIDRaw)
|
||||
@@ -301,6 +314,14 @@ func (sv *Server) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Per RFC 2131 §4.1: unicast Offer/Ack to chaddr because the client
|
||||
// does not yet have the offered IP, so no ARP entry exists.
|
||||
// The Ethernet layer sets dst=gwmac before calling us; overwrite it
|
||||
// here so the frame reaches the client via its hardware address.
|
||||
// offsetToIP==14 means the Ethernet header is at carrierData[0:14].
|
||||
if offsetToIP >= 14 {
|
||||
copy(carrierData[offsetToIP-14:offsetToIP-8], client.hwaddr[:])
|
||||
}
|
||||
}
|
||||
|
||||
client.state = futureState
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package dhcpv4
|
||||
|
||||
// Tests covering the AP-mode DHCP server fixes:
|
||||
// 1. Client lookup by chaddr when no ClientID option is present.
|
||||
// 2. Ciaddr fallback lookup when chaddr lookup finds nothing.
|
||||
// 3. Ethernet dst is patched to chaddr on Offer/Ack when Encapsulate is
|
||||
// called with offsetToIP >= 14.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/soypat/lneto/ipv4"
|
||||
)
|
||||
|
||||
// doraNoClientID runs a complete DORA exchange using raw DHCP frames
|
||||
// (no ClientID option) with the given chaddr. The client uses only chaddr
|
||||
// as its identity, matching normal AP-mode behaviour where mobile clients
|
||||
// don't send OptClientIdentifier.
|
||||
func doraNoClientID(t *testing.T, sv *Server, chaddr [6]byte, xid uint32) (assignedIP [4]byte) {
|
||||
t.Helper()
|
||||
var cl Client
|
||||
err := cl.BeginRequest(xid, RequestConfig{
|
||||
ClientHardwareAddr: chaddr,
|
||||
// Intentionally no ClientID – forces server to key on chaddr.
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BeginRequest: %v", err)
|
||||
}
|
||||
|
||||
var buf [1024]byte
|
||||
|
||||
// DISCOVER
|
||||
n, err := cl.Encapsulate(buf[:], -1, 0)
|
||||
if err != nil || n == 0 {
|
||||
t.Fatalf("discover encapsulate: n=%d err=%v", n, err)
|
||||
}
|
||||
if err = sv.Demux(buf[:n], 0); err != nil {
|
||||
t.Fatalf("discover demux: %v", err)
|
||||
}
|
||||
|
||||
// OFFER
|
||||
n, err = sv.Encapsulate(buf[:], -1, 0)
|
||||
if err != nil || n == 0 {
|
||||
t.Fatalf("offer encapsulate: n=%d err=%v", n, err)
|
||||
}
|
||||
frm, _ := NewFrame(buf[:n])
|
||||
assignedIP = *frm.YIAddr()
|
||||
if err = cl.Demux(buf[:n], 0); err != nil {
|
||||
t.Fatalf("offer demux: %v", err)
|
||||
}
|
||||
|
||||
// REQUEST
|
||||
n, err = cl.Encapsulate(buf[:], -1, 0)
|
||||
if err != nil || n == 0 {
|
||||
t.Fatalf("request encapsulate: n=%d err=%v", n, err)
|
||||
}
|
||||
if err = sv.Demux(buf[:n], 0); err != nil {
|
||||
t.Fatalf("request demux: %v", err)
|
||||
}
|
||||
|
||||
// ACK
|
||||
n, err = sv.Encapsulate(buf[:], -1, 0)
|
||||
if err != nil || n == 0 {
|
||||
t.Fatalf("ack encapsulate: n=%d err=%v", n, err)
|
||||
}
|
||||
if err = cl.Demux(buf[:n], 0); err != nil {
|
||||
t.Fatalf("ack demux: %v", err)
|
||||
}
|
||||
|
||||
if cl.State() != StateBound {
|
||||
t.Errorf("want StateBound, got %s", cl.State())
|
||||
}
|
||||
return assignedIP
|
||||
}
|
||||
|
||||
// TestServerChaddrLookup_NoClientID verifies the server can complete a full DORA
|
||||
// cycle when the client sends no OptClientIdentifier option. Before the fix the
|
||||
// MsgRequest Demux call would fail with "request for non existing client" because
|
||||
// the server tried to look up the entry by ciaddr (0.0.0.0) instead of chaddr.
|
||||
func TestServerChaddrLookup_NoClientID(t *testing.T) {
|
||||
svAddr := [4]byte{192, 168, 4, 1}
|
||||
var sv Server
|
||||
sv.Configure(ServerConfig{
|
||||
ServerAddr: svAddr,
|
||||
Gateway: svAddr,
|
||||
Subnet: ipv4.PrefixFrom(svAddr, 24),
|
||||
})
|
||||
|
||||
chaddr := [6]byte{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}
|
||||
ip := doraNoClientID(t, &sv, chaddr, 0x12345678)
|
||||
if ip[0] != 192 || ip[1] != 168 || ip[2] != 4 {
|
||||
t.Errorf("unexpected subnet in assigned IP %v", ip)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerChaddrLookup_MultipleClientsNoClientID verifies that multiple clients
|
||||
// without ClientID each get a unique address and successfully reach StateBound.
|
||||
func TestServerChaddrLookup_MultipleClientsNoClientID(t *testing.T) {
|
||||
svAddr := [4]byte{192, 168, 4, 1}
|
||||
var sv Server
|
||||
sv.Configure(ServerConfig{
|
||||
ServerAddr: svAddr,
|
||||
Gateway: svAddr,
|
||||
Subnet: ipv4.PrefixFrom(svAddr, 24),
|
||||
})
|
||||
|
||||
addrs := make([][4]byte, 4)
|
||||
for i := range addrs {
|
||||
chaddr := [6]byte{0x00, 0x11, 0x22, 0x33, 0x44, byte(i + 1)}
|
||||
addrs[i] = doraNoClientID(t, &sv, chaddr, uint32(i+1))
|
||||
}
|
||||
|
||||
for i := range addrs {
|
||||
for j := i + 1; j < len(addrs); j++ {
|
||||
if addrs[i] == addrs[j] {
|
||||
t.Errorf("clients %d and %d got same address %v", i, j, addrs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerCiaddrFallback exercises the ciaddr fallback path in Demux.
|
||||
// An entry is registered under an explicit non-MAC ClientID. A subsequent
|
||||
// RELEASE arrives with no ClientID (so chaddr lookup misses) but with
|
||||
// ciaddr=assignedIP, which should let the server find and remove the entry.
|
||||
func TestServerCiaddrFallback(t *testing.T) {
|
||||
svAddr := [4]byte{192, 168, 4, 1}
|
||||
chaddr := [6]byte{0xca, 0xfe, 0x00, 0x00, 0x01, 0x01}
|
||||
const xid = uint32(0xaabbccdd)
|
||||
var sv Server
|
||||
sv.Configure(ServerConfig{
|
||||
ServerAddr: svAddr,
|
||||
Subnet: ipv4.PrefixFrom(svAddr, 24),
|
||||
})
|
||||
|
||||
// DORA with an explicit ClientID — server keys the entry by that string.
|
||||
var cl Client
|
||||
if err := cl.BeginRequest(xid, RequestConfig{
|
||||
ClientHardwareAddr: chaddr,
|
||||
ClientID: "device-id",
|
||||
}); err != nil {
|
||||
t.Fatalf("BeginRequest: %v", err)
|
||||
}
|
||||
assignedIP := doraWithClient(t, &sv, &cl)
|
||||
|
||||
// RELEASE: no ClientID, ciaddr = assignedIP.
|
||||
// chaddr lookup fails (entry is keyed by "device-id"), so server must
|
||||
// fall back to getClientByIP.
|
||||
var relBuf [512]byte
|
||||
rfrm, _ := NewFrame(relBuf[:])
|
||||
rfrm.ClearHeader()
|
||||
rfrm.SetOp(OpRequest)
|
||||
rfrm.SetHardware(1, 6, 0)
|
||||
rfrm.SetXID(xid)
|
||||
*rfrm.CIAddr() = assignedIP
|
||||
copy(rfrm.CHAddrAs6()[:], chaddr[:])
|
||||
rfrm.SetMagicCookie(MagicCookie)
|
||||
opts := rfrm.OptionsPayload()
|
||||
n, _ := EncodeOption(opts, OptMessageType, byte(MsgRelease))
|
||||
opts[n] = byte(OptEnd)
|
||||
n++
|
||||
if err := sv.Demux(relBuf[:OptionsOffset+n], 0); err != nil {
|
||||
t.Fatalf("release demux: %v", err)
|
||||
}
|
||||
if len(sv.hosts) != 0 {
|
||||
t.Errorf("expected 0 hosts after release, got %d", len(sv.hosts))
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerOfferPatchesEthernetDst verifies that Encapsulate overwrites the
|
||||
// first 6 bytes of the carrier (Ethernet dst) with the client's chaddr when
|
||||
// offsetToIP >= 14.
|
||||
func TestServerOfferPatchesEthernetDst(t *testing.T) {
|
||||
svAddr := [4]byte{192, 168, 4, 1}
|
||||
clChaddr := [6]byte{0x02, 0x03, 0x04, 0x05, 0x06, 0x07}
|
||||
var sv Server
|
||||
sv.Configure(ServerConfig{
|
||||
ServerAddr: svAddr,
|
||||
Subnet: ipv4.PrefixFrom(svAddr, 24),
|
||||
})
|
||||
|
||||
var cl Client
|
||||
cl.BeginRequest(0x11223344, RequestConfig{ClientHardwareAddr: clChaddr})
|
||||
|
||||
var buf [1024]byte
|
||||
n, err := cl.Encapsulate(buf[:], -1, 0)
|
||||
if err != nil || n == 0 {
|
||||
t.Fatalf("discover encapsulate: n=%d err=%v", n, err)
|
||||
}
|
||||
if err = sv.Demux(buf[:n], 0); err != nil {
|
||||
t.Fatalf("discover demux: %v", err)
|
||||
}
|
||||
|
||||
// Carrier: 14 bytes Ethernet header, then a minimal IPv4 header marker.
|
||||
var carrier [1024]byte
|
||||
carrier[14] = 0x45 // IPv4 version+IHL so SetIPAddrs succeeds.
|
||||
offerN, err := sv.Encapsulate(carrier[:], 14, 14+20+8)
|
||||
if err != nil || offerN == 0 {
|
||||
t.Fatalf("offer encapsulate: n=%d err=%v", offerN, err)
|
||||
}
|
||||
|
||||
var got [6]byte
|
||||
copy(got[:], carrier[0:6])
|
||||
if got != clChaddr {
|
||||
t.Errorf("Ethernet dst: got %v, want %v", got, clChaddr)
|
||||
}
|
||||
}
|
||||
|
||||
// doraWithClient runs a complete DORA exchange using the given pre-configured
|
||||
// Client and returns the assigned IP.
|
||||
func doraWithClient(t *testing.T, sv *Server, cl *Client) (assignedIP [4]byte) {
|
||||
t.Helper()
|
||||
var buf [1024]byte
|
||||
|
||||
n, err := cl.Encapsulate(buf[:], -1, 0)
|
||||
if err != nil || n == 0 {
|
||||
t.Fatalf("discover encapsulate: n=%d err=%v", n, err)
|
||||
}
|
||||
if err = sv.Demux(buf[:n], 0); err != nil {
|
||||
t.Fatalf("discover demux: %v", err)
|
||||
}
|
||||
|
||||
n, err = sv.Encapsulate(buf[:], -1, 0)
|
||||
if err != nil || n == 0 {
|
||||
t.Fatalf("offer encapsulate: n=%d err=%v", n, err)
|
||||
}
|
||||
frm, _ := NewFrame(buf[:n])
|
||||
assignedIP = *frm.YIAddr()
|
||||
if err = cl.Demux(buf[:n], 0); err != nil {
|
||||
t.Fatalf("offer demux: %v", err)
|
||||
}
|
||||
|
||||
n, err = cl.Encapsulate(buf[:], -1, 0)
|
||||
if err != nil || n == 0 {
|
||||
t.Fatalf("request encapsulate: n=%d err=%v", n, err)
|
||||
}
|
||||
if err = sv.Demux(buf[:n], 0); err != nil {
|
||||
t.Fatalf("request demux: %v", err)
|
||||
}
|
||||
|
||||
n, err = sv.Encapsulate(buf[:], -1, 0)
|
||||
if err != nil || n == 0 {
|
||||
t.Fatalf("ack encapsulate: n=%d err=%v", n, err)
|
||||
}
|
||||
if err = cl.Demux(buf[:n], 0); err != nil {
|
||||
t.Fatalf("ack demux: %v", err)
|
||||
}
|
||||
if cl.State() != StateBound {
|
||||
t.Errorf("want StateBound, got %s", cl.State())
|
||||
}
|
||||
return assignedIP
|
||||
}
|
||||
+337
-8
@@ -6,14 +6,77 @@ import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
"github.com/soypat/lneto/dns"
|
||||
"github.com/soypat/lneto/internal"
|
||||
)
|
||||
|
||||
// Default caps for the repeated, server-supplied options the client retains.
|
||||
// A DHCPv6 server may place arbitrarily many of each in a single message
|
||||
// (RFC 8415), so the client bounds them to prevent a malicious or
|
||||
// misconfigured server from driving unbounded allocation (an OOM vector).
|
||||
const (
|
||||
defaultMaxDNSServers = 4
|
||||
defaultMaxDomainSearch = 6
|
||||
defaultMaxNTPServers = 4
|
||||
defaultMaxNTPMulticastServers = 2
|
||||
defaultMaxNTPServerNames = 4
|
||||
defaultMaxDelegatedPrefixes = 4
|
||||
)
|
||||
|
||||
// Limits bounds how many entries of each repeated, server-supplied option the
|
||||
// client retains from a single exchange. The backing arrays are sized to these
|
||||
// caps once in [Client.BeginRequest] and reused across resets, so parsing a
|
||||
// server message performs no further allocation and a hostile server cannot
|
||||
// grow client memory without bound. A zero (or negative) field selects its
|
||||
// default; see the default* constants.
|
||||
type Limits struct {
|
||||
MaxDNSServers int // OptDNSServers addresses.
|
||||
MaxDomainSearch int // OptDomainList search names.
|
||||
MaxNTPServers int // OptNTPServer suboption 1 addresses.
|
||||
MaxNTPMulticastServers int // OptNTPServer suboption 2 addresses.
|
||||
MaxNTPServerNames int // OptNTPServer suboption 3 FQDNs.
|
||||
MaxDelegatedPrefixes int // OptIAPD/OptIAPrefix delegated prefixes.
|
||||
}
|
||||
|
||||
// withDefaults returns a copy of l with every non-positive field replaced by
|
||||
// its default cap, so the resolved limits are always usable.
|
||||
func (l Limits) withDefaults() Limits {
|
||||
if l.MaxDNSServers <= 0 {
|
||||
l.MaxDNSServers = defaultMaxDNSServers
|
||||
}
|
||||
if l.MaxDomainSearch <= 0 {
|
||||
l.MaxDomainSearch = defaultMaxDomainSearch
|
||||
}
|
||||
if l.MaxNTPServers <= 0 {
|
||||
l.MaxNTPServers = defaultMaxNTPServers
|
||||
}
|
||||
if l.MaxNTPMulticastServers <= 0 {
|
||||
l.MaxNTPMulticastServers = defaultMaxNTPMulticastServers
|
||||
}
|
||||
if l.MaxNTPServerNames <= 0 {
|
||||
l.MaxNTPServerNames = defaultMaxNTPServerNames
|
||||
}
|
||||
if l.MaxDelegatedPrefixes <= 0 {
|
||||
l.MaxDelegatedPrefixes = defaultMaxDelegatedPrefixes
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// RequestConfig holds the parameters for starting a DHCPv6 exchange.
|
||||
type RequestConfig struct {
|
||||
// ClientHardwareAddr is the client's Ethernet MAC address.
|
||||
// It is used to construct the client DUID-LL and IAID.
|
||||
ClientHardwareAddr [6]byte
|
||||
// Limits bounds how many of each repeated server-supplied option the client
|
||||
// stores. The zero value selects safe defaults for every field.
|
||||
Limits Limits
|
||||
}
|
||||
|
||||
// DelegatedPrefix is an IPv6 prefix delegated by a DHCPv6 server.
|
||||
type DelegatedPrefix struct {
|
||||
Prefix netip.Prefix
|
||||
PreferredLifetime uint32
|
||||
ValidLifetime uint32
|
||||
}
|
||||
|
||||
// Client is a stateful DHCPv6 client implementing the [lneto.StackNode] interface.
|
||||
@@ -39,6 +102,21 @@ type Client struct {
|
||||
// dns accumulates DNS recursive name server addresses (OptDNSServers).
|
||||
// Client owns the backing array; cleared on reset, capacity reused.
|
||||
dns []netip.Addr
|
||||
// domainSearch accumulates DNS domain search names (OptDomainList).
|
||||
// Client owns the backing array; cleared on reset, capacity reused.
|
||||
domainSearch []dns.Name
|
||||
// ntps accumulates NTP server addresses (OptNTPServer, suboption 1).
|
||||
// Client owns the backing array; cleared on reset, capacity reused.
|
||||
ntps []netip.Addr
|
||||
// ntpMulticast accumulates NTP multicast addresses (OptNTPServer, suboption 2).
|
||||
// Client owns the backing array; cleared on reset, capacity reused.
|
||||
ntpMulticast []netip.Addr
|
||||
// ntpNames accumulates NTP server FQDNs (OptNTPServer, suboption 3).
|
||||
// Client owns the backing array; cleared on reset, capacity reused.
|
||||
ntpNames []dns.Name
|
||||
// delegatedPrefixes accumulates IA_PD prefixes (OptIAPD/OptIAPrefix).
|
||||
// Client owns the backing array; cleared on reset, capacity reused.
|
||||
delegatedPrefixes []DelegatedPrefix
|
||||
|
||||
assignedAddr [16]byte
|
||||
assignedAddrValid bool
|
||||
@@ -50,9 +128,18 @@ type Client struct {
|
||||
t1, t2 uint32
|
||||
preferredLifetime uint32
|
||||
validLifetime uint32
|
||||
// IA_PD timers from the server's Advertise/Reply.
|
||||
pdT1, pdT2 uint32
|
||||
reconfigureMsg MsgType
|
||||
reconfigureOK bool
|
||||
|
||||
clientMAC [6]byte
|
||||
|
||||
// limits is the resolved (defaults-applied) cap on each repeated option.
|
||||
// It is set in BeginRequest and preserved across resets so the bounded
|
||||
// backing arrays are reused without reallocation.
|
||||
limits Limits
|
||||
|
||||
// auxbuf is a scratch buffer used during Encapsulate to avoid allocations.
|
||||
auxbuf [128]byte
|
||||
}
|
||||
@@ -70,23 +157,65 @@ func (c *Client) BeginRequest(xid uint32, cfg RequestConfig) error {
|
||||
c.clientMAC = cfg.ClientHardwareAddr
|
||||
c.iaid = [4]byte(cfg.ClientHardwareAddr[:4])
|
||||
c.xid = xid & 0xFFFFFF
|
||||
c.limits = cfg.Limits.withDefaults()
|
||||
c.reset()
|
||||
// Size the per-option backing arrays to their caps once, here, so the
|
||||
// parse path on the network-facing Demux never allocates and stored
|
||||
// entries stay bounded across the connection's lifetime.
|
||||
c.dns = ensureAddrCap(c.dns, c.limits.MaxDNSServers)
|
||||
c.ntps = ensureAddrCap(c.ntps, c.limits.MaxNTPServers)
|
||||
c.ntpMulticast = ensureAddrCap(c.ntpMulticast, c.limits.MaxNTPMulticastServers)
|
||||
c.domainSearch = ensureNameCap(c.domainSearch, c.limits.MaxDomainSearch)
|
||||
c.ntpNames = ensureNameCap(c.ntpNames, c.limits.MaxNTPServerNames)
|
||||
c.delegatedPrefixes = ensurePrefixCap(c.delegatedPrefixes, c.limits.MaxDelegatedPrefixes)
|
||||
c.duid = AppendDUIDLL(c.duid[:0], cfg.ClientHardwareAddr)
|
||||
c.state = StateInit
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureAddrCap returns s truncated to length 0 with capacity at least n,
|
||||
// allocating a new backing array only when the existing one is too small.
|
||||
func ensureAddrCap(s []netip.Addr, n int) []netip.Addr {
|
||||
if cap(s) < n {
|
||||
return make([]netip.Addr, 0, n)
|
||||
}
|
||||
return s[:0]
|
||||
}
|
||||
|
||||
func ensureNameCap(s []dns.Name, n int) []dns.Name {
|
||||
if cap(s) < n {
|
||||
return make([]dns.Name, 0, n)
|
||||
}
|
||||
return s[:0]
|
||||
}
|
||||
|
||||
func ensurePrefixCap(s []DelegatedPrefix, n int) []DelegatedPrefix {
|
||||
if cap(s) < n {
|
||||
return make([]DelegatedPrefix, 0, n)
|
||||
}
|
||||
return s[:0]
|
||||
}
|
||||
|
||||
// Reset clears the DHCPv6 exchange state and invalidates stack registrations.
|
||||
func (c *Client) Reset() { c.reset() }
|
||||
|
||||
// reset clears exchange state while preserving slice backing arrays and the
|
||||
// connection ID is incremented to invalidate any existing stack registrations.
|
||||
func (c *Client) reset() {
|
||||
*c = Client{
|
||||
connID: c.connID + 1,
|
||||
xid: c.xid,
|
||||
clientMAC: c.clientMAC,
|
||||
iaid: c.iaid,
|
||||
duid: c.duid,
|
||||
serverDUID: c.serverDUID[:0],
|
||||
dns: c.dns[:0],
|
||||
connID: c.connID + 1,
|
||||
xid: c.xid,
|
||||
clientMAC: c.clientMAC,
|
||||
iaid: c.iaid,
|
||||
limits: c.limits,
|
||||
duid: c.duid,
|
||||
serverDUID: c.serverDUID[:0],
|
||||
dns: c.dns[:0],
|
||||
domainSearch: c.domainSearch[:0],
|
||||
ntps: c.ntps[:0],
|
||||
ntpMulticast: c.ntpMulticast[:0],
|
||||
ntpNames: c.ntpNames[:0],
|
||||
delegatedPrefixes: c.delegatedPrefixes[:0],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +244,8 @@ func (c *Client) Encapsulate(carrierData []byte, _, offsetToFrame int) (int, err
|
||||
frm.SetTransactionID(c.xid)
|
||||
n, _ := EncodeOption(dst[OptionsOffset+numOpts:], OptClientID, c.duid...)
|
||||
numOpts += n
|
||||
n, _ = EncodeOption(dst[OptionsOffset+numOpts:], OptReconfAccept)
|
||||
numOpts += n
|
||||
n, _ = EncodeOptionIANA(dst[OptionsOffset+numOpts:], c.iaid, 0, 0, nil)
|
||||
numOpts += n
|
||||
n, _ = EncodeOption(dst[OptionsOffset+numOpts:], OptORO, defaultOptRequestList...)
|
||||
@@ -130,6 +261,8 @@ func (c *Client) Encapsulate(carrierData []byte, _, offsetToFrame int) (int, err
|
||||
numOpts += n
|
||||
n, _ = EncodeOption(dst[OptionsOffset+numOpts:], OptServerID, c.serverDUID...)
|
||||
numOpts += n
|
||||
n, _ = EncodeOption(dst[OptionsOffset+numOpts:], OptReconfAccept)
|
||||
numOpts += n
|
||||
auxN, _ := EncodeOptionIAAddr(c.auxbuf[:], c.assignedAddr, 0, 0)
|
||||
n, _ = EncodeOptionIANA(dst[OptionsOffset+numOpts:], c.iaid, 0, 0, c.auxbuf[:auxN])
|
||||
numOpts += n
|
||||
@@ -185,6 +318,9 @@ func (c *Client) Demux(carrierData []byte, frameOffset int) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if frm.MsgType() == MsgReconfigure {
|
||||
return c.handleReconfigure(frm)
|
||||
}
|
||||
if frm.TransactionID() != c.xid {
|
||||
return lneto.ErrMismatch
|
||||
}
|
||||
@@ -213,6 +349,41 @@ func (c *Client) Demux(carrierData []byte, frameOffset int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) handleReconfigure(frm Frame) error {
|
||||
if !c.state.HasIP() {
|
||||
return lneto.ErrPacketDrop
|
||||
}
|
||||
var clientOK, serverOK bool
|
||||
var reconf MsgType
|
||||
err := frm.ForEachOption(func(_ int, code OptCode, data []byte) error {
|
||||
switch code {
|
||||
case OptClientID:
|
||||
clientOK = internal.BytesEqual(data, c.duid)
|
||||
case OptServerID:
|
||||
serverOK = len(c.serverDUID) == 0 || internal.BytesEqual(data, c.serverDUID)
|
||||
case OptReconfMsg:
|
||||
if len(data) != 1 {
|
||||
return lneto.ErrInvalidLengthField
|
||||
}
|
||||
reconf = MsgType(data[0])
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !clientOK || !serverOK {
|
||||
return lneto.ErrMismatch
|
||||
}
|
||||
c.reconfigureMsg = reconf
|
||||
c.reconfigureOK = true
|
||||
if reconf != MsgRenew {
|
||||
return lneto.ErrUnsupported
|
||||
}
|
||||
c.state = StateRenewing
|
||||
return nil
|
||||
}
|
||||
|
||||
// setOptions parses all DHCPv6 options in frm and stores relevant values.
|
||||
func (c *Client) setOptions(frm Frame) error {
|
||||
return frm.ForEachOption(func(_ int, code OptCode, data []byte) error {
|
||||
@@ -221,18 +392,121 @@ func (c *Client) setOptions(frm Frame) error {
|
||||
c.serverDUID = append(c.serverDUID[:0], data...)
|
||||
case OptIANA:
|
||||
c.parseIANA(data)
|
||||
case OptIAPD:
|
||||
c.parseIAPD(data)
|
||||
case OptDNSServers:
|
||||
if len(c.dns) > 0 || len(data)%16 != 0 {
|
||||
break // skip if already populated or malformed
|
||||
}
|
||||
for i := 0; i+16 <= len(data); i += 16 {
|
||||
for i := 0; i+16 <= len(data) && len(c.dns) < c.limits.MaxDNSServers; i += 16 {
|
||||
c.dns = append(c.dns, netip.AddrFrom16([16]byte(data[i:i+16])))
|
||||
}
|
||||
case OptDomainList:
|
||||
c.parseDomainSearch(data)
|
||||
case OptNTPServer:
|
||||
c.parseNTPServer(data)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) parseIAPD(data []byte) {
|
||||
if len(c.delegatedPrefixes) > 0 {
|
||||
return
|
||||
}
|
||||
if len(data) < 12 {
|
||||
return
|
||||
}
|
||||
t1 := binary.BigEndian.Uint32(data[4:8])
|
||||
t2 := binary.BigEndian.Uint32(data[8:12])
|
||||
|
||||
for ptr := 12; ptr+4 <= len(data); {
|
||||
subCode := OptCode(binary.BigEndian.Uint16(data[ptr:]))
|
||||
subLen := int(binary.BigEndian.Uint16(data[ptr+2:]))
|
||||
if ptr+4+subLen > len(data) {
|
||||
break
|
||||
}
|
||||
if subCode == OptIAPrefix && subLen >= 25 && len(c.delegatedPrefixes) < c.limits.MaxDelegatedPrefixes {
|
||||
sub := data[ptr+4 : ptr+4+subLen]
|
||||
bits := int(sub[8])
|
||||
prefix := netip.PrefixFrom(netip.AddrFrom16([16]byte(sub[9:25])), bits)
|
||||
if prefix.IsValid() {
|
||||
c.delegatedPrefixes = append(c.delegatedPrefixes, DelegatedPrefix{
|
||||
Prefix: prefix.Masked(),
|
||||
PreferredLifetime: binary.BigEndian.Uint32(sub[:4]),
|
||||
ValidLifetime: binary.BigEndian.Uint32(sub[4:8]),
|
||||
})
|
||||
}
|
||||
}
|
||||
ptr += 4 + subLen
|
||||
}
|
||||
if len(c.delegatedPrefixes) > 0 {
|
||||
c.pdT1 = t1
|
||||
c.pdT2 = t2
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) parseNTPServer(data []byte) {
|
||||
if len(c.ntps) > 0 || len(c.ntpMulticast) > 0 || len(c.ntpNames) > 0 {
|
||||
return
|
||||
}
|
||||
for ptr := 0; ptr+4 <= len(data); {
|
||||
subCode := binary.BigEndian.Uint16(data[ptr:])
|
||||
subLen := int(binary.BigEndian.Uint16(data[ptr+2:]))
|
||||
if ptr+4+subLen > len(data) {
|
||||
break
|
||||
}
|
||||
subData := data[ptr+4 : ptr+4+subLen]
|
||||
switch subCode {
|
||||
case 1:
|
||||
if subLen == 16 && len(c.ntps) < c.limits.MaxNTPServers {
|
||||
c.ntps = append(c.ntps, netip.AddrFrom16([16]byte(data[ptr+4:ptr+20])))
|
||||
}
|
||||
case 2:
|
||||
if subLen == 16 && len(c.ntpMulticast) < c.limits.MaxNTPMulticastServers {
|
||||
c.ntpMulticast = append(c.ntpMulticast, netip.AddrFrom16([16]byte(data[ptr+4:ptr+20])))
|
||||
}
|
||||
case 3:
|
||||
idx := len(c.ntpNames)
|
||||
if idx >= c.limits.MaxNTPServerNames {
|
||||
break // suboption switch: cap reached, drop further names.
|
||||
}
|
||||
if idx < cap(c.ntpNames) {
|
||||
c.ntpNames = c.ntpNames[:idx+1]
|
||||
} else {
|
||||
c.ntpNames = append(c.ntpNames, dns.Name{})
|
||||
}
|
||||
next, err := c.ntpNames[idx].Decode(subData, 0)
|
||||
if err != nil || next != uint16(len(subData)) {
|
||||
c.ntpNames[idx].Reset()
|
||||
c.ntpNames = c.ntpNames[:idx]
|
||||
}
|
||||
}
|
||||
ptr += 4 + subLen
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) parseDomainSearch(data []byte) {
|
||||
if len(c.domainSearch) > 0 {
|
||||
return
|
||||
}
|
||||
for off := uint16(0); off < uint16(len(data)) && len(c.domainSearch) < c.limits.MaxDomainSearch; {
|
||||
idx := len(c.domainSearch)
|
||||
if idx < cap(c.domainSearch) {
|
||||
c.domainSearch = c.domainSearch[:idx+1]
|
||||
} else {
|
||||
c.domainSearch = append(c.domainSearch, dns.Name{})
|
||||
}
|
||||
next, err := c.domainSearch[idx].Decode(data, off)
|
||||
if err != nil || next <= off {
|
||||
c.domainSearch[idx].Reset()
|
||||
c.domainSearch = c.domainSearch[:idx]
|
||||
return
|
||||
}
|
||||
off = next
|
||||
}
|
||||
}
|
||||
|
||||
// parseIANA processes the payload of an OptIANA option, extracting the
|
||||
// assigned address and lease timers from any embedded OptIAAddr sub-option.
|
||||
func (c *Client) parseIANA(data []byte) {
|
||||
@@ -273,15 +547,70 @@ func (c *Client) isClosed() bool { return c.state == 0 || c.xid == 0 }
|
||||
// State returns the current client state.
|
||||
func (c *Client) State() ClientState { return c.state }
|
||||
|
||||
// LastReconfigure returns the last accepted Reconfigure message target.
|
||||
func (c *Client) LastReconfigure() (MsgType, bool) { return c.reconfigureMsg, c.reconfigureOK }
|
||||
|
||||
// AssignedAddr returns the IPv6 address assigned by the server and whether it is valid.
|
||||
func (c *Client) AssignedAddr() ([16]byte, bool) { return c.assignedAddr, c.assignedAddrValid }
|
||||
|
||||
// RebindingSeconds returns the IA_NA T2 timer from the server.
|
||||
func (c *Client) RebindingSeconds() uint32 { return c.t2 }
|
||||
|
||||
// RenewalSeconds returns the IA_NA T1 timer from the server.
|
||||
func (c *Client) RenewalSeconds() uint32 { return c.t1 }
|
||||
|
||||
// PreferredLifetimeSeconds returns the preferred lifetime of the assigned address.
|
||||
func (c *Client) PreferredLifetimeSeconds() uint32 { return c.preferredLifetime }
|
||||
|
||||
// ValidLifetimeSeconds returns the valid lifetime of the assigned address.
|
||||
func (c *Client) ValidLifetimeSeconds() uint32 { return c.validLifetime }
|
||||
|
||||
// PrefixDelegationRebindingSeconds returns the IA_PD T2 timer from the server.
|
||||
func (c *Client) PrefixDelegationRebindingSeconds() uint32 { return c.pdT2 }
|
||||
|
||||
// PrefixDelegationRenewalSeconds returns the IA_PD T1 timer from the server.
|
||||
func (c *Client) PrefixDelegationRenewalSeconds() uint32 { return c.pdT1 }
|
||||
|
||||
// AppendDelegatedPrefixes appends delegated IPv6 prefixes received from the server to dst.
|
||||
func (c *Client) AppendDelegatedPrefixes(dst []DelegatedPrefix) []DelegatedPrefix {
|
||||
return append(dst, c.delegatedPrefixes...)
|
||||
}
|
||||
|
||||
// NumDelegatedPrefixes returns the number of delegated IPv6 prefixes received.
|
||||
func (c *Client) NumDelegatedPrefixes() int { return len(c.delegatedPrefixes) }
|
||||
|
||||
// AppendDNSServers appends the DNS server addresses received from the server to dst.
|
||||
func (c *Client) AppendDNSServers(dst []netip.Addr) []netip.Addr { return append(dst, c.dns...) }
|
||||
|
||||
// NumDNSServers returns the number of DNS server addresses received.
|
||||
func (c *Client) NumDNSServers() int { return len(c.dns) }
|
||||
|
||||
// AppendDomainSearch appends the DNS domain search names received from the server to dst.
|
||||
func (c *Client) AppendDomainSearch(dst []dns.Name) []dns.Name { return append(dst, c.domainSearch...) }
|
||||
|
||||
// NumDomainSearch returns the number of DNS domain search names received.
|
||||
func (c *Client) NumDomainSearch() int { return len(c.domainSearch) }
|
||||
|
||||
// AppendNTPServers appends the NTP server addresses received from the server to dst.
|
||||
func (c *Client) AppendNTPServers(dst []netip.Addr) []netip.Addr { return append(dst, c.ntps...) }
|
||||
|
||||
// NumNTPServers returns the number of NTP server addresses received.
|
||||
func (c *Client) NumNTPServers() int { return len(c.ntps) }
|
||||
|
||||
// AppendNTPMulticastServers appends NTP multicast addresses received from the server to dst.
|
||||
func (c *Client) AppendNTPMulticastServers(dst []netip.Addr) []netip.Addr {
|
||||
return append(dst, c.ntpMulticast...)
|
||||
}
|
||||
|
||||
// NumNTPMulticastServers returns the number of NTP multicast addresses received.
|
||||
func (c *Client) NumNTPMulticastServers() int { return len(c.ntpMulticast) }
|
||||
|
||||
// AppendNTPServerNames appends NTP server FQDNs received from the server to dst.
|
||||
func (c *Client) AppendNTPServerNames(dst []dns.Name) []dns.Name { return append(dst, c.ntpNames...) }
|
||||
|
||||
// NumNTPServerNames returns the number of NTP server FQDNs received.
|
||||
func (c *Client) NumNTPServerNames() int { return len(c.ntpNames) }
|
||||
|
||||
// ConnectionID returns a pointer to the client's connection ID.
|
||||
// The value increments on each reset; callers should discard registrations when it changes.
|
||||
// Implements [lneto.StackNode].
|
||||
|
||||
@@ -2,7 +2,10 @@ package dhcpv6
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/soypat/lneto/dns"
|
||||
)
|
||||
|
||||
// writeOpt6 encodes a single DHCPv6 option into dst using the 4-byte TLV header
|
||||
@@ -124,6 +127,9 @@ func TestClientSolicitRequest(t *testing.T) {
|
||||
if n == 0 {
|
||||
t.Fatal("Encapsulate (Solicit): wrote 0 bytes")
|
||||
}
|
||||
if !frameHasOption(t, buf[:n], OptReconfAccept) {
|
||||
t.Fatal("Solicit missing Reconfigure Accept option")
|
||||
}
|
||||
if cl.State() != StateSoliciting {
|
||||
t.Fatalf("after Solicit: want StateSoliciting, got %v", cl.State())
|
||||
}
|
||||
@@ -164,6 +170,291 @@ func TestClientSolicitRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReconfigureRenew(t *testing.T) {
|
||||
const xid = 0x112233
|
||||
clientMAC := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
|
||||
serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66}
|
||||
assignedAddr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
|
||||
iaid := [4]byte{clientMAC[0], clientMAC[1], clientMAC[2], clientMAC[3]}
|
||||
var cl Client
|
||||
if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: clientMAC}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buf [1024]byte
|
||||
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cl.Demux(buildServerFrame(MsgAdvertise, xid, serverDUID, iaid, assignedAddr), 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cl.Demux(buildServerFrame(MsgReply, xid, serverDUID, iaid, assignedAddr), 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cl.State() != StateBound {
|
||||
t.Fatalf("state before reconfigure = %v, want %v", cl.State(), StateBound)
|
||||
}
|
||||
reconf := buildReconfigureFrame(0x445566, serverDUID, cl.duid, MsgRenew)
|
||||
if err := cl.Demux(reconf, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cl.State() != StateRenewing {
|
||||
t.Fatalf("state after reconfigure = %v, want %v", cl.State(), StateRenewing)
|
||||
}
|
||||
msg, ok := cl.LastReconfigure()
|
||||
if !ok || msg != MsgRenew {
|
||||
t.Fatalf("LastReconfigure = %v, %v, want %v, true", msg, ok, MsgRenew)
|
||||
}
|
||||
n, err := cl.Encapsulate(buf[:], -1, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
frm, err := NewFrame(buf[:n])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if frm.MsgType() != MsgRenew {
|
||||
t.Fatalf("Encapsulate after Reconfigure type = %v, want %v", frm.MsgType(), MsgRenew)
|
||||
}
|
||||
}
|
||||
|
||||
func buildReconfigureFrame(xid uint32, serverDUID, clientDUID []byte, msg MsgType) []byte {
|
||||
buf := make([]byte, 128)
|
||||
buf[0] = byte(MsgReconfigure)
|
||||
buf[1] = byte(xid >> 16)
|
||||
buf[2] = byte(xid >> 8)
|
||||
buf[3] = byte(xid)
|
||||
n := OptionsOffset
|
||||
n += writeOpt6(buf[n:], OptServerID, serverDUID...)
|
||||
n += writeOpt6(buf[n:], OptClientID, clientDUID...)
|
||||
n += writeOpt6(buf[n:], OptReconfMsg, byte(msg))
|
||||
return buf[:n]
|
||||
}
|
||||
|
||||
func frameHasOption(t testing.TB, b []byte, want OptCode) bool {
|
||||
t.Helper()
|
||||
frm, err := NewFrame(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
if err := frm.ForEachOption(func(_ int, code OptCode, _ []byte) error {
|
||||
if code == want {
|
||||
found = true
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func TestClientParsesNTPServerOption(t *testing.T) {
|
||||
const xid = 0x102030
|
||||
clientMAC := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
|
||||
serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66}
|
||||
assignedAddr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
|
||||
ntpAddr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x7b}
|
||||
ntpMulticast := [16]byte{0xff, 0x05, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0x01}
|
||||
iaid := [4]byte{clientMAC[0], clientMAC[1], clientMAC[2], clientMAC[3]}
|
||||
|
||||
var cl Client
|
||||
if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: clientMAC}); err != nil {
|
||||
t.Fatal("BeginRequest:", err)
|
||||
}
|
||||
var buf [1024]byte
|
||||
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
|
||||
t.Fatal("Encapsulate (Solicit):", err)
|
||||
}
|
||||
advFrame := buildServerFrame(MsgAdvertise, xid, serverDUID, iaid, assignedAddr)
|
||||
if err := cl.Demux(advFrame, 0); err != nil {
|
||||
t.Fatal("Demux (Advertise):", err)
|
||||
}
|
||||
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
|
||||
t.Fatal("Encapsulate (Request):", err)
|
||||
}
|
||||
|
||||
replyFrame := appendNTPServerOption(t, buildServerFrame(MsgReply, xid, serverDUID, iaid, assignedAddr), ntpAddr, ntpMulticast, "time.example.com")
|
||||
if err := cl.Demux(replyFrame, 0); err != nil {
|
||||
t.Fatal("Demux (Reply):", err)
|
||||
}
|
||||
var ntps []netip.Addr
|
||||
ntps = cl.AppendNTPServers(ntps)
|
||||
if len(ntps) != 1 || ntps[0] != netip.AddrFrom16(ntpAddr) {
|
||||
t.Fatalf("AppendNTPServers = %v, want %v", ntps, netip.AddrFrom16(ntpAddr))
|
||||
}
|
||||
if n := cl.NumNTPServers(); n != 1 {
|
||||
t.Fatalf("NumNTPServers = %d, want 1", n)
|
||||
}
|
||||
var multicasts []netip.Addr
|
||||
multicasts = cl.AppendNTPMulticastServers(multicasts)
|
||||
if len(multicasts) != 1 || multicasts[0] != netip.AddrFrom16(ntpMulticast) {
|
||||
t.Fatalf("AppendNTPMulticastServers = %v, want %v", multicasts, netip.AddrFrom16(ntpMulticast))
|
||||
}
|
||||
if n := cl.NumNTPMulticastServers(); n != 1 {
|
||||
t.Fatalf("NumNTPMulticastServers = %d, want 1", n)
|
||||
}
|
||||
var names []dns.Name
|
||||
names = cl.AppendNTPServerNames(names)
|
||||
if len(names) != 1 || !names[0].EqualString("time.example.com") {
|
||||
t.Fatalf("AppendNTPServerNames = %v, want time.example.com", names)
|
||||
}
|
||||
if n := cl.NumNTPServerNames(); n != 1 {
|
||||
t.Fatalf("NumNTPServerNames = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientParsesDomainSearchOption(t *testing.T) {
|
||||
const xid = 0x203040
|
||||
clientMAC := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
|
||||
serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66}
|
||||
assignedAddr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
|
||||
iaid := [4]byte{clientMAC[0], clientMAC[1], clientMAC[2], clientMAC[3]}
|
||||
|
||||
var cl Client
|
||||
if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: clientMAC}); err != nil {
|
||||
t.Fatal("BeginRequest:", err)
|
||||
}
|
||||
var buf [1024]byte
|
||||
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
|
||||
t.Fatal("Encapsulate (Solicit):", err)
|
||||
}
|
||||
advFrame := buildServerFrame(MsgAdvertise, xid, serverDUID, iaid, assignedAddr)
|
||||
if err := cl.Demux(advFrame, 0); err != nil {
|
||||
t.Fatal("Demux (Advertise):", err)
|
||||
}
|
||||
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
|
||||
t.Fatal("Encapsulate (Request):", err)
|
||||
}
|
||||
|
||||
replyFrame := appendDomainSearchOption(t, buildServerFrame(MsgReply, xid, serverDUID, iaid, assignedAddr), "example.com", "corp.example.com")
|
||||
if err := cl.Demux(replyFrame, 0); err != nil {
|
||||
t.Fatal("Demux (Reply):", err)
|
||||
}
|
||||
var domains []dns.Name
|
||||
domains = cl.AppendDomainSearch(domains)
|
||||
if len(domains) != 2 {
|
||||
t.Fatalf("AppendDomainSearch len = %d, want 2", len(domains))
|
||||
}
|
||||
if !domains[0].EqualString("example.com") || !domains[1].EqualString("corp.example.com") {
|
||||
t.Fatalf("AppendDomainSearch = %q, %q", domains[0].String(), domains[1].String())
|
||||
}
|
||||
if n := cl.NumDomainSearch(); n != 2 {
|
||||
t.Fatalf("NumDomainSearch = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientParsesDelegatedPrefix(t *testing.T) {
|
||||
const xid = 0x304050
|
||||
clientMAC := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
|
||||
serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66}
|
||||
assignedAddr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
|
||||
delegated := netip.MustParsePrefix("2001:db8:abcd::/48")
|
||||
iaid := [4]byte{clientMAC[0], clientMAC[1], clientMAC[2], clientMAC[3]}
|
||||
|
||||
var cl Client
|
||||
if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: clientMAC}); err != nil {
|
||||
t.Fatal("BeginRequest:", err)
|
||||
}
|
||||
var buf [1024]byte
|
||||
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
|
||||
t.Fatal("Encapsulate (Solicit):", err)
|
||||
}
|
||||
advFrame := buildServerFrame(MsgAdvertise, xid, serverDUID, iaid, assignedAddr)
|
||||
if err := cl.Demux(advFrame, 0); err != nil {
|
||||
t.Fatal("Demux (Advertise):", err)
|
||||
}
|
||||
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
|
||||
t.Fatal("Encapsulate (Request):", err)
|
||||
}
|
||||
|
||||
replyFrame := appendIAPDOption(buildServerFrame(MsgReply, xid, serverDUID, iaid, assignedAddr), iaid, delegated)
|
||||
if err := cl.Demux(replyFrame, 0); err != nil {
|
||||
t.Fatal("Demux (Reply):", err)
|
||||
}
|
||||
var prefixes []DelegatedPrefix
|
||||
prefixes = cl.AppendDelegatedPrefixes(prefixes)
|
||||
if len(prefixes) != 1 {
|
||||
t.Fatalf("AppendDelegatedPrefixes len = %d, want 1", len(prefixes))
|
||||
}
|
||||
if prefixes[0].Prefix != delegated || prefixes[0].PreferredLifetime != 1800 || prefixes[0].ValidLifetime != 3600 {
|
||||
t.Fatalf("delegated prefix = %+v, want %s preferred=1800 valid=3600", prefixes[0], delegated)
|
||||
}
|
||||
if n := cl.NumDelegatedPrefixes(); n != 1 {
|
||||
t.Fatalf("NumDelegatedPrefixes = %d, want 1", n)
|
||||
}
|
||||
if cl.PrefixDelegationRenewalSeconds() != 900 || cl.PrefixDelegationRebindingSeconds() != 1800 {
|
||||
t.Fatalf("IA_PD timers = renewal:%d rebind:%d, want 900/1800", cl.PrefixDelegationRenewalSeconds(), cl.PrefixDelegationRebindingSeconds())
|
||||
}
|
||||
}
|
||||
|
||||
func appendNTPServerOption(t testing.TB, frame []byte, addr, multicast [16]byte, fqdn string) []byte {
|
||||
t.Helper()
|
||||
var ntpPayload []byte
|
||||
ntpPayload = appendNTPServerAddrSuboption(ntpPayload, 1, addr)
|
||||
ntpPayload = appendNTPServerAddrSuboption(ntpPayload, 2, multicast)
|
||||
name, err := dns.NewName(fqdn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nameStart := len(ntpPayload) + 4
|
||||
ntpPayload = append(ntpPayload, 0, 3, 0, 0)
|
||||
ntpPayload, err = name.AppendTo(ntpPayload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binary.BigEndian.PutUint16(ntpPayload[nameStart-2:nameStart], uint16(len(ntpPayload)-nameStart))
|
||||
buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(ntpPayload))...)
|
||||
writeOpt6(buf[len(frame):], OptNTPServer, ntpPayload...)
|
||||
return buf
|
||||
}
|
||||
|
||||
func appendNTPServerAddrSuboption(dst []byte, code uint16, addr [16]byte) []byte {
|
||||
start := len(dst)
|
||||
dst = append(dst, 0, 0, 0, 16)
|
||||
binary.BigEndian.PutUint16(dst[start:start+2], code)
|
||||
return append(dst, addr[:]...)
|
||||
}
|
||||
|
||||
func appendDomainSearchOption(t testing.TB, frame []byte, domains ...string) []byte {
|
||||
t.Helper()
|
||||
var payload []byte
|
||||
for _, domain := range domains {
|
||||
name, err := dns.NewName(domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload, err = name.AppendTo(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(payload))...)
|
||||
writeOpt6(buf[len(frame):], OptDomainList, payload...)
|
||||
return buf
|
||||
}
|
||||
|
||||
func appendIAPDOption(frame []byte, iaid [4]byte, prefix netip.Prefix) []byte {
|
||||
var iaPrefix [29]byte
|
||||
binary.BigEndian.PutUint16(iaPrefix[0:2], uint16(OptIAPrefix))
|
||||
binary.BigEndian.PutUint16(iaPrefix[2:4], 25)
|
||||
binary.BigEndian.PutUint32(iaPrefix[4:8], 1800)
|
||||
binary.BigEndian.PutUint32(iaPrefix[8:12], 3600)
|
||||
iaPrefix[12] = byte(prefix.Bits())
|
||||
prefixAddr := prefix.Addr().As16()
|
||||
copy(iaPrefix[13:], prefixAddr[:])
|
||||
var iapd [41]byte
|
||||
copy(iapd[:4], iaid[:])
|
||||
binary.BigEndian.PutUint32(iapd[4:8], 900)
|
||||
binary.BigEndian.PutUint32(iapd[8:12], 1800)
|
||||
copy(iapd[12:], iaPrefix[:])
|
||||
buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(iapd))...)
|
||||
writeOpt6(buf[len(frame):], OptIAPD, iapd[:]...)
|
||||
return buf
|
||||
}
|
||||
|
||||
// TestClientEncapsulateSolicit verifies that the first Encapsulate call
|
||||
// writes a Solicit message with at least OptClientID and OptIANA.
|
||||
func TestClientEncapsulateSolicit(t *testing.T) {
|
||||
@@ -242,3 +533,188 @@ func TestClientDoubleTapEncapsulate(t *testing.T) {
|
||||
t.Errorf("second Encapsulate: want 0 bytes (idempotent), got %d", n2)
|
||||
}
|
||||
}
|
||||
|
||||
// driveToRequesting advances a fresh client through Solicit→Advertise→Request so
|
||||
// the next Demux of a Reply runs the option-parsing path.
|
||||
func driveToRequesting(t testing.TB, cl *Client, xid uint32, serverDUID []byte, iaid [4]byte, addr [16]byte) {
|
||||
t.Helper()
|
||||
var buf [1024]byte
|
||||
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
|
||||
t.Fatal("Encapsulate (Solicit):", err)
|
||||
}
|
||||
if err := cl.Demux(buildServerFrame(MsgAdvertise, xid, serverDUID, iaid, addr), 0); err != nil {
|
||||
t.Fatal("Demux (Advertise):", err)
|
||||
}
|
||||
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
|
||||
t.Fatal("Encapsulate (Request):", err)
|
||||
}
|
||||
}
|
||||
|
||||
// floodDNSServersOption appends an OptDNSServers carrying n distinct addresses.
|
||||
func floodDNSServersOption(frame []byte, n int) []byte {
|
||||
payload := make([]byte, 0, n*16)
|
||||
for i := range n {
|
||||
var a [16]byte
|
||||
a[0], a[1], a[15] = 0x20, 0x01, byte(i+1)
|
||||
payload = append(payload, a[:]...)
|
||||
}
|
||||
buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(payload))...)
|
||||
writeOpt6(buf[len(frame):], OptDNSServers, payload...)
|
||||
return buf
|
||||
}
|
||||
|
||||
// floodIAPDOption appends an OptIAPD carrying n OptIAPrefix sub-options.
|
||||
func floodIAPDOption(frame []byte, iaid [4]byte, n int) []byte {
|
||||
payload := make([]byte, 12)
|
||||
copy(payload[:4], iaid[:])
|
||||
binary.BigEndian.PutUint32(payload[4:8], 900)
|
||||
binary.BigEndian.PutUint32(payload[8:12], 1800)
|
||||
for i := range n {
|
||||
var iaPrefix [29]byte
|
||||
binary.BigEndian.PutUint16(iaPrefix[0:2], uint16(OptIAPrefix))
|
||||
binary.BigEndian.PutUint16(iaPrefix[2:4], 25)
|
||||
binary.BigEndian.PutUint32(iaPrefix[4:8], 1800)
|
||||
binary.BigEndian.PutUint32(iaPrefix[8:12], 3600)
|
||||
iaPrefix[12] = 48
|
||||
iaPrefix[13], iaPrefix[14], iaPrefix[15] = 0x20, 0x01, byte(i+1)
|
||||
payload = append(payload, iaPrefix[:]...)
|
||||
}
|
||||
buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(payload))...)
|
||||
writeOpt6(buf[len(frame):], OptIAPD, payload...)
|
||||
return buf
|
||||
}
|
||||
|
||||
// floodNTPOption appends an OptNTPServer carrying nAddr unicast (suboption 1),
|
||||
// nMulticast (suboption 2) and nNames FQDN (suboption 3) entries.
|
||||
func floodNTPOption(t testing.TB, frame []byte, nAddr, nMulticast, nNames int) []byte {
|
||||
t.Helper()
|
||||
var payload []byte
|
||||
for i := range nAddr {
|
||||
var a [16]byte
|
||||
a[0], a[15] = 0x20, byte(i+1)
|
||||
payload = appendNTPServerAddrSuboption(payload, 1, a)
|
||||
}
|
||||
for i := range nMulticast {
|
||||
var a [16]byte
|
||||
a[0], a[1], a[15] = 0xff, 0x05, byte(i+1)
|
||||
payload = appendNTPServerAddrSuboption(payload, 2, a)
|
||||
}
|
||||
name, err := dns.NewName("ntp.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range nNames {
|
||||
start := len(payload) + 4
|
||||
payload = append(payload, 0, 3, 0, 0)
|
||||
payload, err = name.AppendTo(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binary.BigEndian.PutUint16(payload[start-2:start], uint16(len(payload)-start))
|
||||
}
|
||||
buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(payload))...)
|
||||
writeOpt6(buf[len(frame):], OptNTPServer, payload...)
|
||||
return buf
|
||||
}
|
||||
|
||||
func repeatStrings(s string, n int) []string {
|
||||
out := make([]string, n)
|
||||
for i := range out {
|
||||
out[i] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestClientLimitsCapServerData verifies that the client stores no more than
|
||||
// the configured number of each repeated option, so a server flooding a Reply
|
||||
// cannot drive unbounded allocation (the OOM concern raised in the PR review).
|
||||
func TestClientLimitsCapServerData(t *testing.T) {
|
||||
const xid = 0x515151
|
||||
mac := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
|
||||
serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66}
|
||||
iaid := [4]byte{mac[0], mac[1], mac[2], mac[3]}
|
||||
addr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
|
||||
limits := Limits{
|
||||
MaxDNSServers: 2, MaxDomainSearch: 2, MaxNTPServers: 2,
|
||||
MaxNTPMulticastServers: 1, MaxNTPServerNames: 2, MaxDelegatedPrefixes: 2,
|
||||
}
|
||||
|
||||
var cl Client
|
||||
if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: mac, Limits: limits}); err != nil {
|
||||
t.Fatal("BeginRequest:", err)
|
||||
}
|
||||
driveToRequesting(t, &cl, xid, serverDUID, iaid, addr)
|
||||
|
||||
// A Reply far exceeding every configured cap.
|
||||
reply := buildServerFrame(MsgReply, xid, serverDUID, iaid, addr)
|
||||
reply = floodDNSServersOption(reply, 8)
|
||||
reply = floodIAPDOption(reply, iaid, 8)
|
||||
reply = appendDomainSearchOption(t, reply, repeatStrings("example.com", 8)...)
|
||||
reply = floodNTPOption(t, reply, 8, 8, 8)
|
||||
if err := cl.Demux(reply, 0); err != nil {
|
||||
t.Fatal("Demux (Reply):", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
got int
|
||||
want int
|
||||
}{
|
||||
{"DNS servers", cl.NumDNSServers(), limits.MaxDNSServers},
|
||||
{"domain search", cl.NumDomainSearch(), limits.MaxDomainSearch},
|
||||
{"NTP servers", cl.NumNTPServers(), limits.MaxNTPServers},
|
||||
{"NTP multicast", cl.NumNTPMulticastServers(), limits.MaxNTPMulticastServers},
|
||||
{"NTP names", cl.NumNTPServerNames(), limits.MaxNTPServerNames},
|
||||
{"delegated prefixes", cl.NumDelegatedPrefixes(), limits.MaxDelegatedPrefixes},
|
||||
} {
|
||||
if tc.got != tc.want {
|
||||
t.Errorf("%s stored = %d, want capped at %d", tc.name, tc.got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientParseNoAllocs verifies that once BeginRequest has sized the option
|
||||
// backing arrays, parsing a server message performs no further allocation: the
|
||||
// bounded slices are reused, keeping the network-facing path off the heap.
|
||||
func TestClientParseNoAllocs(t *testing.T) {
|
||||
const xid = 0x123456
|
||||
mac := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
|
||||
serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66}
|
||||
iaid := [4]byte{mac[0], mac[1], mac[2], mac[3]}
|
||||
addr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
|
||||
|
||||
var cl Client
|
||||
if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: mac}); err != nil {
|
||||
t.Fatal("BeginRequest:", err)
|
||||
}
|
||||
reply := buildServerFrame(MsgReply, xid, serverDUID, iaid, addr)
|
||||
reply = floodDNSServersOption(reply, cl.limits.MaxDNSServers)
|
||||
reply = floodIAPDOption(reply, iaid, cl.limits.MaxDelegatedPrefixes)
|
||||
reply = appendDomainSearchOption(t, reply, repeatStrings("example.com", cl.limits.MaxDomainSearch)...)
|
||||
reply = floodNTPOption(t, reply, cl.limits.MaxNTPServers, cl.limits.MaxNTPMulticastServers, cl.limits.MaxNTPServerNames)
|
||||
frm, err := NewFrame(reply)
|
||||
if err != nil {
|
||||
t.Fatal("NewFrame:", err)
|
||||
}
|
||||
|
||||
clearStores := func() {
|
||||
cl.serverDUID = cl.serverDUID[:0]
|
||||
cl.dns = cl.dns[:0]
|
||||
cl.domainSearch = cl.domainSearch[:0]
|
||||
cl.ntps = cl.ntps[:0]
|
||||
cl.ntpMulticast = cl.ntpMulticast[:0]
|
||||
cl.ntpNames = cl.ntpNames[:0]
|
||||
cl.delegatedPrefixes = cl.delegatedPrefixes[:0]
|
||||
}
|
||||
clearStores()
|
||||
if err := cl.setOptions(frm); err != nil { // warm up the dns.Name backing arrays.
|
||||
t.Fatal("setOptions:", err)
|
||||
}
|
||||
allocs := testing.AllocsPerRun(50, func() {
|
||||
clearStores()
|
||||
_ = cl.setOptions(frm)
|
||||
})
|
||||
if allocs != 0 {
|
||||
t.Errorf("setOptions must not allocate after BeginRequest sizing, got %v allocs/op", allocs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
ErrAlreadyRegistered // protocol already registered
|
||||
ErrTruncatedFrame // truncated frame
|
||||
ErrMissingHALConfig // missing HAL configuration
|
||||
ErrBadState // operation invalid in current state
|
||||
// Below are potentially good future error additions
|
||||
// based on one or two encountered use cases, example use case included.
|
||||
/*
|
||||
|
||||
@@ -83,7 +83,17 @@ func main() {
|
||||
|
||||
var runner netdev.Runner[espradio.STAConfig]
|
||||
go func() {
|
||||
if err := runner.Run(context.Background(), iface, &stack, backoff); err != nil {
|
||||
// EthPoll drains the C ring buffer and delivers frames through the
|
||||
// receive handler, so the device is async but still needs the pump.
|
||||
err := runner.Configure(netdev.RunnerConfig[espradio.STAConfig]{
|
||||
Buffers: iface.RunnerBuffers(2),
|
||||
Backoff: backoff,
|
||||
Flags: netdev.RunnerInterfaceAsync | netdev.RunnerInterfacePoll,
|
||||
})
|
||||
if err != nil {
|
||||
failIfErr("runnerconfig", err)
|
||||
}
|
||||
if err := runner.Run(context.Background(), &iface, &stack); err != nil {
|
||||
failIfErr("runner", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -2,10 +2,12 @@ module piconetdev
|
||||
|
||||
go 1.25.7
|
||||
|
||||
require github.com/soypat/cyw43439 v0.1.1
|
||||
require (
|
||||
github.com/soypat/cyw43439 v0.1.1
|
||||
github.com/soypat/lneto v0.1.1-0.20260425023453-aa77403a2b32
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/soypat/lneto v0.1.0 // indirect
|
||||
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 // indirect
|
||||
github.com/tinygo-org/pio v0.2.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect
|
||||
@@ -13,4 +15,7 @@ require (
|
||||
|
||||
// This is an example taken grom github.com/soypat/lneto
|
||||
// Remove this replace directive when using as own program.
|
||||
replace github.com/soypat/lneto => ../../../.
|
||||
replace github.com/soypat/lneto => ../../../.
|
||||
|
||||
// Local cyw43439 with the poll-based EthPoll API.
|
||||
replace github.com/soypat/cyw43439 => ../../../../cyw43439
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
github.com/soypat/cyw43439 v0.1.1 h1:vcaTiVzfuz3keK7lJpVxStZ6tV8HCw7Ugzsh1k4mneE=
|
||||
github.com/soypat/cyw43439 v0.1.1/go.mod h1:R2uSILRwSPmcmmKy5Z0FtK4ypgiPf5YqK+F+IKmXqxc=
|
||||
github.com/soypat/lneto v0.1.0 h1:VAHCJ33hvC3wDqhM0Vm7w0k6vwNsOCAsQ8XTrXJpS7I=
|
||||
github.com/soypat/lneto v0.1.0/go.mod h1:g/8Lk+hIsMZydyWDJjK2YfsCuG6jA5mWCO6U+4S7w1U=
|
||||
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 h1:Y9fBuiR/urFY/m76+SAZTxk2xAOS2n85f+H1CugajeA=
|
||||
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710/go.mod h1:oCVCNGCHMKoBj97Zp9znLbQ1nHxpkmOY9X+UAGzOxc8=
|
||||
github.com/tinygo-org/pio v0.2.0 h1:vo3xa6xDZ2rVtxrks/KcTZHF3qq4lyWOntvEvl2pOhU=
|
||||
|
||||
@@ -70,14 +70,25 @@ func main() {
|
||||
failIfErr("init iface", err)
|
||||
|
||||
go func() {
|
||||
if err := runner.Run(context.Background(), iface, &stack, backoff); err != nil {
|
||||
err := runner.Configure(netdev.RunnerConfig[ConnectParams]{
|
||||
Buffers: iface.RunnerBuffers(1),
|
||||
Backoff: backoff,
|
||||
Flags: netdev.RunnerInterfaceAsync | netdev.RunnerInterfacePoll,
|
||||
})
|
||||
if err != nil {
|
||||
failIfErr("runnerconfig", err)
|
||||
}
|
||||
if err := runner.Run(context.Background(), &iface, &stack); err != nil {
|
||||
failIfErr("runner", err)
|
||||
}
|
||||
}()
|
||||
assigned, gatewayRt, subnetBits, err := stack.EnableDHCP(context.Background(), true, netip.Addr{})
|
||||
failIfErr("enable dhcp", err)
|
||||
println("assigned=", assigned.String(), "gateway=", gatewayRt.String(), "subnet", subnetBits)
|
||||
select {}
|
||||
for {
|
||||
runner.PrintDebug()
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// compile-time guarantee of interface implementation.
|
||||
@@ -142,23 +153,24 @@ func (d *Netdev) SendOffsetEthFrame(offsetTxEthFrame []byte) error {
|
||||
}
|
||||
|
||||
// SetRecvHandler implements [netdev.DevEthernet].
|
||||
//
|
||||
// The cyw43439 delivers received Ethernet frames through this callback whenever
|
||||
// the bus is serviced (including ioctl/control transactions outside EthPoll), so
|
||||
// the runner must drive it in async mode. EthPoll is still used to pump the device.
|
||||
func (d *Netdev) SetEthRecvHandler(handler func(rxEthframe []byte)) {
|
||||
d.dev.RecvEthHandle(func(pkt []byte) error {
|
||||
handler(pkt)
|
||||
return nil
|
||||
})
|
||||
d.dev.RecvEthHandle(handler)
|
||||
}
|
||||
|
||||
// EthPoll implements [netdev.DevEthernet].
|
||||
func (d *Netdev) EthPoll(buf []byte) (ethFrameOff, ethernetBytes int, err error) {
|
||||
_, err = d.dev.PollOne()
|
||||
return 0, 0, err
|
||||
return d.dev.EthPoll(buf)
|
||||
}
|
||||
|
||||
// MaxFrameSizeAndOffset implements [netdev.DevEthernet].
|
||||
func (d *Netdev) MaxFrameSizeAndOffset() (maxFrameSize int, frameOff int) {
|
||||
return cyw43439.MaxFrameSize, 0
|
||||
return 2048, 0
|
||||
}
|
||||
|
||||
func failIfErr(msg string, err error) {
|
||||
if err != nil {
|
||||
fail(msg, err)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//go:build !tinygo && linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Conn wraps an accepted TCP connection from a raw Linux socket file descriptor.
|
||||
// It implements io.Reader/io.Writer/io.Closer over syscall.Read/Write/Close.
|
||||
type Conn struct {
|
||||
fd int
|
||||
remote netip.AddrPort
|
||||
}
|
||||
|
||||
// Read reads bytes from the connection into b.
|
||||
func (c *Conn) Read(b []byte) (int, error) {
|
||||
if len(b) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
n, err := syscall.Read(c.fd, b)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n == 0 {
|
||||
return 0, syscall.ECONNRESET // Peer closed.
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Write writes b to the connection, looping until all bytes are sent.
|
||||
func (c *Conn) Write(b []byte) (int, error) {
|
||||
total := 0
|
||||
for total < len(b) {
|
||||
n, err := syscall.Write(c.fd, b[total:])
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
total += n
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying file descriptor.
|
||||
func (c *Conn) Close() error {
|
||||
return syscall.Close(c.fd)
|
||||
}
|
||||
|
||||
// RemoteAddr returns the peer address of the connection.
|
||||
func (c *Conn) RemoteAddr() netip.AddrPort { return c.remote }
|
||||
|
||||
// Listener wraps a listening TCP socket bound to a local port.
|
||||
type Listener struct {
|
||||
fd int
|
||||
}
|
||||
|
||||
// Listen creates a listening TCP socket bound to port on all interfaces.
|
||||
func Listen(port uint16) (*Listener, error) {
|
||||
fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, syscall.IPPROTO_TCP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Allow quick rebind after restart.
|
||||
if err = syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1); err != nil {
|
||||
syscall.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
addr := &syscall.SockaddrInet4{Port: int(port)}
|
||||
if err = syscall.Bind(fd, addr); err != nil {
|
||||
syscall.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
if err = syscall.Listen(fd, syscall.SOMAXCONN); err != nil {
|
||||
syscall.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
return &Listener{fd: fd}, nil
|
||||
}
|
||||
|
||||
// Accept blocks until an incoming connection arrives and returns it as a Conn.
|
||||
func (l *Listener) Accept(conn *Conn) error {
|
||||
nfd, sa, err := syscall.Accept(l.fd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn.fd = nfd
|
||||
if sa4, ok := sa.(*syscall.SockaddrInet4); ok {
|
||||
conn.remote = netip.AddrPortFrom(netip.AddrFrom4(sa4.Addr), uint16(sa4.Port))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the listening socket.
|
||||
func (l *Listener) Close() error { return syscall.Close(l.fd) }
|
||||
@@ -0,0 +1,107 @@
|
||||
//go:build !tinygo && linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/soypat/lneto/http/httpraw"
|
||||
)
|
||||
|
||||
const listenPort = 8080
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
println("Error: ", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
println("DONE")
|
||||
}
|
||||
|
||||
func run() error {
|
||||
ln, err := Listen(listenPort)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ln.Close()
|
||||
println("listening on port", listenPort)
|
||||
conn := new(Conn)
|
||||
for {
|
||||
err := ln.Accept(conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
visits.Add(1)
|
||||
if err := handle(conn); err != nil {
|
||||
println("handle:", conn.RemoteAddr().String(), err.Error())
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
htmlHead = `<html><body bgcolor="#000080" text="#00FF00"><center>` +
|
||||
`<marquee><font face="Comic Sans MS" size="5" color="#FFFF00">` +
|
||||
`*** WELCOME TO MY HOMEPAGE ***</font></marquee>` +
|
||||
`<h1><blink>YOU ARE VISITOR #`
|
||||
htmlTail = `!</blink></h1>` +
|
||||
`<font color="#FF00FF">Sign my guestbook!</font>` +
|
||||
`<br><hr>Best viewed in Netscape Navigator</center></body></html>`
|
||||
)
|
||||
|
||||
const maxHTTPHeader = 1024
|
||||
|
||||
var (
|
||||
hdr httpraw.Header
|
||||
httpbuf [maxHTTPHeader]byte
|
||||
htmlbuf [512]byte
|
||||
visits atomic.Uint64
|
||||
)
|
||||
|
||||
func handle(conn *Conn) error {
|
||||
hdr.Reset(httpbuf[:0])
|
||||
hdr.EnableBufferGrowth(false) // Limit memory to buffer capacity.
|
||||
const incomingIsResponse = false // We get HTTP requests from clients.
|
||||
deadline := time.Now().Add(50 * time.Millisecond)
|
||||
for time.Until(deadline) > 0 {
|
||||
if _, err := hdr.ReadFromLimited(conn, maxHTTPHeader); err != nil {
|
||||
return err
|
||||
}
|
||||
needmoredata, err := hdr.TryParse(incomingIsResponse)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if needmoredata {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
println("\n\n================\n\n", hdr.String())
|
||||
if time.Since(deadline) > 0 {
|
||||
print("DEADLINE EXCEED: ", hdr.BufferParsed(), "/", hdr.BufferReceived(), " bytes parsed/read\n")
|
||||
return nil
|
||||
}
|
||||
// Prepare tacky HTML response.
|
||||
n := copy(htmlbuf[:], htmlHead)
|
||||
n += len(strconv.AppendUint(htmlbuf[n:n], visits.Load(), 10))
|
||||
n += copy(htmlbuf[n:], htmlTail)
|
||||
contentLen := n
|
||||
hdr.Reset(httpbuf[:0])
|
||||
hdr.SetProtocol("HTTP/1.1")
|
||||
hdr.SetStatus("200", "OK")
|
||||
hdr.Set("Content-Type", "text/html")
|
||||
hdr.SetInt("Content-Length", int64(contentLen), 10)
|
||||
// Here we do some buffer juggling. We use remaining space
|
||||
// of HTTP Header buffer to write the response that will be written over the wire.
|
||||
respbuf := httpbuf[hdr.BufferUsed():]
|
||||
header, err := hdr.AppendResponse(respbuf[:0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn.Write(header)
|
||||
_, err = conn.Write(htmlbuf[:contentLen])
|
||||
return err
|
||||
}
|
||||
+27
-2
@@ -50,6 +50,7 @@ func run() (err error) {
|
||||
flagUseHTTP = false
|
||||
flagHostToResolve = ""
|
||||
flagRequestedIP = ""
|
||||
flagLocalTCPPort = 0
|
||||
flagDoNTP = false
|
||||
flagNoPcap = false
|
||||
flagPprof = false
|
||||
@@ -58,6 +59,7 @@ func run() (err error) {
|
||||
flag.BoolVar(&flagUseHTTP, "ihttp", flagUseHTTP, "Use HTTP tap interface.")
|
||||
flag.StringVar(&flagHostToResolve, "host", flagHostToResolve, "Hostname to resolve via DNS.")
|
||||
flag.StringVar(&flagRequestedIP, "addr", flagRequestedIP, "IP address to request via DHCP.")
|
||||
flag.IntVar(&flagLocalTCPPort, "port", flagLocalTCPPort, "Local TCP source port to use. Zero chooses a pseudo-random port.")
|
||||
flag.BoolVar(&flagDoNTP, "ntp", flagDoNTP, "Do NTP round and print result time")
|
||||
flag.BoolVar(&flagNoPcap, "nopcap", flagNoPcap, "Disable pcap logging.")
|
||||
flag.BoolVar(&flagPprof, "pprof", flagPprof, "Enable CPU profiling.")
|
||||
@@ -79,6 +81,21 @@ func run() (err error) {
|
||||
flag.Usage()
|
||||
return err
|
||||
}
|
||||
reqAddr := [4]byte{192, 168, 1, 96}
|
||||
requestedAddrSet := false
|
||||
if flagRequestedIP != "" {
|
||||
addr, err := netip.ParseAddr(flagRequestedIP)
|
||||
if err != nil || !addr.Is4() {
|
||||
flag.Usage()
|
||||
return fmt.Errorf("invalid requested IPv4 address %q", flagRequestedIP)
|
||||
}
|
||||
reqAddr = addr.As4()
|
||||
requestedAddrSet = true
|
||||
}
|
||||
if flagLocalTCPPort < 0 || flagLocalTCPPort > math.MaxUint16 {
|
||||
flag.Usage()
|
||||
return fmt.Errorf("invalid local TCP port %d", flagLocalTCPPort)
|
||||
}
|
||||
fmt.Println("softrand", softRand)
|
||||
var iface ltesto.Interface
|
||||
if flagUseHTTP {
|
||||
@@ -223,11 +240,14 @@ func run() (err error) {
|
||||
dhcpRetries = 2
|
||||
)
|
||||
timeDHCP := timer("DHCP request completed")
|
||||
results, err := rstack.DoDHCPv4([4]byte{192, 168, 1, 96}, dhcpTimeout, dhcpRetries)
|
||||
results, err := rstack.DoDHCPv4(reqAddr, dhcpTimeout, dhcpRetries)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DHCP failed: %w", err)
|
||||
}
|
||||
timeDHCP()
|
||||
if requestedAddrSet && results.AssignedAddr4 != reqAddr {
|
||||
return fmt.Errorf("DHCP assigned %s, not requested %s", netip.AddrFrom4(results.AssignedAddr4), netip.AddrFrom4(reqAddr))
|
||||
}
|
||||
|
||||
err = stack.AssimilateDHCPResults(results)
|
||||
if err != nil {
|
||||
@@ -302,7 +322,12 @@ func run() (err error) {
|
||||
timeTCPDial := timer("TCP dial (handshake)")
|
||||
const tcpDialTimeout = 8 * time.Second // Was 60 * time.Minute causing 3.6s sleep per iteration!
|
||||
target := netip.AddrPortFrom(addrs[0], 80)
|
||||
err = rstack.DoDialTCP(&conn, uint16(softRand&0xefff)+1024, target, tcpDialTimeout, internetRetries)
|
||||
localPort := uint16(flagLocalTCPPort)
|
||||
if localPort == 0 {
|
||||
localPort = uint16(softRand&0xefff) + 1024
|
||||
}
|
||||
fmt.Printf("TCP target %s local-port=%d\n", target, localPort)
|
||||
err = rstack.DoDialTCP(&conn, localPort, target, tcpDialTimeout, internetRetries)
|
||||
if err != nil {
|
||||
return fmt.Errorf("TCP failed: %w", err)
|
||||
}
|
||||
|
||||
+44
-10
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"io"
|
||||
"slices"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -165,6 +164,7 @@ func (h *Header) ReadFromBytes(b []byte) (int, error) {
|
||||
}
|
||||
|
||||
// BufferReceived returns the amoung of bytes read during calls to Read* methods.
|
||||
// Returns 0 if buffer is invalid/mangled.
|
||||
func (h *Header) BufferReceived() int {
|
||||
if h.flags.hasAny(flagMangledBuffer | flagOOMReached) {
|
||||
return 0
|
||||
@@ -182,12 +182,23 @@ func (h *Header) BufferParsed() int {
|
||||
return h.hbuf.off
|
||||
}
|
||||
|
||||
// BufferUsed returns the raw memory used.
|
||||
//
|
||||
// BufferUsed + BufferFree == BufferCapacity
|
||||
func (h *Header) BufferUsed() int {
|
||||
return len(h.hbuf.buf)
|
||||
}
|
||||
|
||||
// BufferFree returns amount of bytes free in underlying buffer.
|
||||
//
|
||||
// BufferUsed + BufferFree == BufferCapacity
|
||||
func (h *Header) BufferFree() int {
|
||||
return h.hbuf.free()
|
||||
}
|
||||
|
||||
// BufferCapacity returns the total capacity of the underlying buffer.
|
||||
//
|
||||
// BufferUsed + BufferFree == BufferCapacity
|
||||
func (h *Header) BufferCapacity() int {
|
||||
return cap(h.hbuf.buf)
|
||||
}
|
||||
@@ -223,7 +234,7 @@ func (hb *headerBuf) forEach(cb func(key, value []byte) error) error {
|
||||
// h.Reset(httpHeader); h.Parse() // Parse bytes in place with no copying.
|
||||
// h.Reset(nil) // Reuse buffer previously set in a call to Reset.
|
||||
func (h *Header) Reset(buf []byte) {
|
||||
if h.flags.hasAny(flagNoBufferGrow) && len(buf) < 32 {
|
||||
if h.flags.hasAny(flagNoBufferGrow) && cap(buf) < 32 {
|
||||
panic("small buffer and flagNoBufferGrow set")
|
||||
}
|
||||
const persistentFlags = flagNoBufferGrow
|
||||
@@ -250,18 +261,45 @@ func (h *Header) Body() ([]byte, error) {
|
||||
// SetBytes is equivalent to [Header.Set] but with a []byte value. Does not keep reference to value slice.
|
||||
// Calling SetBytes Mangles the buffer.
|
||||
func (h *Header) SetBytes(key string, value []byte) {
|
||||
h.Set(key, unsafe.String(&value[0], len(value)))
|
||||
h.Set(key, b2s(value))
|
||||
}
|
||||
|
||||
// SetInt is equivalent to [Header.Set] but with an integer value i.e: Content-Length header key.
|
||||
// base must be in the range 2..36 (as accepted by [strconv.AppendInt]); other bases are dropped.
|
||||
// SetInt formats the value directly into the header buffer without heap allocation.
|
||||
func (h *Header) SetInt(key string, value int64, base int) {
|
||||
if base < 2 || base > 36 {
|
||||
return // strconv.AppendInt only supports base 2..36.
|
||||
}
|
||||
useKv := h.takeReusableSlot(key)
|
||||
if useKv == nil {
|
||||
h.appendHeaderInt(key, value, base)
|
||||
} else {
|
||||
useKv.value = h.reuseOrAppendInt(useKv.value, value, base)
|
||||
}
|
||||
}
|
||||
|
||||
// Set sets a key-value pair in the HTTP header.
|
||||
// Calling Set mangles the buffer.
|
||||
func (h *Header) Set(key, value string) {
|
||||
useKv := h.takeReusableSlot(key)
|
||||
if useKv == nil {
|
||||
h.appendHeader(key, value)
|
||||
} else {
|
||||
useKv.value = h.reuseOrAppend(useKv.value, value)
|
||||
}
|
||||
}
|
||||
|
||||
// takeReusableSlot returns the valid key-value entry for key with the largest
|
||||
// value buffer (best candidate for in-place reuse) and invalidates any other
|
||||
// entries sharing the key. Returns nil if the key is not present.
|
||||
func (h *Header) takeReusableSlot(key string) *argsKV {
|
||||
hb := &h.hbuf
|
||||
var useKv *argsKV
|
||||
for i := len(hb.headers); len(hb.headers) > 0 && i <= 0; i++ {
|
||||
for i := 0; i < len(hb.headers); i++ {
|
||||
// Search for key-value with largest buffer for value to store value reusing buffer.
|
||||
gotkv := &hb.headers[i]
|
||||
if b2s(hb.musttoken(gotkv.key)) == key {
|
||||
if gotkv.isValid() && b2s(hb.musttoken(gotkv.key)) == key {
|
||||
if useKv == nil {
|
||||
useKv = gotkv
|
||||
} else if gotkv.value.len > useKv.value.len {
|
||||
@@ -272,11 +310,7 @@ func (h *Header) Set(key, value string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if useKv == nil {
|
||||
h.appendHeader(key, value)
|
||||
} else {
|
||||
useKv.value = h.reuseOrAppend(useKv.value, value)
|
||||
}
|
||||
return useKv
|
||||
}
|
||||
|
||||
// Get gets the first value of a key found in the headers. Use [Header.ForEach] to find multiple values corresponding to same key.
|
||||
|
||||
+216
-1
@@ -124,7 +124,7 @@ func BenchmarkParseBytes(b *testing.B) {
|
||||
var hdr Header
|
||||
b.StartTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
for b.Loop() {
|
||||
err := hdr.ParseBytes(asRequest, data)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
@@ -208,3 +208,218 @@ func TestCopyNormalizedHeaderValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderSetOverwrite(t *testing.T) {
|
||||
var h Header
|
||||
h.Reset(nil)
|
||||
h.SetMethod("GET")
|
||||
h.SetRequestURI("/")
|
||||
h.SetProtocol("HTTP/1.1")
|
||||
|
||||
h.Set("Host", "first.example.com")
|
||||
h.Set("Host", "second.example.com")
|
||||
|
||||
got := string(h.Get("Host"))
|
||||
if got != "second.example.com" {
|
||||
t.Errorf("want Host %q, got %q", "second.example.com", got)
|
||||
}
|
||||
req, err := h.AppendRequest(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := strings.Count(string(req), "Host:"); n != 1 {
|
||||
t.Errorf("want 1 Host field in request, got %d:\n%s", n, req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderSetBytesEmptyValue(t *testing.T) {
|
||||
var h Header
|
||||
h.Reset(nil)
|
||||
h.SetBytes("X-Empty", nil)
|
||||
if got := h.Get("X-Empty"); len(got) != 0 {
|
||||
t.Errorf("want empty value, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// buffer/offset past uint16 range silently corrupts or panics.
|
||||
// A header block > 64KiB pushes a field's offset past 65535. tokint truncation
|
||||
// makes Get return the wrong window (silent corruption) or panic on slice bounds.
|
||||
func TestHeader_LargeBufferOverflow(t *testing.T) {
|
||||
const wantVal = "the-canary-value"
|
||||
// Pad with a big header so the canary field lands past offset 65535.
|
||||
pad := strings.Repeat("x", 70000)
|
||||
raw := "GET / HTTP/1.1\r\n" +
|
||||
"X-Pad: " + pad + "\r\n" +
|
||||
"X-Canary: " + wantVal + "\r\n" +
|
||||
"\r\n"
|
||||
|
||||
var h Header
|
||||
err := h.ParseBytes(false, []byte(raw))
|
||||
if err != nil {
|
||||
// Clean rejection of the oversized header is the intended behavior:
|
||||
// no panic, no silent corruption.
|
||||
return
|
||||
}
|
||||
// If it did parse, the value must be correct (never a truncated-offset window).
|
||||
got := string(h.Get("X-Canary"))
|
||||
if got != wantVal {
|
||||
t.Fatalf("overflow corruption: want X-Canary %q, got %q", wantVal, got)
|
||||
}
|
||||
}
|
||||
|
||||
// a complete but malformed header line with no colon must be a hard error,
|
||||
// not errNeedMore (which makes a streaming parser wait forever).
|
||||
func TestHeader_ColonlessLineIsHardError(t *testing.T) {
|
||||
raw := "GET / HTTP/1.1\r\nBadHeaderNoColon\r\n\r\n"
|
||||
var h Header
|
||||
err := h.ParseBytes(false, []byte(raw))
|
||||
if err == nil {
|
||||
t.Fatal("want error on colonless header line, got nil")
|
||||
}
|
||||
if err == errNeedMore {
|
||||
t.Fatalf("colonless line reported as errNeedMore (parser would hang); want a hard error like errInvalidName")
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for a TCP split landing BEFORE the colon (no newline yet) must
|
||||
// stay "need more data" and parse once the rest arrives. This is the case the
|
||||
// Colonless fix must NOT turn into a hard error.
|
||||
func TestHeader_SplitBeforeColonStillParses(t *testing.T) {
|
||||
const part1 = "GET / HTTP/1.1\r\nHost" // split mid-key, before colon+newline
|
||||
const part2 = ": example.com\r\n\r\n"
|
||||
|
||||
var h Header
|
||||
h.Reset(nil)
|
||||
if _, err := h.ReadFromBytes([]byte(part1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
needMore, err := h.TryParse(false)
|
||||
if err != nil && err != errNeedMore {
|
||||
t.Fatalf("split before colon: want errNeedMore/nil, got %v", err)
|
||||
}
|
||||
if !needMore {
|
||||
t.Fatal("want needMoreData=true after partial input")
|
||||
}
|
||||
|
||||
if _, err := h.ReadFromBytes([]byte(part2)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
needMore, err = h.TryParse(false)
|
||||
if err != nil {
|
||||
t.Fatalf("after full input: %v", err)
|
||||
}
|
||||
if needMore {
|
||||
t.Fatal("want needMoreData=false after full input")
|
||||
}
|
||||
if got := string(h.Get("Host")); got != "example.com" {
|
||||
t.Fatalf("want Host %q, got %q", "example.com", got)
|
||||
}
|
||||
}
|
||||
|
||||
// appendHeader must reserve the +1 byte that mustAppendSlice consumes on an
|
||||
// empty buffer. Force cap == len(key)+len(value) to defeat allocator rounding.
|
||||
func TestHeader_AppendHeaderExactCapNoPanic(t *testing.T) {
|
||||
const key, value = "K", "V"
|
||||
buf := make([]byte, 0, len(key)+len(value)) // exact cap, no slack.
|
||||
var h Header
|
||||
h.Reset(buf)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("appendHeader panicked on exact-cap buffer: %v", r)
|
||||
}
|
||||
}()
|
||||
h.Add(key, value)
|
||||
if got := string(h.Get(key)); got != value {
|
||||
t.Fatalf("want %q, got %q", value, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Add/Set on a full buffer with growth disabled must drop gracefully (flag OOM),
|
||||
// never panic. Panicking is unacceptable for this package.
|
||||
func TestHeader_AddFullBufferNoPanic(t *testing.T) {
|
||||
buf := make([]byte, 0, 40) // Small cap; enough for Reset (len 0) but not the field below.
|
||||
var h Header
|
||||
h.Reset(buf)
|
||||
h.EnableBufferGrowth(false)
|
||||
h.SetMethod("GET")
|
||||
h.SetRequestURI("/")
|
||||
h.SetProtocol("HTTP/1.1")
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("Add on full no-grow buffer panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
h.Add("X-Very-Long-Header-Key", "a-value-that-cannot-possibly-fit-in-the-buffer")
|
||||
|
||||
// Graceful drop: request marshalling reports OOM instead of emitting bad data.
|
||||
if _, err := h.AppendRequest(nil); err == nil {
|
||||
t.Fatal("want OOM error after dropped Add, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeader_SetInt(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
value int64
|
||||
base int
|
||||
want string
|
||||
}{
|
||||
{"decimal", 1234, 10, "1234"},
|
||||
{"zero", 0, 10, "0"},
|
||||
{"negative", -42, 10, "-42"},
|
||||
{"maxint64", 9223372036854775807, 10, "9223372036854775807"},
|
||||
{"minint64", -9223372036854775808, 10, "-9223372036854775808"},
|
||||
{"hex", 255, 16, "ff"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var h Header
|
||||
h.Reset(nil)
|
||||
h.SetInt("Content-Length", tc.value, tc.base)
|
||||
if got := string(h.Get("Content-Length")); got != tc.want {
|
||||
t.Fatalf("want %q, got %q", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SetInt on an existing key must reuse the slot in place (single field, latest value).
|
||||
func TestHeader_SetIntOverwrite(t *testing.T) {
|
||||
var h Header
|
||||
h.Reset(nil)
|
||||
h.SetMethod("GET")
|
||||
h.SetRequestURI("/")
|
||||
h.SetProtocol("HTTP/1.1")
|
||||
|
||||
h.SetInt("Content-Length", 100, 10)
|
||||
h.SetInt("Content-Length", 5, 10) // shorter, must fit in old slot.
|
||||
|
||||
if got := string(h.Get("Content-Length")); got != "5" {
|
||||
t.Fatalf("want Content-Length %q, got %q", "5", got)
|
||||
}
|
||||
req, err := h.AppendRequest(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := strings.Count(string(req), "Content-Length:"); n != 1 {
|
||||
t.Errorf("want 1 Content-Length field, got %d:\n%s", n, req)
|
||||
}
|
||||
}
|
||||
|
||||
// SetInt must not heap-allocate: it must format directly into the header buffer.
|
||||
func TestHeader_SetIntNoAlloc(t *testing.T) {
|
||||
buf := make([]byte, 0, 256)
|
||||
var h Header
|
||||
h.Reset(buf)
|
||||
h.EnableBufferGrowth(false)
|
||||
h.Add("Content-Length", "0000000000000000000000") // pre-size a reusable slot.
|
||||
allocs := testing.AllocsPerRun(100, func() {
|
||||
h.SetInt("Content-Length", 1234567890, 10)
|
||||
})
|
||||
if allocs != 0 {
|
||||
t.Fatalf("SetInt allocated %v times, want 0", allocs)
|
||||
}
|
||||
if got := string(h.Get("Content-Length")); got != "1234567890" {
|
||||
t.Fatalf("want %q, got %q", "1234567890", got)
|
||||
}
|
||||
}
|
||||
|
||||
+117
-20
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"slices"
|
||||
"strconv"
|
||||
"unsafe"
|
||||
|
||||
"github.com/soypat/lneto/internal"
|
||||
@@ -27,8 +28,14 @@ var (
|
||||
errNeedMethodURI = errors.New("need method/request URI to create request header")
|
||||
errBadStatusCodeTxt = errors.New("invalid status code or text")
|
||||
errCookiesParsed = errors.New("cookies already parsed, reset before parsing again")
|
||||
errBufferTooLarge = errors.New("httpraw: buffer exceeds max size (offsets are uint16)")
|
||||
)
|
||||
|
||||
// maxBufLen bounds the header buffer. Offsets/lengths are stored as uint16
|
||||
// (tokint); a buffer past this would truncate/overflow those, silently
|
||||
// returning the wrong bytes or panicking on a wrapped slice bound.
|
||||
const maxBufLen = 0xffff
|
||||
|
||||
type headerBuf struct {
|
||||
// buf[:len] holds entire HTTP header data, which may be normalized by [flags]. buf[off:len] holds data not yet processed during parsing.
|
||||
buf []byte
|
||||
@@ -92,6 +99,9 @@ func (h *Header) parse(asResponse bool) (err error) {
|
||||
}
|
||||
|
||||
func (h *Header) parseFirstLine(asResponse bool) (err error) {
|
||||
if len(h.hbuf.buf) > maxBufLen {
|
||||
return errBufferTooLarge // Offsets would overflow uint16 tokint.
|
||||
}
|
||||
if asResponse {
|
||||
h.statusCode, h.statusText, h.flags, err = h.hbuf.parseFirstLineResponse(h.flags)
|
||||
} else {
|
||||
@@ -302,32 +312,22 @@ func (h *Header) reuseOrAppend(tok headerSlice, value string) headerSlice {
|
||||
|
||||
func (h *Header) appendSlice(value string) headerSlice {
|
||||
debuglog("http:appendslice:start")
|
||||
free := h.hbuf.free()
|
||||
if len(value) > free {
|
||||
if h.flags.hasAny(flagNoBufferGrow) {
|
||||
h.flags |= flagOOMReached
|
||||
return headerSlice{}
|
||||
}
|
||||
debuglog("http:appendslice:grow-buf")
|
||||
h.hbuf.buf = slices.Grow(h.hbuf.buf, len(value)+1) // Grow 1 beyond due to slice validity.
|
||||
if !h.reserve(len(value)) {
|
||||
return headerSlice{}
|
||||
}
|
||||
h.flags |= flagMangledBuffer
|
||||
return h.hbuf.mustAppendSlice(value)
|
||||
}
|
||||
|
||||
func (h *Header) appendHeader(key, value string) {
|
||||
hb := &h.hbuf
|
||||
free := hb.free()
|
||||
buf := h.hbuf.buf
|
||||
|
||||
if len(key)+len(value) > free {
|
||||
if h.flags.hasAny(flagNoBufferGrow) {
|
||||
panic(errSmallBuffer)
|
||||
}
|
||||
debuglog("http:appendhdr:grow-buf")
|
||||
hb.buf = slices.Grow(buf, len(key)+len(value))
|
||||
// reserve accounts for the byte-0 reservation mustAppendSlice makes on an
|
||||
// empty buffer, and drops (flagging OOM) rather than panicking when growth
|
||||
// is disabled and space runs out.
|
||||
if !h.reserve(len(key) + len(value)) {
|
||||
return
|
||||
}
|
||||
h.flags |= flagMangledBuffer
|
||||
hb := &h.hbuf
|
||||
k := hb.mustAppendSlice(key)
|
||||
v := hb.mustAppendSlice(value)
|
||||
debuglog("http:appendhdr:grow-hdrs")
|
||||
@@ -337,6 +337,100 @@ func (h *Header) appendHeader(key, value string) {
|
||||
})
|
||||
}
|
||||
|
||||
// appendHeaderInt is appendHeader's integer counterpart: it appends key and the
|
||||
// formatted integer value as a new header field.
|
||||
func (h *Header) appendHeaderInt(key string, value int64, base int) {
|
||||
n := intLen(value, base)
|
||||
if !h.reserve(len(key) + n) {
|
||||
return // Drop and flag OOM; never panic.
|
||||
}
|
||||
h.flags |= flagMangledBuffer
|
||||
hb := &h.hbuf
|
||||
k := hb.mustAppendSlice(key)
|
||||
v := hb.mustAppendInt(value, base)
|
||||
hb.headers = append(hb.headers, argsKV{
|
||||
key: k,
|
||||
value: v,
|
||||
})
|
||||
}
|
||||
|
||||
// reserve ensures need free bytes are available in the buffer, growing it when
|
||||
// permitted. It accounts for the byte-0 reservation on an empty buffer (see
|
||||
// mustAppendSlice). It returns false and sets flagOOMReached when the space
|
||||
// cannot be guaranteed: a tokint offset overflow, or a full buffer with
|
||||
// flagNoBufferGrow set.
|
||||
func (h *Header) reserve(need int) bool {
|
||||
hb := &h.hbuf
|
||||
if len(hb.buf) == 0 {
|
||||
need++ // mustAppend* reserves byte 0 on an empty buffer.
|
||||
}
|
||||
if len(hb.buf)+need > maxBufLen {
|
||||
h.flags |= flagOOMReached // Offsets would overflow uint16 tokint.
|
||||
return false
|
||||
}
|
||||
if need > hb.free() {
|
||||
if h.flags.hasAny(flagNoBufferGrow) {
|
||||
h.flags |= flagOOMReached
|
||||
return false
|
||||
}
|
||||
hb.buf = slices.Grow(hb.buf, need)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// reuseOrAppendInt writes value into tok's slot in place when it fits, avoiding
|
||||
// any buffer growth; otherwise it appends a fresh slot.
|
||||
func (h *Header) reuseOrAppendInt(tok headerSlice, value int64, base int) headerSlice {
|
||||
n := intLen(value, base)
|
||||
if int(tok.len) >= n {
|
||||
// Reuse: format directly over the existing slot. No free space needed
|
||||
// since n <= tok.len and the slot already lives inside buf.
|
||||
v := strconv.AppendInt(h.hbuf.buf[tok.start:tok.start], value, base)
|
||||
tok.len = tokint(len(v))
|
||||
h.flags |= flagMangledBuffer
|
||||
return tok
|
||||
}
|
||||
return h.appendInt(value, base, n)
|
||||
}
|
||||
|
||||
// appendInt reserves space (growing or flagging OOM) and appends value as a new slot.
|
||||
func (h *Header) appendInt(value int64, base, n int) headerSlice {
|
||||
if !h.reserve(n) {
|
||||
return headerSlice{} // Drop and flag OOM; never panic.
|
||||
}
|
||||
h.flags |= flagMangledBuffer
|
||||
return h.hbuf.mustAppendInt(value, base)
|
||||
}
|
||||
|
||||
// mustAppendInt formats value into the buffer's free region and commits it.
|
||||
// The caller must have reserved at least intLen(value, base) free bytes.
|
||||
func (hb *headerBuf) mustAppendInt(value int64, base int) headerSlice {
|
||||
L := len(hb.buf)
|
||||
if L == 0 {
|
||||
L++ // Valid key-values start after byte 0.
|
||||
}
|
||||
v := strconv.AppendInt(hb.buf[L:L], value, base)
|
||||
hb.buf = hb.buf[:L+len(v)]
|
||||
return hb.slice(hb.buf[L : L+len(v)])
|
||||
}
|
||||
|
||||
// intLen returns the number of bytes strconv.AppendInt would emit for value in
|
||||
// the given base (including a leading minus sign for negatives). Used to size
|
||||
// the buffer and to test whether a value fits an existing slot without writing.
|
||||
func intLen(value int64, base int) int {
|
||||
n := 1
|
||||
u := uint64(value)
|
||||
if value < 0 {
|
||||
n++ // Leading minus sign.
|
||||
u = -u // Two's-complement magnitude; correct even for math.MinInt64.
|
||||
}
|
||||
for u >= uint64(base) {
|
||||
u /= uint64(base)
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (hb *headerBuf) noKV() argsKV { return argsKV{} }
|
||||
|
||||
func (hb *headerBuf) next(ss *scannerState) argsKV {
|
||||
@@ -373,8 +467,11 @@ func (hb *headerBuf) next(ss *scannerState) argsKV {
|
||||
ss.err = errInvalidName
|
||||
return hb.noKV()
|
||||
} else if n < 0 {
|
||||
// No colon found, probably missing data.
|
||||
ss.err = errNeedMore
|
||||
// A newline is present (x>=0 reached here) but the line has no
|
||||
// colon: malformed, not incomplete. A split arriving before the
|
||||
// colon has no newline yet and is caught by the x<0 branch above,
|
||||
// so it still returns errNeedMore.
|
||||
ss.err = errInvalidName
|
||||
return hb.noKV()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// Command benchci parses `go test -bench` output and renders a Markdown
|
||||
// report. It is repo-owned tooling so CI does not depend on third-party
|
||||
// benchmark actions. With -count>1 it reports the median of each metric to
|
||||
// reduce noise.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// commentMarker is a stable HTML marker so a PR commenter can locate and
|
||||
// update an existing report comment instead of posting duplicates.
|
||||
const commentMarker = "<!-- lneto-bench -->"
|
||||
|
||||
func main() {
|
||||
var (
|
||||
currentPath = flag.String("current", "", "path to `go test -bench` output (default stdin)")
|
||||
outPath = flag.String("out", "", "path to write Markdown report (default stdout)")
|
||||
title = flag.String("title", "Benchmark results", "report heading")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
in := io.Reader(os.Stdin)
|
||||
if *currentPath != "" {
|
||||
f, err := os.Open(*currentPath)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
in = f
|
||||
}
|
||||
|
||||
results, err := parse(in)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
||||
out := io.Writer(os.Stdout)
|
||||
if *outPath != "" {
|
||||
f, err := os.Create(*outPath)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
out = f
|
||||
}
|
||||
|
||||
if err := render(out, *title, results); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, "benchci:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// result is the aggregated metrics for a single benchmark.
|
||||
type result struct {
|
||||
pkg string
|
||||
name string // benchmark name including the GOMAXPROCS suffix, e.g. BenchmarkFoo-12
|
||||
|
||||
nsPerOp []float64
|
||||
bytesPerOp []float64
|
||||
allocsPerOp []float64
|
||||
}
|
||||
|
||||
// parse reads `go test -bench -benchmem` output and groups metric samples by
|
||||
// package and benchmark name. Repeated lines (from -count) accumulate samples.
|
||||
func parse(r io.Reader) ([]result, error) {
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
|
||||
byKey := make(map[string]*result)
|
||||
var order []string
|
||||
var pkg string
|
||||
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
if fields[0] == "pkg:" && len(fields) >= 2 {
|
||||
pkg = fields[1]
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(fields[0], "Benchmark") || len(fields) < 4 {
|
||||
continue
|
||||
}
|
||||
// fields: name iters value unit [value unit]...
|
||||
name := fields[0]
|
||||
if _, err := strconv.Atoi(fields[1]); err != nil {
|
||||
continue // second field must be the iteration count
|
||||
}
|
||||
|
||||
key := pkg + "\x00" + name
|
||||
res := byKey[key]
|
||||
if res == nil {
|
||||
res = &result{pkg: pkg, name: name}
|
||||
byKey[key] = res
|
||||
order = append(order, key)
|
||||
}
|
||||
parseMetrics(res, fields[2:])
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]result, 0, len(order))
|
||||
for _, k := range order {
|
||||
out = append(out, *byKey[k])
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].pkg != out[j].pkg {
|
||||
return out[i].pkg < out[j].pkg
|
||||
}
|
||||
return out[i].name < out[j].name
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseMetrics consumes (value, unit) pairs and appends the metrics benchci
|
||||
// reports on. Unknown metrics are ignored.
|
||||
func parseMetrics(res *result, tokens []string) {
|
||||
for i := 0; i+1 < len(tokens); i += 2 {
|
||||
v, err := strconv.ParseFloat(tokens[i], 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
switch tokens[i+1] {
|
||||
case "ns/op":
|
||||
res.nsPerOp = append(res.nsPerOp, v)
|
||||
case "B/op":
|
||||
res.bytesPerOp = append(res.bytesPerOp, v)
|
||||
case "allocs/op":
|
||||
res.allocsPerOp = append(res.allocsPerOp, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// median returns the median of samples. ok is false when there are no samples.
|
||||
func median(samples []float64) (value float64, ok bool) {
|
||||
if len(samples) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
s := append([]float64(nil), samples...)
|
||||
sort.Float64s(s)
|
||||
n := len(s)
|
||||
if n%2 == 1 {
|
||||
return s[n/2], true
|
||||
}
|
||||
return (s[n/2-1] + s[n/2]) / 2, true
|
||||
}
|
||||
|
||||
func render(w io.Writer, title string, results []result) error {
|
||||
bw := bufio.NewWriter(w)
|
||||
fmt.Fprintf(bw, "%s\n\n", commentMarker)
|
||||
fmt.Fprintf(bw, "### %s\n\n", title)
|
||||
|
||||
if len(results) == 0 {
|
||||
fmt.Fprintln(bw, "_No benchmarks found._")
|
||||
return bw.Flush()
|
||||
}
|
||||
|
||||
fmt.Fprintln(bw, "_Timing results (`ns/op`) depend on the host CPU and are only a rough guideline. Memory results (`B/op` and `allocs/op`) are not affected._")
|
||||
fmt.Fprintln(bw)
|
||||
fmt.Fprintln(bw, "| Package | Benchmark | ns/op | B/op | allocs/op |")
|
||||
fmt.Fprintln(bw, "|---|---|---:|---:|---:|")
|
||||
for _, r := range results {
|
||||
fmt.Fprintf(bw, "| %s | %s | %s | %s | %s |\n",
|
||||
shortPkg(r.pkg), r.name,
|
||||
formatNs(median(r.nsPerOp)),
|
||||
formatCount(median(r.bytesPerOp)),
|
||||
formatCount(median(r.allocsPerOp)),
|
||||
)
|
||||
}
|
||||
return bw.Flush()
|
||||
}
|
||||
|
||||
// shortPkg trims the well-known module prefix for readability.
|
||||
func shortPkg(pkg string) string {
|
||||
const prefix = "github.com/soypat/lneto/"
|
||||
if pkg == "" {
|
||||
return "-"
|
||||
}
|
||||
return strings.TrimPrefix(pkg, prefix)
|
||||
}
|
||||
|
||||
func formatCount(v float64, ok bool) string {
|
||||
if !ok {
|
||||
return "-"
|
||||
}
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// formatNs renders a ns/op value using a human-friendly time unit.
|
||||
func formatNs(v float64, ok bool) string {
|
||||
if !ok {
|
||||
return "-"
|
||||
}
|
||||
switch {
|
||||
case v >= 1e9:
|
||||
return fmt.Sprintf("%.3f s", v/1e9)
|
||||
case v >= 1e6:
|
||||
return fmt.Sprintf("%.3f ms", v/1e6)
|
||||
case v >= 1e3:
|
||||
return fmt.Sprintf("%.3f µs", v/1e3)
|
||||
default:
|
||||
return fmt.Sprintf("%.3f ns", v)
|
||||
}
|
||||
}
|
||||
+29
-14
@@ -2,16 +2,16 @@ package internal
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
LevelTrace slog.Level = slog.LevelDebug - 2
|
||||
|
||||
usePrintLogAllocs = true
|
||||
usePrintLogAllocs = HeapAllocDebugging
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -19,9 +19,13 @@ var (
|
||||
lastAllocs uint64
|
||||
lastMallocs uint64
|
||||
allocmu sync.Mutex
|
||||
allocbuf [256]byte
|
||||
allocbuf [128]byte
|
||||
)
|
||||
|
||||
func LogAttrsAndAllocs(allocmsg string, l *slog.Logger, level slog.Level, msg string, attrs ...slog.Attr) {
|
||||
logAttrsAndAllocs(allocmsg, l, level, msg, attrs...)
|
||||
}
|
||||
|
||||
func LogAllocs(msg string) {
|
||||
allocmu.Lock()
|
||||
runtime.ReadMemStats(&memstats)
|
||||
@@ -29,23 +33,34 @@ func LogAllocs(msg string) {
|
||||
allocmu.Unlock()
|
||||
return
|
||||
}
|
||||
inc := int64(memstats.TotalAlloc) - int64(lastAllocs)
|
||||
numAlloc := int64(memstats.Mallocs) - int64(lastMallocs)
|
||||
free := memstats.HeapSys - memstats.HeapInuse
|
||||
if usePrintLogAllocs {
|
||||
// Branch used when debugheaplog enabled
|
||||
print("[ALLOC] ", msg)
|
||||
print(" inc=", int64(memstats.TotalAlloc)-int64(lastAllocs))
|
||||
print(" n=", int64(memstats.Mallocs)-int64(lastMallocs))
|
||||
print(" inc=", inc)
|
||||
print(" n=", numAlloc)
|
||||
print(" heap=", memstats.HeapAlloc)
|
||||
print(" free=", memstats.HeapSys-memstats.HeapInuse)
|
||||
print(" free=", free)
|
||||
print(" tot=", memstats.TotalAlloc)
|
||||
println()
|
||||
} else {
|
||||
n := copy(allocbuf[:], "[ALLOC] ")
|
||||
n += copy(allocbuf[n:], msg)
|
||||
n += copyValueInt(allocbuf[n:], "inc", int64(memstats.TotalAlloc)-int64(lastAllocs))
|
||||
n += copyValueInt(allocbuf[n:], "n", int64(memstats.Mallocs)-int64(lastMallocs))
|
||||
n += copyValueUint(allocbuf[n:], "heap", memstats.HeapAlloc)
|
||||
n += copyValueUint(allocbuf[n:], "free", memstats.HeapSys-memstats.HeapInuse)
|
||||
n += copyValueUint(allocbuf[n:], "tot", memstats.TotalAlloc)
|
||||
println(unsafe.String(&allocbuf[0], n))
|
||||
buf := allocbuf[:len(allocbuf)-1] // keep one character for newline.
|
||||
n := copy(buf[:], "[ALLOC] ")
|
||||
n += copy(buf[n:], msg)
|
||||
n += copyValueInt(buf[n:], "inc", inc)
|
||||
n += copyValueInt(buf[n:], "n", numAlloc)
|
||||
n += copyValueUint(buf[n:], "heap", memstats.HeapAlloc)
|
||||
n += copyValueUint(buf[n:], "free", free)
|
||||
lastN := copyValueUint(buf[n:], "tot", memstats.TotalAlloc)
|
||||
n += lastN
|
||||
allocbuf[n] = '\n' // n will never be larger than len(allocbuf)-1
|
||||
os.Stdout.Write(allocbuf[:n+1])
|
||||
if lastN == 0 {
|
||||
n2 := copy(allocbuf[:], "[WARN] ALLOC BUF OVERRUN\n")
|
||||
os.Stdout.Write(allocbuf[:n2])
|
||||
}
|
||||
}
|
||||
lastAllocs = memstats.TotalAlloc
|
||||
lastMallocs = memstats.Mallocs
|
||||
|
||||
@@ -4,7 +4,6 @@ package internal
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
@@ -22,10 +21,13 @@ func LogEnabled(l *slog.Logger, lvl slog.Level) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func logAttrsAndAllocs(allocmsg string, l *slog.Logger, level slog.Level, msg string, attrs ...slog.Attr) {
|
||||
LogAttrs(nil, level, msg, attrs...) // already logs attributes
|
||||
}
|
||||
|
||||
func LogAttrs(_ *slog.Logger, level slog.Level, msg string, attrs ...slog.Attr) {
|
||||
now := time.Now()
|
||||
n := len(now.AppendFormat(timebuf[:0], timefmt))
|
||||
LogAllocs(msg)
|
||||
print("time=", unsafe.String(&timebuf[0], n), " ")
|
||||
if level == LevelTrace {
|
||||
print("TRACE ")
|
||||
@@ -35,7 +37,6 @@ func LogAttrs(_ *slog.Logger, level slog.Level, msg string, attrs ...slog.Attr)
|
||||
print(level.String(), " ")
|
||||
}
|
||||
print(msg)
|
||||
|
||||
for _, a := range attrs {
|
||||
switch a.Value.Kind() {
|
||||
case slog.KindString:
|
||||
@@ -49,12 +50,5 @@ func LogAttrs(_ *slog.Logger, level slog.Level, msg string, attrs ...slog.Attr)
|
||||
}
|
||||
}
|
||||
println()
|
||||
allocmu.Lock()
|
||||
runtime.ReadMemStats(&memstats)
|
||||
if lastAllocs != memstats.TotalAlloc {
|
||||
print("alloc increase in heaplog")
|
||||
}
|
||||
lastAllocs = memstats.TotalAlloc
|
||||
lastMallocs = memstats.Mallocs
|
||||
allocmu.Unlock()
|
||||
LogAllocs(msg)
|
||||
}
|
||||
|
||||
@@ -21,3 +21,9 @@ func LogAttrs(l *slog.Logger, level slog.Level, msg string, attrs ...slog.Attr)
|
||||
l.LogAttrs(context.Background(), level, msg, attrs...)
|
||||
}
|
||||
}
|
||||
|
||||
func logAttrsAndAllocs(allocmsg string, l *slog.Logger, level slog.Level, msg string, attrs ...slog.Attr) {
|
||||
LogAllocs(allocmsg)
|
||||
LogAttrs(l, level, msg, attrs...)
|
||||
LogAllocs(msg) // Should not log again unless LogAttrs allocated.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package ltesto
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
)
|
||||
|
||||
// NewSched creates a cooperative two-goroutine scheduler modelling a
|
||||
// coroutine handoff: the scheduled (stack) goroutine drives the [SchedGoro] handle
|
||||
// while the controlling test thread drives the [SchedDriver] handle. Splitting the
|
||||
// API across two handles makes it impossible to call a goroutine-side method
|
||||
// from the test thread, or vice versa.
|
||||
func NewSched(t testing.TB) *Sched {
|
||||
return &Sched{
|
||||
t: t,
|
||||
goroYieldSignal: make(chan struct{}),
|
||||
goroContinueSignal: make(chan struct{}),
|
||||
finishChan: make(chan error, 1),
|
||||
timeout: time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Sched is the shared state behind a [SchedGoro]/[SchedDriver] pair. It exposes no
|
||||
// handoff methods directly; obtain a handle with [Sched.Goro] (for the
|
||||
// scheduled goroutine) or [Sched.Driver] (for the test thread).
|
||||
type Sched struct {
|
||||
t testing.TB
|
||||
// when stack backs off it signals here and waits until channel read or timeout.
|
||||
goroYieldSignal chan struct{}
|
||||
// when main goroutine is ready for more information this channel is written to to signal waiting on stack activity.
|
||||
goroContinueSignal chan struct{}
|
||||
finishChan chan error
|
||||
finishcalled atomic.Bool
|
||||
coroCalls atomic.Int32
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// AwaitGoroYield blocks until the coroutine suspends itself via [SchedGoro.Yield].
|
||||
func (ss *Sched) AwaitGoroYield() {
|
||||
select {
|
||||
case <-ss.goroYieldSignal:
|
||||
case <-time.After(ss.timeout):
|
||||
ss.t.Fatal("timeout waiting for stack to backoff")
|
||||
}
|
||||
}
|
||||
|
||||
// AwaitGoroYieldOrDone blocks until the coroutine either parks itself via
|
||||
// [SchedGoro.Yield] (returning done=false) or terminates via [SchedGoro.FinishWithErr]
|
||||
// /[SchedGoro.Finish] (returning done=true and the terminal error). It lets a driver
|
||||
// loop service an a-priori-unknown number of yields and still observe completion in
|
||||
// the same select, avoiding the deadlock of guessing whether the goroutine will yield
|
||||
// again. Do not mix with [Sched.Done] on the same scheduler.
|
||||
func (ss *Sched) AwaitGoroYieldOrDone() (done bool, err error) {
|
||||
select {
|
||||
case <-ss.goroYieldSignal:
|
||||
return false, nil
|
||||
case err = <-ss.finishChan:
|
||||
return true, err
|
||||
case <-time.After(ss.timeout):
|
||||
ss.t.Fatal("timeout waiting for stack to yield or finish")
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// YieldToGoro wakes a coroutine parked in [SchedGoro.Yield], letting the goroutine run on.
|
||||
func (ss *Sched) YieldToGoro() {
|
||||
select {
|
||||
case ss.goroContinueSignal <- struct{}{}:
|
||||
case <-time.After(ss.timeout):
|
||||
ss.t.Fatal("timeout while trying to yield to stack")
|
||||
}
|
||||
}
|
||||
|
||||
// Done returns the channel that receives the coroutine's terminal error from
|
||||
// [SchedGoro.FinishWithErr]. It may only be called once.
|
||||
func (ss *Sched) Done() <-chan error {
|
||||
if ss.finishcalled.CompareAndSwap(false, true) {
|
||||
return ss.finishChan
|
||||
}
|
||||
panic("Done called twice")
|
||||
}
|
||||
|
||||
// Goro returns the handle whose methods must be called from inside the
|
||||
// scheduled (stack) goroutine.
|
||||
func (ss *Sched) Goro() SchedGoro {
|
||||
if !ss.coroCalls.CompareAndSwap(0, 1) {
|
||||
panic("only one goroutine supported for now")
|
||||
}
|
||||
return SchedGoro{ss: ss}
|
||||
}
|
||||
|
||||
// SchedGoro is the coroutine-side handle of a [Sched]. Every method MUST be
|
||||
// called from inside the scheduled goroutine and never from the test thread.
|
||||
type SchedGoro struct{ ss *Sched }
|
||||
|
||||
// Yield suspends the goroutine at a backoff point and parks until the driver
|
||||
// calls [SchedDriver.YieldToGoro]. Its signature satisfies [lneto.BackoffStrategy] so it
|
||||
// can be passed directly as the stack's backoff strategy.
|
||||
func (c SchedGoro) Yield(consecutiveBackoffs uint) time.Duration {
|
||||
ss := c.ss
|
||||
timeout := time.After(ss.timeout)
|
||||
select {
|
||||
case ss.goroYieldSignal <- struct{}{}:
|
||||
case <-timeout:
|
||||
ss.t.Fatal("timeout backing off, possible race condition? Multiple stacks using same backoff is unexpected pattern")
|
||||
}
|
||||
select {
|
||||
case <-ss.goroContinueSignal:
|
||||
case <-timeout:
|
||||
ss.t.Fatal("timeout waiting for continue")
|
||||
}
|
||||
return lneto.BackoffFlagNop // backoff yield implemented on our side.
|
||||
}
|
||||
|
||||
// FinishWithErr terminates the coroutine, handing err to the driver's [SchedDriver.Done]
|
||||
// channel. It must be called at most once.
|
||||
func (c SchedGoro) FinishWithErr(err error) {
|
||||
ss := c.ss
|
||||
if len(ss.finishChan) != 0 {
|
||||
ss.t.Fatal("Coro.FinishWithErr can be called once only")
|
||||
}
|
||||
ss.finishChan <- err
|
||||
}
|
||||
|
||||
// Finish is just shorthand for c.FinishWithErr(nil).
|
||||
func (c SchedGoro) Finish() {
|
||||
c.FinishWithErr(nil)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
var (
|
||||
ErrRingBufferFull = lneto.ErrBufferFull
|
||||
errRingNoData = errors.New("lneto/ring: empty write")
|
||||
errInvalidCommit = errors.New("lneto/ring: invalid commit amount")
|
||||
errInvalidDiscard = errors.New("lneto/ring: invalid discard amount")
|
||||
errDiscardExceeds = errors.New("lneto/ring: discard exceeds length")
|
||||
errOffsetOverflow = errors.New("lneto/ring: offset too large (32 bit overflow)")
|
||||
@@ -92,6 +93,58 @@ func (r *Ring) Write(b []byte) (int, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// writeStart returns the buffer index where the next [Ring.Write] or
|
||||
// [Ring.Commit] begins, matching [Ring.Write]'s placement (including wrap).
|
||||
func (r *Ring) writeStart() int {
|
||||
if r.End == 0 {
|
||||
return r.Off // Empty: writing begins at Off.
|
||||
}
|
||||
if r.End == len(r.Buf) {
|
||||
return 0 // Tail full: next byte wraps to the start.
|
||||
}
|
||||
return r.End
|
||||
}
|
||||
|
||||
// PeekWrite stages b offset bytes past the write position (see
|
||||
// [Ring.writeStart]) without advancing it, so the bytes are not yet readable; a
|
||||
// later [Ring.Commit] reveals them. It reports false, writing nothing, when
|
||||
// offset is negative or offset+len(b) exceeds [Ring.Free]. Used to place
|
||||
// out-of-order data ahead of a gap that a normal Write later fills.
|
||||
func (r *Ring) PeekWrite(b []byte, offset int) bool {
|
||||
if offset < 0 || offset+len(b) > r.Free() {
|
||||
return false
|
||||
}
|
||||
off := r.writeStart() + offset
|
||||
if off >= len(r.Buf) {
|
||||
off -= len(r.Buf)
|
||||
}
|
||||
n := copy(r.Buf[off:], b)
|
||||
if n < len(b) {
|
||||
copy(r.Buf, b[n:])
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Commit advances the write pointer by n bytes, making readable any bytes
|
||||
// previously staged with [Ring.PeekWrite]. It copies nothing and errors if n is
|
||||
// not positive or exceeds [Ring.Free].
|
||||
func (r *Ring) Commit(n int) error {
|
||||
if n <= 0 {
|
||||
return errInvalidCommit
|
||||
} else if n > r.Free() {
|
||||
return ErrRingBufferFull
|
||||
}
|
||||
if r.End == 0 {
|
||||
r.End = r.Off // Match Write: commit begins at Off when empty.
|
||||
}
|
||||
end := r.End + n
|
||||
if end > len(r.Buf) {
|
||||
end -= len(r.Buf)
|
||||
}
|
||||
r.End = end // Never 0 here: end==len(Buf) is kept.
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadDiscard is a performance auxiliary method that performs a dummy read or no-op read
|
||||
// for advancing the read pointer n bytes without actually copying data.
|
||||
// This method panics if amount of bytes is more than buffered (see [Ring.Buffered]).
|
||||
|
||||
@@ -580,3 +580,84 @@ func canonRing(r *Ring) {
|
||||
r.onReadEnd(1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingPeekWriteCommit verifies that bytes staged ahead of a gap with
|
||||
// PeekWrite become readable in order once the gap is filled and committed.
|
||||
func TestRingPeekWriteCommit(t *testing.T) {
|
||||
r := &Ring{Buf: make([]byte, 16)}
|
||||
// Stage "BBBB" 4 bytes ahead of the write position (the gap).
|
||||
if !r.PeekWrite([]byte("BBBB"), 4) {
|
||||
t.Fatal("PeekWrite should fit")
|
||||
}
|
||||
// Staged bytes are not yet readable.
|
||||
if r.Buffered() != 0 {
|
||||
t.Fatalf("staged bytes must not be readable, buffered=%d", r.Buffered())
|
||||
}
|
||||
// Fill the gap with a normal write, then commit the staged tail.
|
||||
if _, err := r.Write([]byte("AAAA")); err != nil {
|
||||
t.Fatal("gap write:", err)
|
||||
}
|
||||
if err := r.Commit(4); err != nil {
|
||||
t.Fatal("commit:", err)
|
||||
}
|
||||
got := make([]byte, 8)
|
||||
n, err := r.Read(got)
|
||||
if err != nil {
|
||||
t.Fatal("read:", err)
|
||||
}
|
||||
if string(got[:n]) != "AAAABBBB" {
|
||||
t.Fatalf("read %q, want AAAABBBB", got[:n])
|
||||
}
|
||||
testRingSanity(t, r)
|
||||
}
|
||||
|
||||
// TestRingPeekWriteWrap exercises PeekWrite/Commit when the staged region wraps
|
||||
// across the end of the backing buffer. Existing data at Off=2,End=6 puts the
|
||||
// write position at index 6, so a 2-byte gap fills indices 6,7 and the staged
|
||||
// tail wraps to indices 0,1.
|
||||
func TestRingPeekWriteWrap(t *testing.T) {
|
||||
r := &Ring{Buf: make([]byte, 8)}
|
||||
setRingData(t, r, 2, []byte("WXYZ")) // Off=2, End=6, 4 bytes buffered.
|
||||
if r.writeStart() != 6 {
|
||||
t.Fatalf("writeStart=%d, want 6", r.writeStart())
|
||||
}
|
||||
// Stage "CD" 2 bytes ahead of the write position (6) → wraps to indices 0,1.
|
||||
if !r.PeekWrite([]byte("CD"), 2) {
|
||||
t.Fatal("PeekWrite (wrap) should fit")
|
||||
}
|
||||
// Fill the 2-byte gap at indices 6,7, then commit the wrapped tail.
|
||||
if _, err := r.Write([]byte("AB")); err != nil {
|
||||
t.Fatal("gap write:", err)
|
||||
}
|
||||
if err := r.Commit(2); err != nil {
|
||||
t.Fatal("commit:", err)
|
||||
}
|
||||
got := make([]byte, 8)
|
||||
n, err := r.Read(got)
|
||||
if err != nil {
|
||||
t.Fatal("read:", err)
|
||||
}
|
||||
if string(got[:n]) != "WXYZABCD" {
|
||||
t.Fatalf("read %q, want WXYZABCD", got[:n])
|
||||
}
|
||||
testRingSanity(t, r)
|
||||
}
|
||||
|
||||
func TestRingPeekWriteRejects(t *testing.T) {
|
||||
r := &Ring{Buf: make([]byte, 8)}
|
||||
if r.PeekWrite([]byte("toolong!!"), 0) {
|
||||
t.Error("PeekWrite must reject data larger than the buffer")
|
||||
}
|
||||
if r.PeekWrite([]byte("data"), 5) { // 5+4 > 8 free.
|
||||
t.Error("PeekWrite must reject offset+len beyond free space")
|
||||
}
|
||||
if r.PeekWrite([]byte("x"), -1) {
|
||||
t.Error("PeekWrite must reject negative offset")
|
||||
}
|
||||
if err := r.Commit(0); err == nil {
|
||||
t.Error("Commit(0) must error")
|
||||
}
|
||||
if err := r.Commit(9); err == nil {
|
||||
t.Error("Commit beyond free must error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package internet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"math/rand"
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/soypat/lneto/tcp"
|
||||
)
|
||||
|
||||
// TestConn_ConcurrentCloseDoesNotDropWrite is a regression test for issue #82:
|
||||
// when one goroutine has a Write in progress (blocked because the TX buffer is
|
||||
// full) and another goroutine calls Close, the in-flight write data must not be
|
||||
// silently dropped. With the atomic write-lock, Close waits for the in-progress
|
||||
// write to finish queueing all of its data before tearing the connection down.
|
||||
//
|
||||
// The scenario is made deterministic by filling the TX buffer before any packets
|
||||
// are exchanged: the writer blocks holding the write lock, Close is then issued
|
||||
// (and must wait), and only afterwards is the packet pump started so the buffer
|
||||
// can drain and the writer can run to completion.
|
||||
func TestConn_ConcurrentCloseDoesNotDropWrite(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(1))
|
||||
var sbCl, sbSv StackIPv4
|
||||
var connCl, connSv tcp.Conn
|
||||
setupClientServerEstablished(t, rng, &sbCl, &sbSv, &connCl, &connSv)
|
||||
|
||||
// Payload several times larger than the 2048-byte TX buffer so the writer
|
||||
// must block waiting for the buffer to drain across many segments.
|
||||
payload := make([]byte, 4*2048)
|
||||
for i := range payload {
|
||||
payload[i] = byte(i)
|
||||
}
|
||||
|
||||
// Watchdog: break any deadlock so a regression fails fast instead of hanging.
|
||||
watchdog := time.AfterFunc(10*time.Second, func() {
|
||||
t.Error("test timed out: Close likely blocked waiting on a stuck write")
|
||||
connCl.Abort()
|
||||
connSv.Abort()
|
||||
})
|
||||
defer watchdog.Stop()
|
||||
|
||||
// Writer (G1): writes the whole payload. Blocks once the TX buffer fills.
|
||||
type writeResult struct {
|
||||
n int
|
||||
err error
|
||||
}
|
||||
writeDone := make(chan writeResult, 1)
|
||||
go func() {
|
||||
n, err := connCl.Write(payload)
|
||||
writeDone <- writeResult{n, err}
|
||||
}()
|
||||
|
||||
// Wait until the writer has filled the TX buffer and is blocked. At this
|
||||
// point it owns the write lock, reproducing "Write in progress".
|
||||
for connCl.FreeOutput() != 0 {
|
||||
runtime.Gosched()
|
||||
}
|
||||
|
||||
// Close (G2): issued while the write is in progress. Must wait for the
|
||||
// writer to drain instead of dropping its data.
|
||||
closeDone := make(chan error, 1)
|
||||
go func() {
|
||||
closeDone <- connCl.Close()
|
||||
}()
|
||||
|
||||
// Reader: drains the server side until EOF, accumulating everything received.
|
||||
readDone := make(chan []byte, 1)
|
||||
go func() {
|
||||
got := make([]byte, 0, len(payload))
|
||||
rbuf := make([]byte, 1024)
|
||||
for {
|
||||
n, err := connSv.Read(rbuf)
|
||||
got = append(got, rbuf[:n]...)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
readDone <- got
|
||||
}()
|
||||
|
||||
// Packet pump: only now do we move packets between the two stacks, letting
|
||||
// the buffer drain so the blocked writer can finish.
|
||||
var stop atomic.Bool
|
||||
pumpStopped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(pumpStopped)
|
||||
var buf [2048]byte
|
||||
for !stop.Load() {
|
||||
progressed := false
|
||||
if n, err := sbCl.Encapsulate(buf[:], 0, 0); err == nil && n > 0 {
|
||||
_ = sbSv.Demux(buf[:n], 0)
|
||||
progressed = true
|
||||
}
|
||||
if n, err := sbSv.Encapsulate(buf[:], 0, 0); err == nil && n > 0 {
|
||||
_ = sbCl.Demux(buf[:n], 0)
|
||||
progressed = true
|
||||
}
|
||||
if !progressed {
|
||||
runtime.Gosched()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
wr := <-writeDone
|
||||
if wr.err != nil {
|
||||
t.Errorf("issue #82: Write returned error %v; concurrent Close dropped in-flight data", wr.err)
|
||||
}
|
||||
if wr.n != len(payload) {
|
||||
t.Errorf("issue #82: Write wrote %d of %d bytes; concurrent Close truncated the write", wr.n, len(payload))
|
||||
}
|
||||
if err := <-closeDone; err != nil {
|
||||
t.Errorf("Close returned error: %v", err)
|
||||
}
|
||||
got := <-readDone
|
||||
stop.Store(true)
|
||||
<-pumpStopped
|
||||
|
||||
if len(got) != len(payload) {
|
||||
t.Fatalf("server received %d of %d bytes; data was dropped by concurrent Close", len(got), len(payload))
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatal("server received corrupted data")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConn_ConcurrentWritesSerialize verifies that the atomic write-lock
|
||||
// serializes concurrent Write calls on the same connection so their payloads are
|
||||
// not interleaved on the wire, and that all bytes from both writers are delivered.
|
||||
func TestConn_ConcurrentWritesSerialize(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(2))
|
||||
var sbCl, sbSv StackIPv4
|
||||
var connCl, connSv tcp.Conn
|
||||
setupClientServerEstablished(t, rng, &sbCl, &sbSv, &connCl, &connSv)
|
||||
|
||||
const chunk = 1500
|
||||
a := bytes.Repeat([]byte{0xAA}, chunk)
|
||||
b := bytes.Repeat([]byte{0xBB}, chunk)
|
||||
|
||||
watchdog := time.AfterFunc(10*time.Second, func() {
|
||||
t.Error("test timed out")
|
||||
connCl.Abort()
|
||||
connSv.Abort()
|
||||
})
|
||||
defer watchdog.Stop()
|
||||
|
||||
var stop atomic.Bool
|
||||
pumpStopped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(pumpStopped)
|
||||
var buf [2048]byte
|
||||
for !stop.Load() {
|
||||
progressed := false
|
||||
if n, err := sbCl.Encapsulate(buf[:], 0, 0); err == nil && n > 0 {
|
||||
_ = sbSv.Demux(buf[:n], 0)
|
||||
progressed = true
|
||||
}
|
||||
if n, err := sbSv.Encapsulate(buf[:], 0, 0); err == nil && n > 0 {
|
||||
_ = sbCl.Demux(buf[:n], 0)
|
||||
progressed = true
|
||||
}
|
||||
if !progressed {
|
||||
runtime.Gosched()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
writeErr := make(chan error, 2)
|
||||
writer := func(b []byte) {
|
||||
n, err := connCl.Write(b)
|
||||
if err == nil && n != len(b) {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
writeErr <- err
|
||||
}
|
||||
go writer(a)
|
||||
go writer(b)
|
||||
|
||||
got := make([]byte, 0, 2*chunk)
|
||||
rbuf := make([]byte, 1024)
|
||||
for len(got) < 2*chunk {
|
||||
n, err := connSv.Read(rbuf)
|
||||
got = append(got, rbuf[:n]...)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
}
|
||||
for range 2 {
|
||||
if err := <-writeErr; err != nil {
|
||||
t.Errorf("concurrent write failed: %v", err)
|
||||
}
|
||||
}
|
||||
stop.Store(true)
|
||||
<-pumpStopped
|
||||
|
||||
if len(got) != 2*chunk {
|
||||
t.Fatalf("received %d bytes, want %d", len(got), 2*chunk)
|
||||
}
|
||||
// Serialized writes mean one chunk's bytes appear fully before the other's;
|
||||
// the boundary is a single transition, never interleaved.
|
||||
transitions := 0
|
||||
for i := 1; i < len(got); i++ {
|
||||
if got[i] != got[i-1] {
|
||||
transitions++
|
||||
}
|
||||
}
|
||||
if transitions != 1 {
|
||||
t.Fatalf("expected exactly one A/B boundary (serialized writes), got %d transitions", transitions)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ package pcap
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
@@ -980,18 +980,22 @@ func (frm Frame) String() string {
|
||||
|
||||
func (frm Frame) AppendString(b []byte) []byte {
|
||||
bitlen := frm.LenBits()
|
||||
b = fmt.Appendf(b, "%s", frm.Protocol)
|
||||
b = append(b, frm.Protocol...)
|
||||
if bitlen%8 == 0 {
|
||||
b = fmt.Appendf(b, " len=%d", bitlen/8)
|
||||
b = append(b, " len="...)
|
||||
b = strconv.AppendInt(b, int64(bitlen/8), 10)
|
||||
} else {
|
||||
b = fmt.Appendf(b, " bits=%d", bitlen)
|
||||
b = append(b, " bits="...)
|
||||
b = strconv.AppendInt(b, int64(bitlen), 10)
|
||||
}
|
||||
iopt, err := frm.FieldByClass(FieldClassOptions)
|
||||
if err == nil {
|
||||
b = fmt.Appendf(b, " optlen=%d", (frm.Fields[iopt].BitLength+7)/8)
|
||||
b = append(b, " optlen="...)
|
||||
b = strconv.AppendInt(b, int64((frm.Fields[iopt].BitLength+7)/8), 10)
|
||||
}
|
||||
for _, err := range frm.Errors {
|
||||
b = fmt.Appendf(b, " %s", err.Error())
|
||||
b = append(b, ' ')
|
||||
b = append(b, err.Error()...)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -1005,10 +1009,10 @@ func (frm Frame) LenBits() (totalBitlen int) {
|
||||
|
||||
func (ff FrameField) String() string {
|
||||
if ff.Class == FieldClassPayload {
|
||||
return fmt.Sprintf("Payload len=%d", ff.BitLength/8)
|
||||
return "Payload len=" + strconv.Itoa(ff.BitLength/8)
|
||||
}
|
||||
if ff.Name != "" {
|
||||
return fmt.Sprintf("%s (%s)", ff.Name, ff.Class.String())
|
||||
return ff.Name + " (" + ff.Class.String() + ")"
|
||||
}
|
||||
return ff.Class.String()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package pcap
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
"github.com/soypat/lneto/dhcp/dhcpv4"
|
||||
"github.com/soypat/lneto/dns"
|
||||
"github.com/soypat/lneto/ethernet"
|
||||
"github.com/soypat/lneto/ipv4"
|
||||
"github.com/soypat/lneto/udp"
|
||||
)
|
||||
|
||||
const benchSubfieldLimit = 32
|
||||
|
||||
// buildDHCPPacket builds an Ethernet+IPv4+UDP+DHCPv4 Discover packet, exercising
|
||||
// the option-heavy DHCP path (hostname, client id, requested address, param list).
|
||||
func buildDHCPPacket(b testing.TB) []byte {
|
||||
const (
|
||||
ethSize = 14
|
||||
ipv4Size = 20
|
||||
udpSize = 8
|
||||
)
|
||||
pkt := make([]byte, 600)
|
||||
|
||||
efrm, _ := ethernet.NewFrame(pkt)
|
||||
*efrm.DestinationHardwareAddr() = [6]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff}
|
||||
*efrm.SourceHardwareAddr() = [6]byte{0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe}
|
||||
efrm.SetEtherType(ethernet.TypeIPv4)
|
||||
|
||||
ifrm, _ := ipv4.NewFrame(pkt[ethSize:])
|
||||
ifrm.SetVersionAndIHL(4, 5)
|
||||
ifrm.SetID(0x1234)
|
||||
ifrm.SetFlags(0x4000)
|
||||
ifrm.SetTTL(64)
|
||||
ifrm.SetProtocol(lneto.IPProtoUDP)
|
||||
|
||||
ufrm, _ := udp.NewFrame(pkt[ethSize+ipv4Size:])
|
||||
ufrm.SetSourcePort(dhcpv4.DefaultClientPort)
|
||||
ufrm.SetDestinationPort(dhcpv4.DefaultServerPort)
|
||||
|
||||
var cl dhcpv4.Client
|
||||
err := cl.BeginRequest(0xdeadbeef, dhcpv4.RequestConfig{
|
||||
RequestedAddr: [4]byte{192, 168, 1, 100},
|
||||
ClientHardwareAddr: [6]byte{0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe},
|
||||
Hostname: "myhost",
|
||||
ClientID: "lneto-test",
|
||||
})
|
||||
if err != nil {
|
||||
b.Fatal("begin request:", err)
|
||||
}
|
||||
dhcpLen, err := cl.Encapsulate(pkt, ethSize, ethSize+ipv4Size+udpSize)
|
||||
if err != nil {
|
||||
b.Fatal("encapsulate:", err)
|
||||
}
|
||||
totalLen := ipv4Size + udpSize + dhcpLen
|
||||
ifrm.SetTotalLength(uint16(totalLen))
|
||||
ufrm.SetLength(uint16(udpSize + dhcpLen))
|
||||
ifrm.SetCRC(ifrm.CalculateHeaderCRC())
|
||||
return pkt[:ethSize+totalLen]
|
||||
}
|
||||
|
||||
// buildDNSPacket builds an Ethernet+IPv4+UDP+DNS message with multiple questions
|
||||
// and answers, exercising name encoding and resource record rendering.
|
||||
func buildDNSPacket(b testing.TB) []byte {
|
||||
const (
|
||||
ethSize = 14
|
||||
ipv4Size = 20
|
||||
udpSize = 8
|
||||
)
|
||||
var msg dns.Message
|
||||
msg.Questions = []dns.Question{
|
||||
{Name: dns.MustNewName("example.com"), Type: dns.TypeA, Class: dns.ClassINET},
|
||||
{Name: dns.MustNewName("temu.com"), Type: dns.TypeAAAA, Class: dns.ClassANY},
|
||||
}
|
||||
msg.Answers = []dns.Resource{
|
||||
dns.NewResource(dns.MustNewName("abc.com"), dns.TypeALL, dns.ClassANY, 64, []byte{10, 0, 11, 1}),
|
||||
dns.NewResource(dns.MustNewName("123.com"), dns.TypeA, dns.ClassINET, 64, []byte{20, 0, 22, 2}),
|
||||
}
|
||||
dnsPayload, err := msg.AppendTo(nil, 0x1234, dns.NewClientHeaderFlags(dns.OpCodeQuery, true))
|
||||
if err != nil {
|
||||
b.Fatal("dns encode:", err)
|
||||
}
|
||||
|
||||
pkt := make([]byte, ethSize+ipv4Size+udpSize+len(dnsPayload))
|
||||
|
||||
efrm, _ := ethernet.NewFrame(pkt)
|
||||
*efrm.DestinationHardwareAddr() = [6]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x01}
|
||||
*efrm.SourceHardwareAddr() = [6]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x02}
|
||||
efrm.SetEtherType(ethernet.TypeIPv4)
|
||||
|
||||
ifrm, _ := ipv4.NewFrame(pkt[ethSize:])
|
||||
ifrm.SetVersionAndIHL(4, 5)
|
||||
ifrm.SetTTL(64)
|
||||
ifrm.SetProtocol(lneto.IPProtoUDP)
|
||||
ifrm.SetTotalLength(uint16(ipv4Size + udpSize + len(dnsPayload)))
|
||||
ifrm.SetCRC(ifrm.CalculateHeaderCRC())
|
||||
|
||||
ufrm, _ := udp.NewFrame(pkt[ethSize+ipv4Size:])
|
||||
ufrm.SetSourcePort(58200)
|
||||
ufrm.SetDestinationPort(dns.ServerPort)
|
||||
ufrm.SetLength(uint16(udpSize + len(dnsPayload)))
|
||||
|
||||
copy(pkt[ethSize+ipv4Size+udpSize:], dnsPayload)
|
||||
return pkt
|
||||
}
|
||||
|
||||
func configureBenchFormatter(f *Formatter) {
|
||||
f.SubfieldLimit = benchSubfieldLimit
|
||||
f.FrameSep = "\n"
|
||||
f.FieldSep = "; "
|
||||
f.SubfieldSep = "\n\t"
|
||||
}
|
||||
|
||||
// BenchmarkPcap measures the decode, format, and decode+format (roundtrip) phases
|
||||
// separately for the string-heavy DHCP and DNS frames. Run with -benchmem for
|
||||
// per-phase allocs/op.
|
||||
func BenchmarkPcap(b *testing.B) {
|
||||
cases := []struct {
|
||||
name string
|
||||
pkt []byte
|
||||
}{
|
||||
{"DHCP", buildDHCPPacket(b)},
|
||||
{"DNS", buildDNSPacket(b)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
b.Run("decode", func(b *testing.B) {
|
||||
var pb PacketBreakdown
|
||||
pb.SubfieldLimit = benchSubfieldLimit
|
||||
var frames []Frame
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
frames, _ = pb.CaptureEthernet(frames[:0], tc.pkt, 0)
|
||||
}
|
||||
})
|
||||
b.Run("format", func(b *testing.B) {
|
||||
var pb PacketBreakdown
|
||||
pb.SubfieldLimit = benchSubfieldLimit
|
||||
frames, err := pb.CaptureEthernet(nil, tc.pkt, 0)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
var f Formatter
|
||||
configureBenchFormatter(&f)
|
||||
var buf []byte
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
buf, _ = f.FormatFrames(buf[:0], frames, tc.pkt)
|
||||
}
|
||||
})
|
||||
b.Run("roundtrip", func(b *testing.B) {
|
||||
var pb PacketBreakdown
|
||||
pb.SubfieldLimit = benchSubfieldLimit
|
||||
var f Formatter
|
||||
configureBenchFormatter(&f)
|
||||
var frames []Frame
|
||||
var buf []byte
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
frames, _ = pb.CaptureEthernet(frames[:0], tc.pkt, 0)
|
||||
buf, _ = f.FormatFrames(buf[:0], frames, tc.pkt)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkPcapPhases runs decode+format in a single benchmark loop while
|
||||
// reporting per-phase wall time via custom metrics (decode-ns/op, format-ns/op).
|
||||
// decode-ns/op + format-ns/op approximates ns/op minus time.Now overhead.
|
||||
// Per-phase allocs are not split here (ReadMemStats is STW and skews timing);
|
||||
// use BenchmarkPcap's decode/format sub-benchmarks with -benchmem for that.
|
||||
func BenchmarkPcapPhases(b *testing.B) {
|
||||
cases := []struct {
|
||||
name string
|
||||
pkt []byte
|
||||
}{
|
||||
{"DHCP", buildDHCPPacket(b)},
|
||||
{"DNS", buildDNSPacket(b)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
var pb PacketBreakdown
|
||||
pb.SubfieldLimit = benchSubfieldLimit
|
||||
var f Formatter
|
||||
configureBenchFormatter(&f)
|
||||
var frames []Frame
|
||||
var buf []byte
|
||||
var decNs, fmtNs int64
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
t0 := time.Now()
|
||||
frames, _ = pb.CaptureEthernet(frames[:0], tc.pkt, 0)
|
||||
t1 := time.Now()
|
||||
buf, _ = f.FormatFrames(buf[:0], frames, tc.pkt)
|
||||
t2 := time.Now()
|
||||
decNs += t1.Sub(t0).Nanoseconds()
|
||||
fmtNs += t2.Sub(t1).Nanoseconds()
|
||||
}
|
||||
b.ReportMetric(float64(decNs)/float64(b.N), "decode-ns/op")
|
||||
b.ReportMetric(float64(fmtNs)/float64(b.N), "format-ns/op")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -158,7 +158,12 @@ func (ls *StackEthernet) Demux(carrierData []byte, frameOffset int) (err error)
|
||||
return err
|
||||
}
|
||||
DROP:
|
||||
ls.handlers.info("LinkStack:drop-packet", internal.SlogAddr6("dsthw", dstaddr), slog.String("ethertype", efrm.EtherTypeOrSize().String()))
|
||||
// Frames not addressed to us are routine noise on a shared LAN (multicast/
|
||||
// broadcast spam). Log at debug so a production logger at info level gates it
|
||||
// out instead of allocating slog attrs per dropped packet.
|
||||
if internal.LogEnabled(ls.handlers.logger.log, slog.LevelDebug) {
|
||||
ls.handlers.debug("LinkStack:drop-packet", internal.SlogAddr6("dsthw", dstaddr), slog.String("ethertype", efrm.EtherTypeOrSize().String()))
|
||||
}
|
||||
return lneto.ErrPacketDrop
|
||||
}
|
||||
|
||||
|
||||
+23
-12
@@ -22,34 +22,35 @@ type StackIPv4 struct {
|
||||
}
|
||||
|
||||
func (stackip4 *StackIPv4) Reset(vld *lneto.Validator, maxNodes int) error {
|
||||
stackip4.connID++
|
||||
stackip4.reset4(vld, maxNodes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (stackip *StackIPv4) ConnectionID() *uint64 {
|
||||
return &stackip.connID
|
||||
func (stackip4 *StackIPv4) ConnectionID() *uint64 {
|
||||
return &stackip4.connID
|
||||
}
|
||||
|
||||
func (stackip *StackIPv4) Protocol() uint64 {
|
||||
func (stackip4 *StackIPv4) Protocol() uint64 {
|
||||
return uint64(ethernet.TypeIPv4)
|
||||
}
|
||||
|
||||
func (stackip *StackIPv4) LocalPort() uint16 { return 0 }
|
||||
func (stackip4 *StackIPv4) LocalPort() uint16 { return 0 }
|
||||
|
||||
func (stackip *StackIPv4) SetLogger(logger *slog.Logger) {
|
||||
stackip.stackip4.handlers.log = logger
|
||||
func (stackip4 *StackIPv4) SetLogger(logger *slog.Logger) {
|
||||
stackip4.stackip4.handlers.log = logger
|
||||
}
|
||||
|
||||
func (stackip *StackIPv4) Demux(carrierData []byte, offset int) error {
|
||||
func (stackip4 *StackIPv4) Demux(carrierData []byte, offset int) error {
|
||||
debugLog("ip:demux")
|
||||
return stackip.stackip4.demux4(carrierData, offset)
|
||||
return stackip4.stackip4.demux4(carrierData, offset)
|
||||
}
|
||||
|
||||
func (stackip *StackIPv4) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int) (n int, err error) {
|
||||
func (stackip4 *StackIPv4) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int) (n int, err error) {
|
||||
if offsetToFrame != offsetToIP {
|
||||
return 0, lneto.ErrBug
|
||||
}
|
||||
return stackip.stackip4.encapsulate4(carrierData, offsetToIP)
|
||||
return stackip4.stackip4.encapsulate4(carrierData, offsetToIP)
|
||||
}
|
||||
|
||||
type stackip4 struct {
|
||||
@@ -58,6 +59,7 @@ type stackip4 struct {
|
||||
ipID uint16
|
||||
ip4 [4]byte
|
||||
acceptMulticast bool
|
||||
acceptBroadcast bool
|
||||
}
|
||||
|
||||
func (si4 *stackip4) reset4(vld *lneto.Validator, maxNodes int) {
|
||||
@@ -86,6 +88,10 @@ func (si4 *stackip4) IsRegistered4(proto lneto.IPProto) bool {
|
||||
func (si4 *stackip4) SetAcceptMulticast4(accept bool) {
|
||||
si4.acceptMulticast = accept
|
||||
}
|
||||
func (si4 *stackip4) SetAcceptBroadcast4(accept bool) {
|
||||
si4.acceptBroadcast = accept
|
||||
}
|
||||
|
||||
func (si4 *stackip4) Addr4() [4]byte { return si4.ip4 }
|
||||
func (si4 *stackip4) SetAddr4(ip4 [4]byte) {
|
||||
si4.ip4 = ip4
|
||||
@@ -100,8 +106,13 @@ func (si4 *stackip4) demux4(carrierData []byte, offset int) error {
|
||||
return err
|
||||
}
|
||||
dst := ifrm.DestinationAddr()
|
||||
if si4.ip4 != ([4]byte{}) && *dst != si4.ip4 {
|
||||
if !si4.acceptMulticast || !internal.IsMulticastIPAddr(dst[:]) {
|
||||
if si4.ip4 != ([4]byte{}) && *dst != si4.ip4 { // Accept all packets when IP zeroed.
|
||||
switch {
|
||||
case si4.acceptMulticast && ipv4.IsMulticast(*dst):
|
||||
// accept multicast.
|
||||
case si4.acceptBroadcast && ipv4.IsBroadcast(*dst):
|
||||
// accept broadcast.
|
||||
default:
|
||||
si4.handlers.debug("ip:not-for-us")
|
||||
return lneto.ErrPacketDrop // Not meant for us.
|
||||
}
|
||||
|
||||
+14
-12
@@ -19,35 +19,36 @@ type StackIPv6 struct {
|
||||
stackip6
|
||||
}
|
||||
|
||||
func (stackip4 *StackIPv6) Reset(vld *lneto.Validator, maxNodes int) error {
|
||||
stackip4.reset6(vld, maxNodes)
|
||||
func (stackip6 *StackIPv6) Reset(vld *lneto.Validator, maxNodes int) error {
|
||||
stackip6.connID++
|
||||
stackip6.reset6(vld, maxNodes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (stackip *StackIPv6) ConnectionID() *uint64 {
|
||||
return &stackip.connID
|
||||
func (stackip6 *StackIPv6) ConnectionID() *uint64 {
|
||||
return &stackip6.connID
|
||||
}
|
||||
|
||||
func (stackip *StackIPv6) Protocol() uint64 {
|
||||
func (stackip6 *StackIPv6) Protocol() uint64 {
|
||||
return uint64(ethernet.TypeIPv6)
|
||||
}
|
||||
|
||||
func (stackip *StackIPv6) LocalPort() uint16 { return 0 }
|
||||
func (stackip6 *StackIPv6) LocalPort() uint16 { return 0 }
|
||||
|
||||
func (stackip *StackIPv6) SetLogger(logger *slog.Logger) {
|
||||
stackip.stackip6.handlers.log = logger
|
||||
func (stackip6 *StackIPv6) SetLogger(logger *slog.Logger) {
|
||||
stackip6.stackip6.handlers.log = logger
|
||||
}
|
||||
|
||||
func (stackip *StackIPv6) Demux(carrierData []byte, offset int) error {
|
||||
func (stackip6 *StackIPv6) Demux(carrierData []byte, offset int) error {
|
||||
debugLog("ip:demux")
|
||||
return stackip.stackip6.demux6(carrierData, offset)
|
||||
return stackip6.stackip6.demux6(carrierData, offset)
|
||||
}
|
||||
|
||||
func (stackip *StackIPv6) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int) (n int, err error) {
|
||||
func (stackip6 *StackIPv6) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int) (n int, err error) {
|
||||
if offsetToFrame != offsetToIP {
|
||||
return 0, lneto.ErrBug
|
||||
}
|
||||
return stackip.stackip6.encapsulate6(carrierData, offsetToIP)
|
||||
return stackip6.stackip6.encapsulate6(carrierData, offsetToIP)
|
||||
}
|
||||
|
||||
type stackip6 struct {
|
||||
@@ -55,6 +56,7 @@ type stackip6 struct {
|
||||
vld *lneto.Validator
|
||||
ip6 [16]byte
|
||||
acceptMulticast bool
|
||||
acceptBroadcast bool
|
||||
}
|
||||
|
||||
func (si6 *stackip6) Register6(h lneto.StackNode) error {
|
||||
|
||||
@@ -18,6 +18,17 @@ func IsMulticast(addr [4]byte) bool {
|
||||
return addr[0]&0xf0 == 0xe0
|
||||
}
|
||||
|
||||
// IsBroadcast reports whether addr is the limited broadcast address
|
||||
// 255.255.255.255 used to address all hosts on the local network segment
|
||||
// as defined in [RFC919]. It does not detect directed (subnet) broadcast
|
||||
// addresses, which depend on the network mask and cannot be determined from
|
||||
// the address alone.
|
||||
//
|
||||
// [RFC919]: https://datatracker.ietf.org/doc/html/rfc919
|
||||
func IsBroadcast(addr [4]byte) bool {
|
||||
return addr == [4]byte{255, 255, 255, 255}
|
||||
}
|
||||
|
||||
// IsLinkLocal reports whether addr is within the IPv4 link-local prefix
|
||||
// 169.254.0.0/16 reserved for dynamic link-local configuration as defined in [RFC3927].
|
||||
//
|
||||
|
||||
@@ -38,6 +38,10 @@ type Client struct {
|
||||
raddr [4]byte
|
||||
}
|
||||
responseRing internal.Ring
|
||||
// addrScratch holds the remote address for SetIPAddrs during Encapsulate.
|
||||
// Kept on the (heap-resident) Client to avoid a per-call escape of a local
|
||||
// [4]byte, which TinyGo would otherwise heap-allocate on every poll.
|
||||
addrScratch [4]byte
|
||||
}
|
||||
|
||||
type ClientConfig struct {
|
||||
@@ -155,7 +159,6 @@ func (client *Client) Encapsulate(carrierData []byte, ipOffset, frameOffset int)
|
||||
|
||||
// Put n bytes of ICMP data.
|
||||
var n int
|
||||
var raddr [4]byte
|
||||
if len(client.incomingEcho) > 0 {
|
||||
// Priority: send echo reply.1
|
||||
inc := client.incomingEcho[0]
|
||||
@@ -170,7 +173,7 @@ func (client *Client) Encapsulate(carrierData []byte, ipOffset, frameOffset int)
|
||||
}
|
||||
client.incomingEcho = slices.Delete(client.incomingEcho, 0, 1)
|
||||
n = sizeHeader + dataLen
|
||||
raddr = inc.raddr
|
||||
client.addrScratch = inc.raddr
|
||||
} else if len(client.outgoingEcho) > 0 {
|
||||
idx := 0
|
||||
for idx < len(client.outgoingEcho) {
|
||||
@@ -199,7 +202,7 @@ func (client *Client) Encapsulate(carrierData []byte, ipOffset, frameOffset int)
|
||||
copy(data[written:written+size%len(pattern)], pattern)
|
||||
n = sizeHeader + size
|
||||
out.key |= keyHashSentBit
|
||||
raddr = out.raddr
|
||||
client.addrScratch = out.raddr
|
||||
} else {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -210,7 +213,7 @@ func (client *Client) Encapsulate(carrierData []byte, ipOffset, frameOffset int)
|
||||
sum := crc.PayloadSum16(carrierData[frameOffset : frameOffset+n])
|
||||
ifrm.SetCRC(sum)
|
||||
if ipOffset >= 0 {
|
||||
err = internal.SetIPAddrs(carrierData[ipOffset:], 0, nil, raddr[:])
|
||||
err = internal.SetIPAddrs(carrierData[ipOffset:], 0, nil, client.addrScratch[:])
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
+74
-10
@@ -8,6 +8,7 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
@@ -29,6 +30,13 @@ type Conn struct {
|
||||
h Handler
|
||||
remoteAddr []byte
|
||||
|
||||
// writeLock holds the connection ID ([Handler.connid]) of the goroutine
|
||||
// that currently owns the write side via [Conn.Write] or [Conn.Flush].
|
||||
// A zero value means no write is in progress. It serializes concurrent
|
||||
// writers and lets [Conn.Close] acquire the write side before closing so it
|
||||
// does not drop in-flight data (see issue #82).
|
||||
writeLock atomic.Uint64
|
||||
|
||||
_backoff lneto.BackoffStrategy
|
||||
rdead time.Time
|
||||
wdead time.Time
|
||||
@@ -49,6 +57,7 @@ func (conn *Conn) reset(h Handler) {
|
||||
conn.wdead = time.Time{}
|
||||
conn.abortErr = nil
|
||||
conn.ipID = 0
|
||||
conn.writeLock.Store(0)
|
||||
}
|
||||
|
||||
// ConnConfig provides configuration parameters for [Conn].
|
||||
@@ -67,6 +76,16 @@ type ConnConfig struct {
|
||||
// Logger sets the [Conn] logger.
|
||||
// Lower level logging available at [Handler.SetLoggers] via [Conn.InternalHandler].
|
||||
Logger *slog.Logger
|
||||
// LossRecovery is the optional packet-loss recovery algorithm (RTO,
|
||||
// congestion control, ...) for the connection. If set, Nanotime must also be
|
||||
// set (else Configure returns an error). Leaving it nil disables loss
|
||||
// recovery. See [LossRecovery].
|
||||
LossRecovery LossRecovery
|
||||
// Nanotime is the monotonic time source in nanoseconds (the func() int64
|
||||
// convention used across lneto) that drives LossRecovery. It is required when
|
||||
// LossRecovery is set and unused otherwise. The tcp package reads it only to
|
||||
// stamp the loss-recovery hooks; it holds no clock itself.
|
||||
Nanotime func() int64
|
||||
}
|
||||
|
||||
// Configure should be called on any newly created connection before usage. See [ConnConfig].
|
||||
@@ -74,6 +93,10 @@ func (conn *Conn) Configure(config ConnConfig) (err error) {
|
||||
if config.RWBackoff == nil {
|
||||
return lneto.ErrMissingHALConfig
|
||||
}
|
||||
if config.LossRecovery != nil && config.Nanotime == nil {
|
||||
// The tcp package holds no clock: a loss-recovery algorithm cannot run without it.
|
||||
return lneto.ErrInvalidConfig
|
||||
}
|
||||
conn.mu.Lock()
|
||||
defer conn.mu.Unlock()
|
||||
err = conn.h.SetBuffers(config.TxBuf, config.RxBuf, config.TxPacketQueueSize)
|
||||
@@ -82,6 +105,7 @@ func (conn *Conn) Configure(config ConnConfig) (err error) {
|
||||
}
|
||||
conn._backoff = config.RWBackoff
|
||||
conn.logger.log = config.Logger
|
||||
conn.h.SetLossRecovery(config.LossRecovery, config.Nanotime)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -99,6 +123,22 @@ func (conn *Conn) RemotePort() uint16 {
|
||||
return conn.h.RemotePort()
|
||||
}
|
||||
|
||||
// IsAwaitingControl reports whether the connection is waiting for a response to
|
||||
// a control segment that can be retransmitted to advance connection state.
|
||||
func (conn *Conn) IsAwaitingControl() bool {
|
||||
conn.mu.Lock()
|
||||
defer conn.mu.Unlock()
|
||||
return conn.h.IsAwaitingControl()
|
||||
}
|
||||
|
||||
// RequeueControl asks the next packet emission to retransmit the outstanding
|
||||
// control segment, if the connection is waiting for one.
|
||||
func (conn *Conn) RequeueControl() {
|
||||
conn.mu.Lock()
|
||||
defer conn.mu.Unlock()
|
||||
conn.h.RequeueControl()
|
||||
}
|
||||
|
||||
// RemoteAddr returns the address of the peer Conn is exchanging data with.
|
||||
func (conn *Conn) RemoteAddr() []byte {
|
||||
conn.mu.Lock()
|
||||
@@ -213,6 +253,11 @@ func (conn *Conn) CloseRead() error {
|
||||
|
||||
// Close will initiate TCP close sequence. After Close is called future [Conn.Write] calls will fail with [net.ErrClosed].
|
||||
func (conn *Conn) Close() error {
|
||||
connid, err := conn.acquireWriteLock(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.releaseWriteLock(connid)
|
||||
conn.mu.Lock()
|
||||
defer conn.mu.Unlock()
|
||||
conn.trace("TCPConn.Close", slog.Uint64("lport", uint64(conn.h.localPort)), slog.Uint64("rport", uint64(conn.h.remotePort)))
|
||||
@@ -237,10 +282,11 @@ func (conn *Conn) InternalHandler() *Handler {
|
||||
|
||||
// Write writes argument data to the TCPConns's output buffer which is queued to be sent.
|
||||
func (conn *Conn) Write(b []byte) (int, error) {
|
||||
connid, err := conn.lockPipeConnID()
|
||||
connid, err := conn.acquireWriteLock(&conn.wdead)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer conn.releaseWriteLock(connid)
|
||||
rport := conn.RemotePort()
|
||||
plen := len(b)
|
||||
lport := conn.LocalPort()
|
||||
@@ -286,10 +332,11 @@ func (conn *Conn) Write(b []byte) (int, error) {
|
||||
|
||||
// Flush blocks until all buffered TCP data has been sent.
|
||||
func (conn *Conn) Flush() error {
|
||||
connid, err := conn.lockPipeConnID()
|
||||
connid, err := conn.acquireWriteLock(&conn.wdead)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.releaseWriteLock(connid)
|
||||
var backoffs uint
|
||||
for conn.BufferedUnsent() != 0 {
|
||||
if err := conn.checkPipe(connid, &conn.wdead); err != nil {
|
||||
@@ -350,14 +397,31 @@ func (conn *Conn) Read(b []byte) (int, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (conn *Conn) lockPipeConnID() (uint64, error) {
|
||||
// acquireWriteLock validates the connection is open and acquires the write lock
|
||||
// for the current connection, returning its connection ID. It serializes
|
||||
// concurrent writers: while another goroutine owns the write side it blocks with
|
||||
// backoff until that writer releases, the connection closes, or deadline elapses.
|
||||
// The returned connID must be released with [Conn.releaseWriteLock].
|
||||
func (conn *Conn) acquireWriteLock(deadline *time.Time) (connID uint64, err error) {
|
||||
conn.mu.Lock()
|
||||
defer conn.mu.Unlock()
|
||||
err := conn.checkPipeOpen()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
connID = conn.h.connid
|
||||
conn.mu.Unlock()
|
||||
var backoffs uint
|
||||
for {
|
||||
if err := conn.checkPipe(connID, deadline); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if conn.writeLock.CompareAndSwap(0, connID) {
|
||||
return connID, nil
|
||||
}
|
||||
conn.backoff(backoffs)
|
||||
backoffs++
|
||||
}
|
||||
return conn.h.connid, nil
|
||||
}
|
||||
|
||||
// releaseWriteLock releases a write lock previously acquired by [Conn.acquireWriteLock].
|
||||
func (conn *Conn) releaseWriteLock(connID uint64) {
|
||||
conn.writeLock.CompareAndSwap(connID, 0)
|
||||
}
|
||||
|
||||
func (conn *Conn) checkPipe(connID uint64, deadline *time.Time) (err error) {
|
||||
@@ -365,9 +429,9 @@ func (conn *Conn) checkPipe(connID uint64, deadline *time.Time) (err error) {
|
||||
defer conn.mu.Unlock()
|
||||
if conn.abortErr != nil {
|
||||
err = conn.abortErr
|
||||
} else if connID != conn.h.connid {
|
||||
} else if connID != conn.h.connid || conn.h.State().IsClosed() {
|
||||
err = net.ErrClosed
|
||||
} else if !deadline.IsZero() && time.Since(*deadline) > 0 {
|
||||
} else if deadline != nil && !deadline.IsZero() && time.Since(*deadline) > 0 {
|
||||
err = errDeadlineExceeded
|
||||
}
|
||||
return err
|
||||
|
||||
@@ -257,6 +257,16 @@ func (tcb *ControlBlock) HasPendingRetransmit() bool {
|
||||
return tcb._state.TxDataOpen() && tcb.dupack >= retransmitAfterDupacks && tcb.nRetransmit <= tcb.dupack-retransmitAfterDupacks
|
||||
}
|
||||
|
||||
// RetransmitAll rewinds snd.NXT back to snd.UNA so the next PendingSegment and
|
||||
// Send calls retransmit all unacknowledged data from the oldest sequence number
|
||||
// (go-back-N). It must be paired with ringTx.RetransmitFromUNA to rewind the
|
||||
// transmit buffer. Implements RFC 9293 §3.10.8 (RETRANSMISSION TIMEOUT).
|
||||
func (tcb *ControlBlock) RetransmitAll() {
|
||||
tcb.snd.NXT = tcb.snd.UNA
|
||||
tcb.dupack = 0
|
||||
tcb.nRetransmit = 0
|
||||
}
|
||||
|
||||
// PendingSegment calculates a suitable next segment to send from a payload length.
|
||||
// It does not modify the ControlBlock state or pending segment queue.
|
||||
func (tcb *ControlBlock) PendingSegment(payloadLen int) (_ Segment, ok bool) {
|
||||
|
||||
+188
-14
@@ -28,11 +28,23 @@ type Handler struct {
|
||||
// connection is established via Open calls. This disambiguates whether
|
||||
// Read and Write calls belong to the current connection.
|
||||
|
||||
optcodec OptionCodec
|
||||
optcodec OptionCodec
|
||||
// reasm tracks out-of-order segments staged in bufRx's free region. Always
|
||||
// enabled once buffers are set (see [Handler.SetBuffers]).
|
||||
reasm reassembly
|
||||
// loss is the optional packet-loss recovery algorithm (RTO, congestion
|
||||
// control, ...) driven from the rx/tx hooks. nil disables loss recovery, in
|
||||
// which case the connection behaves as if no timing existed. nanotime is the
|
||||
// monotonic time source (nanoseconds) passed to those hooks; it is non-nil
|
||||
// whenever loss is non-nil (enforced by [Conn.Configure]). See [LossRecovery].
|
||||
loss LossRecovery
|
||||
nanotime func() int64
|
||||
|
||||
closing bool
|
||||
shutdownRx bool
|
||||
// nRetransmit stores the number of times the oldest packet was retransmit.
|
||||
nRetransmit uint8
|
||||
nRetransmit uint8
|
||||
requeueControl bool
|
||||
}
|
||||
|
||||
// SetLoggers sets the [slog.Logger] for the Handler and internal [ControlBlock].
|
||||
@@ -63,9 +75,32 @@ func (h *Handler) SetBuffers(txbuf, rxbuf []byte, packets int) error {
|
||||
}
|
||||
h.scb.SetRecvWindow(Size(h.bufRx.Size()))
|
||||
h.bufRx.Reset()
|
||||
h.reasm.reset(maxReasmSegments)
|
||||
return h.bufTx.ResetOrReuse(txbuf, packets, 0)
|
||||
}
|
||||
|
||||
// SetLossRecovery installs the packet-loss recovery algorithm and the monotonic
|
||||
// time source (nanoseconds, the func() int64 convention used across lneto) that
|
||||
// drives it. The tcp package keeps no clock of its own; nanotime is read only to
|
||||
// stamp the rx/tx hooks (see [LossRecovery]). Passing loss == nil disables loss
|
||||
// recovery. It should be set before the connection is opened.
|
||||
func (h *Handler) SetLossRecovery(loss LossRecovery, nanotime func() int64) {
|
||||
h.loss = loss
|
||||
h.nanotime = nanotime
|
||||
}
|
||||
|
||||
func (h *Handler) lossEnabled() bool { return h.loss != nil }
|
||||
|
||||
// NextDeadline returns the monotonic-nanosecond instant at which the connection
|
||||
// must next be serviced by a transmit attempt (e.g. an RTO expiry), or 0 when
|
||||
// there is no deadline or no loss recovery is configured. See [LossRecovery].
|
||||
func (h *Handler) NextDeadline() int64 {
|
||||
if h.loss == nil {
|
||||
return 0
|
||||
}
|
||||
return h.loss.NextDeadline()
|
||||
}
|
||||
|
||||
// LocalPort returns the local port of the connection. Returns 0 if the connection is closed and uninitialized.
|
||||
func (h *Handler) LocalPort() uint16 {
|
||||
return h.localPort
|
||||
@@ -124,15 +159,24 @@ func (h *Handler) reset(localPort, remotePort uint16, iss Value) {
|
||||
*h = Handler{
|
||||
connid: h.connid + 1,
|
||||
scb: h.scb,
|
||||
bufTx: h.bufTx,
|
||||
bufRx: h.bufRx,
|
||||
localPort: localPort,
|
||||
remotePort: remotePort,
|
||||
validator: h.validator,
|
||||
logger: h.logger,
|
||||
closing: false,
|
||||
shutdownRx: false,
|
||||
// Persist configuration across reopen:
|
||||
validator: h.validator,
|
||||
loss: h.loss,
|
||||
nanotime: h.nanotime,
|
||||
logger: h.logger,
|
||||
// persist memory across repoen:
|
||||
bufTx: h.bufTx,
|
||||
bufRx: h.bufRx,
|
||||
reasm: h.reasm,
|
||||
}
|
||||
if h.lossEnabled() {
|
||||
h.loss.Reset()
|
||||
}
|
||||
h.reasm.clear() // preserve metadata capacity across reopen, drop held segments.
|
||||
h.bufTx.ResetOrReuse(nil, 0, iss)
|
||||
h.bufRx.Reset()
|
||||
}
|
||||
@@ -162,15 +206,28 @@ func (h *Handler) Recv(incomingPacket []byte) error {
|
||||
return lneto.ErrMismatch
|
||||
}
|
||||
payload := tfrm.Payload()
|
||||
if !h.shutdownRx && len(payload) > h.bufRx.Free() {
|
||||
return lneto.ErrBufferFull
|
||||
}
|
||||
segIncoming := tfrm.Segment(len(payload))
|
||||
if h.scb.IncomingIsKeepalive(segIncoming) {
|
||||
h.info("tcp.Handler:rx-keepalive", slog.Uint64("port", uint64(h.localPort)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Notify loss recovery of the received segment (RTT sampling, timer
|
||||
// management) and let it drop the segment before processing if it asks to.
|
||||
if h.lossEnabled() && !h.loss.PreRx(segIncoming, h.nanotime()).Keep {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Out-of-order reassembly: buffer in-window data that arrived ahead of the
|
||||
// next expected sequence number before the ControlBlock (sequential-only)
|
||||
// would reject it. Buffered segments live in bufRx's free region.
|
||||
if h.reasm.enabled() && h.handleOutOfOrder(segIncoming, payload) {
|
||||
return nil
|
||||
}
|
||||
if !h.shutdownRx && len(payload) > h.bufRx.Free() {
|
||||
return lneto.ErrBufferFull
|
||||
}
|
||||
|
||||
prevState := h.scb.State()
|
||||
prevUNA := h.scb.snd.UNA // Capture before Recv updates snd.UNA (RFC 6298 §5.3).
|
||||
err = h.scb.Recv(segIncoming)
|
||||
@@ -203,6 +260,11 @@ func (h *Handler) Recv(incomingPacket []byte) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if segIncoming.DATALEN != 0 {
|
||||
// The just-accepted in-order segment may have filled a gap; deliver any
|
||||
// now-contiguous buffered segments.
|
||||
h.deliverReassembled()
|
||||
}
|
||||
if segIncoming.Flags.HasAny(FlagACK) {
|
||||
if segIncoming.ACK == prevUNA {
|
||||
// scb keeping track of duplicate acks.
|
||||
@@ -241,6 +303,54 @@ func (h *Handler) Recv(incomingPacket []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleOutOfOrder buffers an in-window data segment that arrived ahead of the
|
||||
// next expected sequence number and queues a duplicate ACK so the sender fast-
|
||||
// retransmits the gap. It returns true when it has consumed the segment; false
|
||||
// leaves the segment to the ControlBlock (in-order data, control segments, old
|
||||
// or out-of-window segments, or when the reassembly buffer cannot hold it).
|
||||
func (h *Handler) handleOutOfOrder(seg Segment, payload []byte) bool {
|
||||
if h.shutdownRx {
|
||||
// Discard mode drops payloads, which would break the ring/rcv.NXT
|
||||
// lockstep reassemble relies on; do not buffer.
|
||||
return false
|
||||
}
|
||||
if seg.DATALEN == 0 || seg.Flags.HasAny(flagctl) {
|
||||
return false // only pure data segments are buffered out of order.
|
||||
}
|
||||
rcvNxt := h.scb.RecvNext()
|
||||
if seg.SEQ == rcvNxt {
|
||||
return false // in order: the ControlBlock handles it normally.
|
||||
}
|
||||
rcvWnd := h.scb.RecvWindow()
|
||||
if !seg.SEQ.InWindow(rcvNxt, rcvWnd) || !seg.Last().InWindow(rcvNxt, rcvWnd) {
|
||||
return false // old or out of window: let the ControlBlock decide.
|
||||
}
|
||||
if !h.reasm.store(&h.bufRx, rcvNxt, seg.SEQ, payload) {
|
||||
return false // no room: fall back to ControlBlock (challenge ACK).
|
||||
}
|
||||
h.scb.pending[0] |= FlagACK // duplicate ACK advertises the gap at rcv.NXT.
|
||||
h.trace("tcp.Handler:rx-ooo", slog.Uint64("seg.seq", uint64(seg.SEQ)), slog.Uint64("rcv.nxt", uint64(rcvNxt)))
|
||||
return true
|
||||
}
|
||||
|
||||
// deliverReassembled hands any now-contiguous out-of-order segments to the
|
||||
// receive stream, advancing rcv.NXT and queuing an ACK for what was delivered.
|
||||
func (h *Handler) deliverReassembled() {
|
||||
if h.reasm.buffered() == 0 {
|
||||
return
|
||||
}
|
||||
if h.shutdownRx {
|
||||
// Discard mode skips the gap-filling write, so staged bytes can no
|
||||
// longer be committed coherently; drop them (the peer retransmits).
|
||||
h.reasm.clear()
|
||||
return
|
||||
}
|
||||
if delivered := h.reasm.reassemble(&h.bufRx, h.scb.RecvNext()); delivered > 0 {
|
||||
h.scb.rcv.NXT.UpdateForward(delivered)
|
||||
h.scb.pending[0] |= FlagACK
|
||||
}
|
||||
}
|
||||
|
||||
// ShutdownRead activates local discard mode: incoming payload bytes are dropped
|
||||
// (ACK/SEQ still advance normally) and Read returns [io.EOF] immediately.
|
||||
// Not reversible within the lifetime of a connection.
|
||||
@@ -270,7 +380,20 @@ func (h *Handler) Send(b []byte) (int, error) {
|
||||
if h.IsTxOver() {
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
var now int64
|
||||
if h.lossEnabled() {
|
||||
now = h.nanotime()
|
||||
if h.loss.PreTx(now).RetransmitAll {
|
||||
// Go-back-N retransmission directed by loss recovery: rewind the
|
||||
// send sequence and transmit buffer so unacknowledged data is resent
|
||||
// from snd.UNA. Done before the early short-circuit below so an
|
||||
// expired RTO retransmits even with no new data queued.
|
||||
h.scb.RetransmitAll()
|
||||
h.bufTx.RetransmitFromUNA()
|
||||
}
|
||||
}
|
||||
awaitingSyn := h.AwaitingSynSend()
|
||||
requeueControl := h.requeueControl
|
||||
buffered := h.bufTx.BufferedUnsent()
|
||||
if h.scb.State() == StateCloseWait && !h.closing && buffered == 0 && !h.scb.HasPending() {
|
||||
// Remote closed with no application data left to send: initiate our own close.
|
||||
@@ -278,7 +401,7 @@ func (h *Handler) Send(b []byte) (int, error) {
|
||||
// before Send is called, implementing the half-close per RFC 9293 §3.5.
|
||||
h.closing = true
|
||||
}
|
||||
if !awaitingSyn && buffered == 0 && !h.closing && !h.scb.HasPending() {
|
||||
if !awaitingSyn && !requeueControl && buffered == 0 && !h.closing && !h.scb.HasPending() {
|
||||
// Early nop short circuit.
|
||||
return 0, nil
|
||||
}
|
||||
@@ -301,16 +424,32 @@ func (h *Handler) Send(b []byte) (int, error) {
|
||||
offset := uint8(5)
|
||||
mss := uint16(len(b) - sizeHeaderTCP)
|
||||
var segment Segment
|
||||
if awaitingSyn {
|
||||
if awaitingSyn || requeueControl && h.scb.State() == StateSynSent {
|
||||
// Handling init syn segment.
|
||||
segment = ClientSynSegment(h.bufTx.iss, Size(h.bufRx.Size()))
|
||||
h.optcodec.PutOption16(b[sizeHeaderTCP:], OptMaxSegmentSize, mss)
|
||||
offset++
|
||||
if requeueControl {
|
||||
h.info("tcp.Handler:requeue-syn", slog.Uint64("port", uint64(h.localPort)), slog.Uint64("rport", uint64(h.remotePort)))
|
||||
}
|
||||
} else if requeueControl && h.scb.State() == StateSynRcvd {
|
||||
segment = Segment{
|
||||
SEQ: h.scb.snd.UNA,
|
||||
ACK: h.scb.rcv.NXT,
|
||||
WND: Size(h.bufRx.Free()),
|
||||
Flags: synack,
|
||||
}
|
||||
h.optcodec.PutOption16(b[sizeHeaderTCP:], OptMaxSegmentSize, mss)
|
||||
offset++
|
||||
h.info("tcp.Handler:requeue-synack", slog.Uint64("port", uint64(h.localPort)), slog.Uint64("rport", uint64(h.remotePort)))
|
||||
} else if requeueControl {
|
||||
h.requeueControl = false
|
||||
return 0, nil
|
||||
} else {
|
||||
var ok bool
|
||||
maxPayload := len(b) - sizeHeaderTCP
|
||||
segment, ok = h.scb.PendingSegment(maxPayload)
|
||||
segment.WND = Size(h.bufRx.Free())
|
||||
segment.WND = h.recvWindow()
|
||||
if !ok {
|
||||
// No pending control segment or data to send. Yield.
|
||||
return 0, nil
|
||||
@@ -335,6 +474,10 @@ func (h *Handler) Send(b []byte) (int, error) {
|
||||
} else if prevState != h.scb.State() && h.logenabled(slog.LevelInfo) {
|
||||
h.info("tcp.Handler:tx-statechange", slog.Uint64("port", uint64(h.localPort)), slog.String("oldState", prevState.String()), slog.String("newState", h.scb.State().String()), slog.String("txflags", segment.Flags.String()))
|
||||
}
|
||||
if h.lossEnabled() {
|
||||
h.loss.PostTx(segment, now)
|
||||
}
|
||||
h.requeueControl = false
|
||||
tfrm.SetSourcePort(h.localPort)
|
||||
tfrm.SetDestinationPort(h.remotePort)
|
||||
tfrm.SetSegment(segment, offset)
|
||||
@@ -372,6 +515,9 @@ func (h *Handler) Read(b []byte) (n int, err error) {
|
||||
n, err = h.bufRx.Read(b)
|
||||
}
|
||||
if n > 0 {
|
||||
// Reading freed receive-buffer space; deliver any contiguous
|
||||
// out-of-order data that was waiting for room.
|
||||
h.deliverReassembled()
|
||||
h.maybeQueueWindowUpdate()
|
||||
}
|
||||
if n == 0 && err == nil {
|
||||
@@ -394,7 +540,7 @@ func (h *Handler) Read(b []byte) (n int, err error) {
|
||||
// space >= min(bufferSize/2, MSS). This applies uniformly including zero-window
|
||||
// recovery — the remote uses zero-window probes until enough space opens.
|
||||
func (h *Handler) maybeQueueWindowUpdate() {
|
||||
currentFree := Size(h.bufRx.Free())
|
||||
currentFree := h.recvWindow()
|
||||
lastAdvertised := h.scb.RecvWindow()
|
||||
if currentFree <= lastAdvertised {
|
||||
return // Window hasn't grown.
|
||||
@@ -435,7 +581,21 @@ func (h *Handler) FreeOutput() int {
|
||||
|
||||
// FreeInput returns the number of free bytes in the receive buffer.
|
||||
func (h *Handler) FreeInput() int {
|
||||
return h.bufRx.Free()
|
||||
return int(h.recvWindow())
|
||||
}
|
||||
|
||||
// recvWindow returns the receive window to advertise: free receive-buffer space
|
||||
// minus the bytes already held out of order. Subtracting them prevents the
|
||||
// sender from overrunning the receiver while a gap is open.
|
||||
func (h *Handler) recvWindow() Size {
|
||||
free := Size(h.bufRx.Free())
|
||||
if !h.reasm.enabled() {
|
||||
return free
|
||||
}
|
||||
if ooo := Size(h.reasm.bufferedBytes()); ooo < free {
|
||||
return free - ooo
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// AwaitingSynResponse returns true if the Handler is an active client opened with [Handler.OpenActive] and has already sent out the first SYN packet to the remote client.
|
||||
@@ -443,6 +603,20 @@ func (h *Handler) AwaitingSynResponse() bool {
|
||||
return h.remotePort != 0 && h.scb.State() == StateSynSent
|
||||
}
|
||||
|
||||
// IsAwaitingControl reports whether the connection is waiting for a response to
|
||||
// a control segment that can be retransmitted to advance connection state.
|
||||
func (h *Handler) IsAwaitingControl() bool {
|
||||
return h.AwaitingSynResponse() || h.scb.State() == StateSynRcvd
|
||||
}
|
||||
|
||||
// RequeueControl asks the next Send call to retransmit the outstanding control
|
||||
// segment, if the connection is waiting for one.
|
||||
func (h *Handler) RequeueControl() {
|
||||
if h.IsAwaitingControl() {
|
||||
h.requeueControl = true
|
||||
}
|
||||
}
|
||||
|
||||
// AwaitingSynAck returns true if the Handler is a passive server opened with [Handler.OpenListen] and not yet received a valid SYN remote packet.
|
||||
func (h *Handler) AwaitingSynAck() bool {
|
||||
return h.remotePort == 0 && h.scb.State() == StateListen
|
||||
|
||||
@@ -3,6 +3,7 @@ package tcp
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
@@ -20,6 +21,103 @@ func TestHandler(t *testing.T) {
|
||||
sendDataFull(t, client, server, []byte("hello"), rawbuf[:])
|
||||
}
|
||||
|
||||
func TestHandler_RequeueControlRetransmitsSYN(t *testing.T) {
|
||||
const mtu = ethernet.MaxMTU
|
||||
const maxpackets = 3
|
||||
rng := rand.New(rand.NewSource(1))
|
||||
client, server := newHandler(t, mtu, maxpackets), newHandler(t, mtu, maxpackets)
|
||||
setupClientServer(t, rng, client, server)
|
||||
|
||||
var rawbuf [mtu]byte
|
||||
n, err := client.Send(rawbuf[:])
|
||||
if err != nil {
|
||||
t.Fatal("client sending initial SYN:", err)
|
||||
} else if n < sizeHeaderTCP {
|
||||
t.Fatalf("initial SYN size=%d, want at least %d", n, sizeHeaderTCP)
|
||||
}
|
||||
initial := mustSegment(t, rawbuf[:n], 0)
|
||||
if initial.Flags != FlagSYN {
|
||||
t.Fatalf("initial flags=%s, want SYN", initial.Flags)
|
||||
}
|
||||
if client.State() != StateSynSent {
|
||||
t.Fatalf("client state=%s, want SYN-SENT", client.State())
|
||||
}
|
||||
|
||||
clear(rawbuf[:])
|
||||
n, err = client.Send(rawbuf[:])
|
||||
if err != nil {
|
||||
t.Fatal("client sending before RequeueControl:", err)
|
||||
} else if n != 0 {
|
||||
t.Fatalf("Send before RequeueControl wrote %d bytes, want 0", n)
|
||||
}
|
||||
|
||||
client.RequeueControl()
|
||||
clear(rawbuf[:])
|
||||
n, err = client.Send(rawbuf[:])
|
||||
if err != nil {
|
||||
t.Fatal("client retransmitting SYN:", err)
|
||||
} else if n < sizeHeaderTCP {
|
||||
t.Fatalf("retransmitted SYN size=%d, want at least %d", n, sizeHeaderTCP)
|
||||
}
|
||||
retransmit := mustSegment(t, rawbuf[:n], 0)
|
||||
if retransmit.Flags != FlagSYN {
|
||||
t.Fatalf("retransmit flags=%s, want SYN", retransmit.Flags)
|
||||
}
|
||||
if retransmit.SEQ != initial.SEQ {
|
||||
t.Fatalf("retransmit SEQ=%d, want initial SEQ=%d", retransmit.SEQ, initial.SEQ)
|
||||
}
|
||||
|
||||
if err := server.Recv(rawbuf[:n]); err != nil {
|
||||
t.Fatal("server receiving retransmitted SYN:", err)
|
||||
}
|
||||
clear(rawbuf[:])
|
||||
n, err = server.Send(rawbuf[:])
|
||||
if err != nil {
|
||||
t.Fatal("server sending SYN-ACK:", err)
|
||||
}
|
||||
segSynAck := mustSegment(t, rawbuf[:n], 0)
|
||||
if segSynAck.Flags != synack {
|
||||
t.Fatalf("server flags=%s, want SYN-ACK", segSynAck.Flags)
|
||||
}
|
||||
if err := client.Recv(rawbuf[:n]); err != nil {
|
||||
t.Fatal("client receiving SYN-ACK:", err)
|
||||
}
|
||||
if client.State() != StateEstablished {
|
||||
t.Fatalf("client state=%s, want ESTABLISHED", client.State())
|
||||
}
|
||||
|
||||
clear(rawbuf[:])
|
||||
n, err = client.Send(rawbuf[:]) // final ACK.
|
||||
if err != nil {
|
||||
t.Fatal("client sending final ACK:", err)
|
||||
}
|
||||
if ack := mustSegment(t, rawbuf[:n], 0); ack.Flags != FlagACK {
|
||||
t.Fatalf("client final flags=%s, want ACK", ack.Flags)
|
||||
}
|
||||
if err := server.Recv(rawbuf[:n]); err != nil {
|
||||
t.Fatal("server receiving final ACK:", err)
|
||||
}
|
||||
|
||||
client.RequeueControl()
|
||||
clear(rawbuf[:])
|
||||
n, err = client.Send(rawbuf[:])
|
||||
if err != nil {
|
||||
t.Fatal("client sending after establishment:", err)
|
||||
} else if n != 0 {
|
||||
seg := mustSegment(t, rawbuf[:n], 0)
|
||||
t.Fatalf("Send after establishment wrote %d bytes (%s), want 0", n, seg.Flags)
|
||||
}
|
||||
}
|
||||
|
||||
func mustSegment(t *testing.T, b []byte, payloadLen int) Segment {
|
||||
t.Helper()
|
||||
frame, err := NewFrame(b)
|
||||
if err != nil {
|
||||
t.Fatal("parse TCP frame:", err)
|
||||
}
|
||||
return frame.Segment(payloadLen)
|
||||
}
|
||||
|
||||
func sendDataFull(t *testing.T, client, server *Handler, data, packetBuf []byte) {
|
||||
n, err := client.Write(data)
|
||||
if err != nil {
|
||||
@@ -1390,3 +1488,103 @@ func TestRetransmit_CumulativeACK_NoSpurious(t *testing.T) {
|
||||
t.Fatalf("spurious retransmission after cumulative ACK: SEQ=%d len=%d (issue #57)", seg.SEQ, seg.DATALEN)
|
||||
}
|
||||
}
|
||||
|
||||
// emitClientData writes payload to the client and emits it as one data packet,
|
||||
// returning a copy of the wire bytes (the caller controls delivery order).
|
||||
func emitClientData(t *testing.T, client *Handler, buf []byte, payload string) []byte {
|
||||
t.Helper()
|
||||
if _, err := client.Write([]byte(payload)); err != nil {
|
||||
t.Fatal("client write:", err)
|
||||
}
|
||||
clear(buf)
|
||||
n, err := client.Send(buf)
|
||||
if err != nil {
|
||||
t.Fatal("client send:", err)
|
||||
}
|
||||
if n <= sizeHeaderTCP {
|
||||
t.Fatal("expected a data segment, got header-only")
|
||||
}
|
||||
return append([]byte(nil), buf[:n]...)
|
||||
}
|
||||
|
||||
// TestHandler_OutOfOrderReassembly drives the full out-of-order path: a later
|
||||
// segment delivered before the gap-filling one is staged, then delivered
|
||||
// contiguously once the gap arrives, without go-back-N.
|
||||
func TestHandler_OutOfOrderReassembly(t *testing.T) {
|
||||
const mtu = ethernet.MaxMTU
|
||||
const maxpackets = 4
|
||||
rng := rand.New(rand.NewSource(99))
|
||||
client, server := newHandler(t, mtu, maxpackets), newHandler(t, mtu, maxpackets)
|
||||
setupClientServer(t, rng, client, server)
|
||||
var buf [mtu]byte
|
||||
establish(t, client, server, buf[:])
|
||||
|
||||
pkt1 := emitClientData(t, client, buf[:], "AAAA") // seq S, covers S..S+4.
|
||||
pkt2 := emitClientData(t, client, buf[:], "BBBB") // seq S+4, covers S+4..S+8.
|
||||
|
||||
full := server.SizeInput()
|
||||
|
||||
// Deliver the second segment first: accepted, buffered, not yet readable.
|
||||
if err := server.Recv(pkt2); err != nil {
|
||||
t.Fatalf("out-of-order segment must be accepted, got: %v", err)
|
||||
}
|
||||
if server.BufferedInput() != 0 {
|
||||
t.Fatalf("OOO data must not be readable yet, buffered=%d", server.BufferedInput())
|
||||
}
|
||||
// Advertised window shrinks by the held bytes so the peer cannot overrun.
|
||||
if got := server.FreeInput(); got != full-4 {
|
||||
t.Fatalf("FreeInput=%d, want %d (window reduced by held OOO bytes)", got, full-4)
|
||||
}
|
||||
|
||||
// Deliver the gap-filling first segment: both become contiguous.
|
||||
if err := server.Recv(pkt1); err != nil {
|
||||
t.Fatalf("gap-filling segment: %v", err)
|
||||
}
|
||||
if server.BufferedInput() != 8 {
|
||||
t.Fatalf("buffered=%d, want 8 after gap fill", server.BufferedInput())
|
||||
}
|
||||
if got := server.FreeInput(); got != full-8 {
|
||||
t.Fatalf("FreeInput=%d, want %d after delivery", got, full-8)
|
||||
}
|
||||
|
||||
var rd [16]byte
|
||||
n, err := server.Read(rd[:])
|
||||
if err != nil {
|
||||
t.Fatal("server read:", err)
|
||||
}
|
||||
if string(rd[:n]) != "AAAABBBB" {
|
||||
t.Fatalf("reassembled %q, want AAAABBBB", rd[:n])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_OutOfOrderDiscardedAfterShutdownRead verifies staged segments are
|
||||
// dropped, not delivered, when the read side is shut down before the gap fills.
|
||||
func TestHandler_OutOfOrderDiscardedAfterShutdownRead(t *testing.T) {
|
||||
const mtu = ethernet.MaxMTU
|
||||
const maxpackets = 4
|
||||
rng := rand.New(rand.NewSource(100))
|
||||
client, server := newHandler(t, mtu, maxpackets), newHandler(t, mtu, maxpackets)
|
||||
setupClientServer(t, rng, client, server)
|
||||
var buf [mtu]byte
|
||||
establish(t, client, server, buf[:])
|
||||
|
||||
pkt1 := emitClientData(t, client, buf[:], "AAAA")
|
||||
pkt2 := emitClientData(t, client, buf[:], "BBBB")
|
||||
|
||||
if err := server.Recv(pkt2); err != nil { // buffer out of order.
|
||||
t.Fatalf("OOO segment: %v", err)
|
||||
}
|
||||
server.ShutdownRead() // application done reading; staged data must be dropped.
|
||||
|
||||
if err := server.Recv(pkt1); err != nil && !IsDroppedErr(err) {
|
||||
t.Fatalf("gap-filling segment after shutdown: %v", err)
|
||||
}
|
||||
var rd [16]byte
|
||||
n, err := server.Read(rd[:])
|
||||
if n != 0 || err != io.EOF {
|
||||
t.Fatalf("read after ShutdownRead = %d,%v want 0,EOF", n, err)
|
||||
}
|
||||
if server.BufferedInput() != 0 {
|
||||
t.Fatalf("discard mode must hold no data, buffered=%d", server.BufferedInput())
|
||||
}
|
||||
}
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package tcp
|
||||
|
||||
// LossRecovery abstracts TCP packet-loss recovery: RTO, congestion control and
|
||||
// any similar algorithm that observes segment traffic and steers the
|
||||
// connection's transmit behaviour. As far as the tcp package is concerned these
|
||||
// are all the same thing — packet-loss recovery algorithms — so they share one
|
||||
// interface (see discussion #157).
|
||||
//
|
||||
// The tcp package stays free of any time source: the current monotonic time in
|
||||
// nanoseconds (the func() int64 convention used across lneto) is passed in at
|
||||
// each hook boundary. It originates from [ConnConfig.Nanotime] and satisfies the
|
||||
// "WHEN was this segment rx/tx'd" requirement without a clock living inside the
|
||||
// state machine, which also keeps implementations deterministic for testing
|
||||
// (see issue #140).
|
||||
//
|
||||
// The interface is intentionally free of errors: an implementation handles or
|
||||
// reports its own errors rather than propagating them into lneto internals.
|
||||
//
|
||||
// Introspection (smoothed RTT, current window, ...) is deliberately left off the
|
||||
// interface; expose it on the concrete implementation the caller constructs and
|
||||
// hands to [ConnConfig].
|
||||
type LossRecovery interface {
|
||||
// Reset returns the implementation to its initial, pre-connection state. It
|
||||
// is invoked whenever the connection is (re)opened or aborted so a single
|
||||
// LossRecovery value can be reused across the lifetime of connection reuse
|
||||
// (see discussion #115).
|
||||
Reset()
|
||||
|
||||
// NextDeadline returns the monotonic-nanosecond instant at which the
|
||||
// connection must next be serviced by a transmit attempt — typically the RTO
|
||||
// expiry. A return of 0 means there is no pending deadline. It replaces a
|
||||
// poll/atomic-flag scheme with a deadline the caller's event loop can
|
||||
// schedule against.
|
||||
NextDeadline() int64
|
||||
|
||||
// PreRx is called for every segment received on the TCP port before the
|
||||
// state machine processes it, with the monotonic time the segment arrived. It
|
||||
// returns whether the segment should be kept (processed) or dropped.
|
||||
PreRx(incoming Segment, now int64) RxDirective
|
||||
|
||||
// PreTx is called on entering the transmit path (Encapsulate), before a
|
||||
// segment is built, with the current monotonic time. Its directive tells the
|
||||
// connection whether to retransmit unacknowledged data, rewind the send
|
||||
// pointer, or hold back new data.
|
||||
PreTx(now int64) TxDirective
|
||||
|
||||
// PostTx is called on leaving the transmit path with the segment that was
|
||||
// actually emitted and the monotonic time it was sent. This is where segment
|
||||
// timing (for RTT sampling and the retransmission timer) is recorded.
|
||||
PostTx(outgoing Segment, now int64)
|
||||
}
|
||||
|
||||
// TxDirective is returned by [LossRecovery.PreTx] to steer the transmit path.
|
||||
// The zero value directs the connection to proceed normally (send new data if
|
||||
// available, no retransmission).
|
||||
type TxDirective struct {
|
||||
// RewindNXT is the number of sequence-space octets to rewind snd.NXT by
|
||||
// before transmitting, for partial (e.g. selective) retransmission. Zero
|
||||
// means no rewind. It is independent of Retransmit, which rewinds fully to
|
||||
// snd.UNA.
|
||||
// RewindNXT uint32
|
||||
|
||||
// RetransmitAll requests go-back-N retransmission: the connection rewinds
|
||||
// snd.NXT to snd.UNA and resends unacknowledged data from the oldest
|
||||
// sequence number.
|
||||
RetransmitAll bool
|
||||
// HoldNew pauses transmission of new data (for example when the congestion
|
||||
// window is exhausted). Retransmissions already directed by this same
|
||||
// directive still proceed.
|
||||
// HoldNew bool
|
||||
}
|
||||
|
||||
// RxDirective is returned by [LossRecovery.PreRx].
|
||||
//
|
||||
// NOTE: its shape is the minimum viable contract — it mirrors the original
|
||||
// PreRx "keep" boolean from discussion #157 — and is the one element of the
|
||||
// interface not yet fully settled there. It is a struct (rather than a bare
|
||||
// bool) so fields can be added without breaking implementations.
|
||||
type RxDirective struct {
|
||||
// Keep reports whether the received segment should be handed to the state
|
||||
// machine. A false value drops the segment before it is processed.
|
||||
Keep bool
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package tcp
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"github.com/soypat/lneto/ethernet"
|
||||
)
|
||||
|
||||
// recordingLoss is a test LossRecovery that records every hook invocation and
|
||||
// lets the test steer the directives returned to the Handler. It is the
|
||||
// interface counterpart driven by the Handler under test.
|
||||
type recordingLoss struct {
|
||||
resets int
|
||||
preRx []hookCall
|
||||
preTx []int64
|
||||
postTx []hookCall
|
||||
deadline int64 // value NextDeadline reports back.
|
||||
|
||||
// Directives handed back to the Handler.
|
||||
keep bool // PreRx result. Default true (see newRecordingLoss).
|
||||
tx TxDirective // PreTx result.
|
||||
}
|
||||
|
||||
type hookCall struct {
|
||||
seg Segment
|
||||
now int64
|
||||
}
|
||||
|
||||
func newRecordingLoss() *recordingLoss { return &recordingLoss{keep: true} }
|
||||
|
||||
var _ LossRecovery = (*recordingLoss)(nil)
|
||||
|
||||
func (l *recordingLoss) Reset() { l.resets++ }
|
||||
func (l *recordingLoss) NextDeadline() int64 { return l.deadline }
|
||||
|
||||
func (l *recordingLoss) PreRx(incoming Segment, now int64) RxDirective {
|
||||
l.preRx = append(l.preRx, hookCall{seg: incoming, now: now})
|
||||
return RxDirective{Keep: l.keep}
|
||||
}
|
||||
|
||||
func (l *recordingLoss) PreTx(now int64) TxDirective {
|
||||
l.preTx = append(l.preTx, now)
|
||||
return l.tx
|
||||
}
|
||||
|
||||
func (l *recordingLoss) PostTx(outgoing Segment, now int64) {
|
||||
l.postTx = append(l.postTx, hookCall{seg: outgoing, now: now})
|
||||
}
|
||||
|
||||
// TestLossRecovery_DisabledByDefault verifies the Handler runs normally with no
|
||||
// loss recovery installed: NextDeadline reports no deadline and the transmit/
|
||||
// receive paths never touch a nil LossRecovery.
|
||||
func TestLossRecovery_DisabledByDefault(t *testing.T) {
|
||||
const mtu = ethernet.MaxMTU
|
||||
rng := rand.New(rand.NewSource(1))
|
||||
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
|
||||
setupClientServer(t, rng, client, server)
|
||||
|
||||
if d := client.NextDeadline(); d != 0 {
|
||||
t.Fatalf("NextDeadline with no loss recovery = %d, want 0", d)
|
||||
}
|
||||
var buf [mtu]byte
|
||||
establish(t, client, server, buf[:]) // must not panic on nil loss recovery.
|
||||
}
|
||||
|
||||
// TestLossRecovery_HooksInvoked verifies the Handler drives the full hook
|
||||
// contract across a handshake: Reset on open, PreTx+PostTx on every transmit,
|
||||
// PreRx on every receive, each stamped with the configured monotonic clock.
|
||||
func TestLossRecovery_HooksInvoked(t *testing.T) {
|
||||
const mtu = ethernet.MaxMTU
|
||||
rng := rand.New(rand.NewSource(2))
|
||||
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
|
||||
|
||||
loss := newRecordingLoss()
|
||||
const clockNow = 1_000_000
|
||||
client.SetLossRecovery(loss, func() int64 { return clockNow })
|
||||
|
||||
setupClientServer(t, rng, client, server) // OpenActive → reset → Reset().
|
||||
if loss.resets == 0 {
|
||||
t.Fatal("Reset not called on open")
|
||||
}
|
||||
|
||||
var buf [mtu]byte
|
||||
establish(t, client, server, buf[:])
|
||||
|
||||
// Client emitted SYN and the final ACK: both paths must have hit PreTx/PostTx.
|
||||
if len(loss.preTx) == 0 {
|
||||
t.Fatal("PreTx never called on transmit")
|
||||
}
|
||||
if len(loss.postTx) == 0 {
|
||||
t.Fatal("PostTx never called on transmit")
|
||||
}
|
||||
if len(loss.preTx) != len(loss.postTx) {
|
||||
t.Fatalf("PreTx calls=%d, PostTx calls=%d, want equal", len(loss.preTx), len(loss.postTx))
|
||||
}
|
||||
// Client received the SYN-ACK: PreRx must have seen it.
|
||||
if len(loss.preRx) == 0 {
|
||||
t.Fatal("PreRx never called on receive")
|
||||
}
|
||||
|
||||
// The Handler holds no clock: every hook must be stamped from the supplied
|
||||
// nanotime source.
|
||||
for i, c := range loss.postTx {
|
||||
if c.now != clockNow {
|
||||
t.Fatalf("PostTx[%d].now = %d, want clock %d", i, c.now, clockNow)
|
||||
}
|
||||
}
|
||||
for i, now := range loss.preTx {
|
||||
if now != clockNow {
|
||||
t.Fatalf("PreTx[%d].now = %d, want clock %d", i, now, clockNow)
|
||||
}
|
||||
}
|
||||
for i, c := range loss.preRx {
|
||||
if c.now != clockNow {
|
||||
t.Fatalf("PreRx[%d].now = %d, want clock %d", i, c.now, clockNow)
|
||||
}
|
||||
}
|
||||
|
||||
// PostTx receives the segment actually emitted: the first is the SYN.
|
||||
if !loss.postTx[0].seg.Flags.HasAny(FlagSYN) {
|
||||
t.Fatalf("first PostTx segment flags=%s, want SYN", loss.postTx[0].seg.Flags)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLossRecovery_NextDeadlineDelegates verifies NextDeadline is forwarded to
|
||||
// the installed LossRecovery unchanged.
|
||||
func TestLossRecovery_NextDeadlineDelegates(t *testing.T) {
|
||||
const mtu = ethernet.MaxMTU
|
||||
rng := rand.New(rand.NewSource(3))
|
||||
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
|
||||
|
||||
loss := newRecordingLoss()
|
||||
loss.deadline = 4242
|
||||
client.SetLossRecovery(loss, func() int64 { return 1 })
|
||||
setupClientServer(t, rng, client, server)
|
||||
|
||||
if d := client.NextDeadline(); d != 4242 {
|
||||
t.Fatalf("NextDeadline = %d, want delegated 4242", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLossRecovery_PreRxDropsSegment verifies a PreRx directive of Keep=false
|
||||
// drops the segment before the state machine sees it: the payload is not
|
||||
// buffered and connection state is untouched.
|
||||
func TestLossRecovery_PreRxDropsSegment(t *testing.T) {
|
||||
const mtu = ethernet.MaxMTU
|
||||
rng := rand.New(rand.NewSource(4))
|
||||
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
|
||||
|
||||
loss := newRecordingLoss()
|
||||
server.SetLossRecovery(loss, func() int64 { return 1 })
|
||||
setupClientServer(t, rng, client, server)
|
||||
var buf [mtu]byte
|
||||
establish(t, client, server, buf[:]) // keep=true so handshake completes.
|
||||
|
||||
// Now start dropping everything the server receives.
|
||||
loss.keep = false
|
||||
preRxBefore := len(loss.preRx)
|
||||
|
||||
data := []byte("dropme")
|
||||
if _, err := client.Write(data); err != nil {
|
||||
t.Fatal("client write:", err)
|
||||
}
|
||||
clear(buf[:])
|
||||
n, err := client.Send(buf[:])
|
||||
if err != nil {
|
||||
t.Fatal("client send:", err)
|
||||
}
|
||||
|
||||
if err := server.Recv(buf[:n]); err != nil {
|
||||
t.Fatalf("dropped segment must return nil, got %v", err)
|
||||
}
|
||||
if len(loss.preRx) != preRxBefore+1 {
|
||||
t.Fatalf("PreRx calls=%d, want %d (segment must reach PreRx)", len(loss.preRx), preRxBefore+1)
|
||||
}
|
||||
if server.BufferedInput() != 0 {
|
||||
t.Fatalf("dropped segment must not be buffered, got %d bytes", server.BufferedInput())
|
||||
}
|
||||
if server.State() != StateEstablished {
|
||||
t.Fatalf("dropped segment must not change state, got %s", server.State())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLossRecovery_PreTxRetransmitAll verifies a PreTx directive of
|
||||
// RetransmitAll drives go-back-N: the Handler rewinds and re-emits already-sent,
|
||||
// unacknowledged data from snd.UNA on the next transmit.
|
||||
func TestLossRecovery_PreTxRetransmitAll(t *testing.T) {
|
||||
const mtu = ethernet.MaxMTU
|
||||
rng := rand.New(rand.NewSource(5))
|
||||
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
|
||||
|
||||
loss := newRecordingLoss()
|
||||
client.SetLossRecovery(loss, func() int64 { return 1 })
|
||||
setupClientServer(t, rng, client, server)
|
||||
var buf [mtu]byte
|
||||
establish(t, client, server, buf[:])
|
||||
|
||||
// Emit one data segment; server never ACKs, so it stays unacknowledged.
|
||||
data := []byte("payload")
|
||||
if _, err := client.Write(data); err != nil {
|
||||
t.Fatal("client write:", err)
|
||||
}
|
||||
clear(buf[:])
|
||||
n, err := client.Send(buf[:])
|
||||
if err != nil {
|
||||
t.Fatal("client send data:", err)
|
||||
}
|
||||
if n <= sizeHeaderTCP {
|
||||
t.Fatal("expected data segment")
|
||||
}
|
||||
firstSeg := mustSegment(t, buf[:n], n-sizeHeaderTCP)
|
||||
|
||||
// Direct go-back-N on the next transmit.
|
||||
loss.tx = TxDirective{RetransmitAll: true}
|
||||
clear(buf[:])
|
||||
n, err = client.Send(buf[:])
|
||||
if err != nil {
|
||||
t.Fatal("client send retransmit:", err)
|
||||
}
|
||||
if n <= sizeHeaderTCP {
|
||||
t.Fatal("expected retransmitted data segment")
|
||||
}
|
||||
rtSeg := mustSegment(t, buf[:n], n-sizeHeaderTCP)
|
||||
|
||||
if rtSeg.SEQ != firstSeg.SEQ {
|
||||
t.Fatalf("retransmit SEQ=%d, want original UNA SEQ=%d (go-back-N)", rtSeg.SEQ, firstSeg.SEQ)
|
||||
}
|
||||
if rtSeg.DATALEN != firstSeg.DATALEN {
|
||||
t.Fatalf("retransmit DATALEN=%d, want %d", rtSeg.DATALEN, firstSeg.DATALEN)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLossRecovery_ResetOnReopen verifies Reset fires on every (re)open and on
|
||||
// Abort, so a single LossRecovery value can be reused across connection reuse.
|
||||
func TestLossRecovery_ResetOnReopen(t *testing.T) {
|
||||
const mtu = ethernet.MaxMTU
|
||||
client := newHandler(t, mtu, 3)
|
||||
loss := newRecordingLoss()
|
||||
client.SetLossRecovery(loss, func() int64 { return 1 })
|
||||
|
||||
if err := client.OpenActive(1234, 5678, 0); err != nil {
|
||||
t.Fatal("open 1:", err)
|
||||
}
|
||||
afterOpen := loss.resets
|
||||
if afterOpen == 0 {
|
||||
t.Fatal("Reset not called on first open")
|
||||
}
|
||||
|
||||
client.Abort()
|
||||
if loss.resets <= afterOpen {
|
||||
t.Fatalf("Reset not called on Abort: resets=%d, want >%d", loss.resets, afterOpen)
|
||||
}
|
||||
afterAbort := loss.resets
|
||||
|
||||
if err := client.OpenActive(1234, 5678, 0); err != nil {
|
||||
t.Fatal("open 2:", err)
|
||||
}
|
||||
if loss.resets <= afterAbort {
|
||||
t.Fatalf("Reset not called on reopen: resets=%d, want >%d", loss.resets, afterAbort)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package tcp
|
||||
|
||||
import "github.com/soypat/lneto/internal"
|
||||
|
||||
// maxReasmSegments bounds how many distinct out-of-order segments may be held.
|
||||
// It caps only fixed metadata; payload bytes live in the receive ring, bounded
|
||||
// by its free space. Independent of the transmit queue depth.
|
||||
const maxReasmSegments = 8
|
||||
|
||||
// reassembly holds in-window TCP segments that arrived ahead of the next
|
||||
// expected sequence number, so that once the gap is filled the buffered tail is
|
||||
// delivered without go-back-N. Payloads are staged in the free region of the
|
||||
// Handler receive ring (see [internal.Ring.PeekWrite]); only fixed, reused
|
||||
// metadata lives here, so the data path allocates nothing.
|
||||
//
|
||||
// held is kept ordered by ascending sequence number (oldest to newest), which
|
||||
// lets [reassembly.store] locate insertions and overlaps by neighbour and lets
|
||||
// [reassembly.reassemble] deliver a contiguous prefix and truncate it in one
|
||||
// pass.
|
||||
type reassembly struct {
|
||||
held []reasmSeg
|
||||
}
|
||||
|
||||
// reasmSeg records a held segment by sequence number and payload length. No
|
||||
// buffer offset is kept: the ring write pointer advances in lockstep with
|
||||
// rcv.NXT, so the staged bytes are always where seq implies (see
|
||||
// [reassembly.reassemble]).
|
||||
type reasmSeg struct {
|
||||
seq Value
|
||||
n int
|
||||
}
|
||||
|
||||
// reset (re)configures bounded metadata for up to maxSegs held segments, or
|
||||
// disables reassembly when maxSegs is not positive. Held state is cleared;
|
||||
// metadata capacity persists across connection reopens.
|
||||
func (r *reassembly) reset(maxSegs int) {
|
||||
if maxSegs <= 0 {
|
||||
r.held = nil
|
||||
return
|
||||
}
|
||||
internal.SliceReuse(&r.held, maxSegs)
|
||||
}
|
||||
|
||||
// clear drops all held segments without changing configuration.
|
||||
func (r *reassembly) clear() { r.held = r.held[:0] }
|
||||
|
||||
// enabled reports whether out-of-order buffering is configured.
|
||||
func (r *reassembly) enabled() bool { return cap(r.held) > 0 }
|
||||
|
||||
// buffered reports the number of out-of-order segments currently held.
|
||||
func (r *reassembly) buffered() int { return len(r.held) }
|
||||
|
||||
// bufferedBytes reports the total payload bytes currently held out of order.
|
||||
// The receiver subtracts these from its advertised window so the sender cannot
|
||||
// overrun the space the held segments already consume.
|
||||
func (r *reassembly) bufferedBytes() int {
|
||||
n := 0
|
||||
for i := range r.held {
|
||||
n += r.held[i].n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// store stages payload at the offset it will occupy in rx once the gap from
|
||||
// rcvNxt fills, keeping held ordered by seq. It returns true when held,
|
||||
// including when already held (storing is idempotent), and false when disabled,
|
||||
// the payload is empty, metadata is full, it does not fit rx's free region, or
|
||||
// it overlaps a held segment.
|
||||
func (r *reassembly) store(rx *internal.Ring, rcvNxt, seq Value, payload []byte) bool {
|
||||
if !r.enabled() || len(payload) == 0 || len(r.held) >= cap(r.held) {
|
||||
return false
|
||||
}
|
||||
gap := int(Sizeof(rcvNxt, seq))
|
||||
// Early free-space bail before the ordered-insert scan; strictly cautious,
|
||||
// as PeekWrite re-checks this below.
|
||||
if gap+len(payload) > rx.Free() {
|
||||
return false
|
||||
}
|
||||
// Find the insertion point that keeps held ordered by ascending seq.
|
||||
end := Add(seq, Size(len(payload)))
|
||||
i := 0
|
||||
for i < len(r.held) && r.held[i].seq.LessThan(seq) {
|
||||
i++
|
||||
}
|
||||
if i > 0 { // Overlaps the predecessor?
|
||||
if prev := r.held[i-1]; seq.LessThan(Add(prev.seq, Size(prev.n))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if i < len(r.held) { // Duplicate, or overlaps the successor?
|
||||
if next := r.held[i]; next.seq == seq {
|
||||
return true // already buffered; idempotent.
|
||||
} else if next.seq.LessThan(end) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !rx.PeekWrite(payload, gap) {
|
||||
return false
|
||||
}
|
||||
r.held = append(r.held, reasmSeg{})
|
||||
copy(r.held[i+1:], r.held[i:])
|
||||
r.held[i] = reasmSeg{seq: seq, n: len(payload)}
|
||||
return true
|
||||
}
|
||||
|
||||
// reassemble delivers held segments contiguous with nxt by committing their
|
||||
// staged bytes to rx, and drops any beginning before nxt (stale, or overwritten
|
||||
// by the in-order write that advanced nxt). Because held is ordered, it walks a
|
||||
// leading prefix and truncates once. It returns the bytes delivered; the caller
|
||||
// advances rcv.NXT and ACKs. Delivery stops at the first gap, or if rx is full
|
||||
// (the remainder is delivered on a later call).
|
||||
func (r *reassembly) reassemble(rx *internal.Ring, nxt Value) Size {
|
||||
var delivered Size
|
||||
i := 0
|
||||
for i < len(r.held) {
|
||||
seg := r.held[i]
|
||||
switch {
|
||||
case seg.seq == nxt:
|
||||
if rx.Commit(seg.n) != nil {
|
||||
r.held = append(r.held[:0], r.held[i:]...)
|
||||
return delivered
|
||||
}
|
||||
nxt = Add(nxt, Size(seg.n))
|
||||
delivered += Size(seg.n)
|
||||
i++
|
||||
case seg.seq.LessThan(nxt):
|
||||
i++ // stale/overwritten: drop.
|
||||
default:
|
||||
r.held = append(r.held[:0], r.held[i:]...)
|
||||
return delivered // gap before the next segment.
|
||||
}
|
||||
}
|
||||
r.held = r.held[:0]
|
||||
return delivered
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package tcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/soypat/lneto/internal"
|
||||
)
|
||||
|
||||
func TestReassemblyDisabledByDefault(t *testing.T) {
|
||||
var r reassembly
|
||||
if r.enabled() {
|
||||
t.Fatal("zero-value reassembly must be disabled")
|
||||
}
|
||||
var rx internal.Ring
|
||||
if r.store(&rx, 100, 100, []byte("x")) {
|
||||
t.Error("store must fail when disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReassemblyStoreAndReassemble(t *testing.T) {
|
||||
var r reassembly
|
||||
r.reset(4)
|
||||
rx := internal.Ring{Buf: make([]byte, 32)}
|
||||
if !r.enabled() {
|
||||
t.Fatal("reassembly should be enabled after reset")
|
||||
}
|
||||
// Buffer two out-of-order segments (gap at seq 100), stored newest-first to
|
||||
// prove store keeps held ordered by seq.
|
||||
if !r.store(&rx, 100, 108, []byte("CCC")) { // covers 108..111
|
||||
t.Fatal("store 108 failed")
|
||||
}
|
||||
if !r.store(&rx, 100, 104, []byte("BBBB")) { // covers 104..108
|
||||
t.Fatal("store 104 failed")
|
||||
}
|
||||
if r.buffered() != 2 {
|
||||
t.Fatalf("buffered=%d, want 2", r.buffered())
|
||||
}
|
||||
if r.held[0].seq != 104 || r.held[1].seq != 108 {
|
||||
t.Fatalf("held not ordered by seq: %+v", r.held)
|
||||
}
|
||||
// A gap at 100 blocks delivery entirely.
|
||||
if got := r.reassemble(&rx, 100); got != 0 {
|
||||
t.Fatalf("reassemble(100)=%d, want 0 with a gap at 100", got)
|
||||
}
|
||||
// Once 100..104 is delivered in order, both held segments become contiguous.
|
||||
if _, err := rx.Write([]byte("AAAA")); err != nil {
|
||||
t.Fatal("gap write:", err)
|
||||
}
|
||||
if got := r.reassemble(&rx, 104); got != 7 {
|
||||
t.Fatalf("reassemble(104)=%d, want 7", got)
|
||||
}
|
||||
if r.buffered() != 0 {
|
||||
t.Errorf("buffered=%d, want 0 after full delivery", r.buffered())
|
||||
}
|
||||
out := make([]byte, 16)
|
||||
n, _ := rx.Read(out)
|
||||
if !bytes.Equal(out[:n], []byte("AAAABBBBCCC")) {
|
||||
t.Fatalf("reassembled %q, want AAAABBBBCCC", out[:n])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReassemblyDedup(t *testing.T) {
|
||||
var r reassembly
|
||||
r.reset(4)
|
||||
rx := internal.Ring{Buf: make([]byte, 32)}
|
||||
if !r.store(&rx, 100, 104, []byte("AAA")) {
|
||||
t.Fatal("first store failed")
|
||||
}
|
||||
if !r.store(&rx, 100, 104, []byte("AAA")) {
|
||||
t.Error("duplicate store of same seq should be idempotent true")
|
||||
}
|
||||
if r.buffered() != 1 {
|
||||
t.Errorf("buffered=%d, want 1 (duplicate must not add a segment)", r.buffered())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReassemblyFull(t *testing.T) {
|
||||
var r reassembly
|
||||
r.reset(2) // only 2 slots.
|
||||
rx := internal.Ring{Buf: make([]byte, 32)}
|
||||
if !r.store(&rx, 100, 104, []byte("a")) || !r.store(&rx, 100, 108, []byte("b")) {
|
||||
t.Fatal("filling slots failed")
|
||||
}
|
||||
if r.store(&rx, 100, 116, []byte("c")) {
|
||||
t.Error("store must fail when all slots are occupied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReassemblyOversizedRejected(t *testing.T) {
|
||||
var r reassembly
|
||||
r.reset(2)
|
||||
rx := internal.Ring{Buf: make([]byte, 4)}
|
||||
if r.store(&rx, 100, 104, []byte("toolong")) {
|
||||
t.Error("payload larger than free receive space must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReassemblyOverlapRejected(t *testing.T) {
|
||||
var r reassembly
|
||||
r.reset(4)
|
||||
rx := internal.Ring{Buf: make([]byte, 32)}
|
||||
if !r.store(&rx, 100, 104, []byte("BBBB")) { // covers 104..108
|
||||
t.Fatal("store 104 failed")
|
||||
}
|
||||
// Segments overlapping the held 104..108 region must be rejected.
|
||||
if r.store(&rx, 100, 106, []byte("XX")) { // 106..108 overlaps its successor
|
||||
t.Error("overlapping store must be rejected")
|
||||
}
|
||||
if r.store(&rx, 100, 102, []byte("YYYY")) { // 102..106 overlaps its predecessor
|
||||
t.Error("overlapping store must be rejected")
|
||||
}
|
||||
if r.buffered() != 1 {
|
||||
t.Errorf("buffered=%d, want 1 (overlaps must not be stored)", r.buffered())
|
||||
}
|
||||
}
|
||||
|
||||
// TestReassembleDropsStale checks segments beginning before nxt are dropped, not
|
||||
// delivered (their staged bytes may have been overwritten by the in-order write).
|
||||
func TestReassembleDropsStale(t *testing.T) {
|
||||
var r reassembly
|
||||
r.reset(4)
|
||||
rx := internal.Ring{Buf: make([]byte, 32)}
|
||||
r.store(&rx, 100, 104, []byte("BBBB")) // covers 104..108
|
||||
r.store(&rx, 100, 112, []byte("DDDD")) // covers 112..116
|
||||
// rcv.NXT advanced to 106, partway into the first held segment, with a gap
|
||||
// before the second: the stale segment is dropped and nothing is delivered.
|
||||
if got := r.reassemble(&rx, 106); got != 0 {
|
||||
t.Errorf("reassemble(106)=%d, want 0", got)
|
||||
}
|
||||
if r.buffered() != 1 || r.held[0].seq != 112 {
|
||||
t.Errorf("held=%+v, want only seq 112", r.held)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReassemblyResetDisables(t *testing.T) {
|
||||
var r reassembly
|
||||
rx := internal.Ring{Buf: make([]byte, 16)}
|
||||
r.reset(4)
|
||||
r.store(&rx, 100, 104, []byte("a"))
|
||||
r.reset(0)
|
||||
if r.enabled() {
|
||||
t.Error("reset(0) must disable reassembly")
|
||||
}
|
||||
if r.buffered() != 0 {
|
||||
t.Error("reset must clear held segments")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReassembly_noAllocs verifies the data-path operations allocate nothing
|
||||
// once configured (metadata is bounded at reset, payloads reuse the ring).
|
||||
func TestReassembly_noAllocs(t *testing.T) {
|
||||
var r reassembly
|
||||
r.reset(4)
|
||||
rx := internal.Ring{Buf: make([]byte, 32)}
|
||||
seg := []byte("DATA")
|
||||
allocs := testing.AllocsPerRun(100, func() {
|
||||
r.clear()
|
||||
rx.Reset()
|
||||
r.store(&rx, 100, 108, seg) // buffer out of order.
|
||||
r.store(&rx, 100, 104, seg) // buffer out of order.
|
||||
_ = r.bufferedBytes()
|
||||
_ = r.reassemble(&rx, 112)
|
||||
})
|
||||
if allocs != 0 {
|
||||
t.Errorf("reassembly data path must not allocate, got %v allocs/op", allocs)
|
||||
}
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
package tcp
|
||||
|
||||
import "time"
|
||||
|
||||
// RFC 6298 retransmission-timeout (RTO) parameters. The algorithm keeps a
|
||||
// single retransmission timer per connection (RFC 6298 §5): the timer is
|
||||
// (re)started whenever new data is acknowledged while data remains in flight,
|
||||
// stopped when all data is acknowledged, and on expiry the oldest unacknowledged
|
||||
// segment is retransmitted and the RTO is doubled (exponential backoff, §5.5).
|
||||
const (
|
||||
// rtoInitial is the RTO used before the first RTT measurement (RFC 6298 §2.1).
|
||||
rtoInitial = time.Second
|
||||
// rtoMin clamps the lower bound of the RTO. RFC 6298 §2.4 recommends a
|
||||
// minimum of 1s, but that is punishing on the low-latency links lneto
|
||||
// targets; like Linux we use a smaller floor so recovery on LAN/embedded
|
||||
// links is timely.
|
||||
rtoMin = 200 * time.Millisecond
|
||||
// rtoMax clamps the upper bound across exponential backoff (RFC 6298 §5.5
|
||||
// permits a maximum of at least 60s).
|
||||
rtoMax = 60 * time.Second
|
||||
|
||||
// rttGainShift (alpha = 1/8) and rttvarGainShift (beta = 1/4) are the
|
||||
// smoothing gains of RFC 6298 §2.3, applied as integer shifts.
|
||||
rttGainShift = 3 // alpha = 1/8
|
||||
rttvarGainShift = 2 // beta = 1/4
|
||||
// rttvarK is the RTTVAR multiplier in RTO = SRTT + K*RTTVAR (RFC 6298 §2.3).
|
||||
rttvarK = 4
|
||||
// backoffMax caps the exponential-backoff doublings so RTO arithmetic cannot
|
||||
// overflow and a wedged connection keeps probing at rtoMax.
|
||||
backoffMax = 12
|
||||
)
|
||||
|
||||
// RTO implements the RFC 6298 round-trip-time estimator and the single
|
||||
// retransmission timer as a [LossRecovery]. Construct it with new(RTO) and hand
|
||||
// it to [ConnConfig.LossRecovery]; the connection calls [RTO.Reset] on open, so
|
||||
// the zero value is ready to use.
|
||||
//
|
||||
// RTO is a pure, reactive state machine: it observes the segments a connection
|
||||
// sends and receives (via the LossRecovery hooks) and the monotonic time handed
|
||||
// in at each hook, and from those alone derives RTT estimates and retransmission
|
||||
// decisions. It holds no clock and allocates nothing, which keeps it
|
||||
// deterministic for unit testing (see issue #140).
|
||||
//
|
||||
// RTO tracks its own shadow of the send sequence space purely from the segments
|
||||
// it observes: [RTO.PostTx] advances the highest sequence sent and [RTO.PreRx]
|
||||
// advances the highest sequence acknowledged. This is what lets it manage the
|
||||
// timer (RFC 6298 §5.2/§5.3) without reaching into the tcp state machine, and it
|
||||
// is also how retransmissions are distinguished for Karn's algorithm — a segment
|
||||
// whose sequence space is not beyond the shadow snd.NXT is a retransmission and
|
||||
// is never RTT-sampled.
|
||||
type RTO struct {
|
||||
srtt time.Duration // smoothed round-trip time (SRTT).
|
||||
rttvar time.Duration // round-trip-time variation (RTTVAR).
|
||||
rto time.Duration // current retransmission timeout.
|
||||
haveRTT bool // false until the first RTT sample is taken.
|
||||
|
||||
// Shadow of the send sequence space, derived from observed segments.
|
||||
haveSeq bool // false until the first data segment is observed.
|
||||
sndUNA Value // highest acknowledged sequence number seen on the wire.
|
||||
sndNXT Value // one past the highest sequence number sent.
|
||||
|
||||
// RTT sampling state (Karn's algorithm, RFC 6298 §3): at most one segment is
|
||||
// timed at a time and retransmitted segments are never sampled.
|
||||
timing bool
|
||||
timedSeq Value // ACK at or beyond this value completes the sample.
|
||||
timedAt int64 // send time (monotonic ns) of the timed segment.
|
||||
|
||||
// Retransmission timer state.
|
||||
running bool
|
||||
deadline int64 // time (monotonic ns) at which the timer expires.
|
||||
backoff uint8 // consecutive timeouts, for exponential backoff.
|
||||
}
|
||||
|
||||
var _ LossRecovery = (*RTO)(nil)
|
||||
|
||||
// Reset returns the estimator to its pre-connection state with the initial RTO.
|
||||
// It implements [LossRecovery] and is called when the connection opens or aborts
|
||||
// so the estimator can be reused across connection reuse.
|
||||
func (r *RTO) Reset() { *r = RTO{rto: rtoInitial} }
|
||||
|
||||
// SmoothedRTT returns the current smoothed round-trip time (SRTT), or zero
|
||||
// before the first RTT measurement. It is concrete-type introspection and is
|
||||
// intentionally not part of [LossRecovery].
|
||||
func (r *RTO) SmoothedRTT() time.Duration { return r.srtt }
|
||||
|
||||
// CurrentRTO returns the timeout currently in effect, clamped to [rtoMin, rtoMax].
|
||||
func (r *RTO) CurrentRTO() time.Duration {
|
||||
rto := r.rto
|
||||
if rto < rtoMin {
|
||||
rto = rtoMin
|
||||
} else if rto > rtoMax {
|
||||
rto = rtoMax
|
||||
}
|
||||
return rto
|
||||
}
|
||||
|
||||
// Running reports whether the retransmission timer is currently armed.
|
||||
func (r *RTO) Running() bool { return r.running }
|
||||
|
||||
// NextDeadline returns the monotonic-nanosecond instant at which the timer
|
||||
// expires, or 0 when it is not armed. It implements [LossRecovery].
|
||||
func (r *RTO) NextDeadline() int64 {
|
||||
if !r.running {
|
||||
return 0
|
||||
}
|
||||
return r.deadline
|
||||
}
|
||||
|
||||
// PreRx samples the RTT and manages the retransmission timer from a received
|
||||
// segment (RFC 6298 §5.2/§5.3). It implements [LossRecovery] and always keeps
|
||||
// the segment (the estimator never drops traffic).
|
||||
func (r *RTO) PreRx(incoming Segment, now int64) RxDirective {
|
||||
if !r.haveSeq || !incoming.Flags.HasAny(FlagACK) {
|
||||
return RxDirective{Keep: true}
|
||||
}
|
||||
ack := incoming.ACK
|
||||
if r.timing && !ack.LessThan(r.timedSeq) {
|
||||
// ACK covers the timed segment: take the RTT sample (§4). A valid
|
||||
// measurement collapses the backoff (§5.7).
|
||||
r.updateRTT(time.Duration(now - r.timedAt))
|
||||
r.timing = false
|
||||
r.backoff = 0
|
||||
}
|
||||
if r.sndUNA.LessThan(ack) && !r.sndNXT.LessThan(ack) {
|
||||
// ACK advances snd.UNA and does not exceed what we have sent.
|
||||
r.sndUNA = ack
|
||||
}
|
||||
if r.sndUNA == r.sndNXT {
|
||||
r.running = false // §5.3: all outstanding data acknowledged.
|
||||
} else {
|
||||
// §5.3: new (but not all) data acknowledged — restart the timer.
|
||||
r.running = true
|
||||
r.deadline = now + int64(r.CurrentRTO())
|
||||
}
|
||||
return RxDirective{Keep: true}
|
||||
}
|
||||
|
||||
// PreTx reports whether the retransmission timer has expired and, if so, applies
|
||||
// the RFC 6298 §5.4–§5.6 timeout response — discard the outstanding RTT sample
|
||||
// (Karn), back the RTO off exponentially and restart the timer — returning a
|
||||
// directive that asks the connection to retransmit from snd.UNA (go-back-N). It
|
||||
// implements [LossRecovery].
|
||||
func (r *RTO) PreTx(now int64) TxDirective {
|
||||
if !r.running || now < r.deadline || r.sndUNA == r.sndNXT {
|
||||
return TxDirective{}
|
||||
}
|
||||
r.timing = false // §5.4: do not sample a retransmitted segment.
|
||||
if r.backoff < backoffMax {
|
||||
r.backoff++
|
||||
r.rto = min(r.CurrentRTO()*2, rtoMax) // §5.5: RTO = RTO * 2.
|
||||
}
|
||||
r.running = true
|
||||
r.deadline = now + int64(r.CurrentRTO())
|
||||
return TxDirective{RetransmitAll: true}
|
||||
}
|
||||
|
||||
// PostTx records an emitted segment: it advances the shadow send sequence,
|
||||
// begins timing newly transmitted data (RFC 6298 §3) and arms the timer (§5.1).
|
||||
// Segments that do not extend the send sequence are retransmissions and are
|
||||
// never RTT-sampled (Karn's algorithm). Control-only segments (no data) are
|
||||
// ignored. It implements [LossRecovery].
|
||||
func (r *RTO) PostTx(outgoing Segment, now int64) {
|
||||
if outgoing.DATALEN == 0 {
|
||||
return // only data segments are timed / arm the RTO.
|
||||
}
|
||||
segStart := outgoing.SEQ
|
||||
segEnd := segStart + Value(outgoing.LEN())
|
||||
if !r.haveSeq {
|
||||
r.haveSeq = true
|
||||
r.sndUNA = segStart
|
||||
r.sndNXT = segStart
|
||||
}
|
||||
if !r.sndNXT.LessThan(segEnd) {
|
||||
// Segment does not extend the send sequence: it is a retransmission.
|
||||
// Discard any outstanding RTT sample per Karn's algorithm. The timer was
|
||||
// already (re)armed by PreTx on the timeout that triggered this resend.
|
||||
r.timing = false
|
||||
return
|
||||
}
|
||||
r.sndNXT = segEnd
|
||||
if !r.timing {
|
||||
r.timing = true
|
||||
r.timedSeq = segEnd
|
||||
r.timedAt = now
|
||||
}
|
||||
if !r.running {
|
||||
r.running = true
|
||||
r.deadline = now + int64(r.CurrentRTO())
|
||||
}
|
||||
}
|
||||
|
||||
// updateRTT folds a round-trip measurement into SRTT/RTTVAR/RTO using the
|
||||
// integer-shift form of RFC 6298 §2.2/§2.3.
|
||||
func (r *RTO) updateRTT(sample time.Duration) {
|
||||
if sample <= 0 {
|
||||
return
|
||||
}
|
||||
if !r.haveRTT {
|
||||
// First measurement (RFC 6298 §2.2).
|
||||
r.srtt = sample
|
||||
r.rttvar = sample / 2
|
||||
r.haveRTT = true
|
||||
} else {
|
||||
// Subsequent measurements (RFC 6298 §2.3):
|
||||
// RTTVAR = (1-beta)*RTTVAR + beta*|SRTT-R|
|
||||
// SRTT = (1-alpha)*SRTT + alpha*R
|
||||
diff := r.srtt - sample
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
r.rttvar += (diff - r.rttvar) >> rttvarGainShift
|
||||
r.srtt += (sample - r.srtt) >> rttGainShift
|
||||
}
|
||||
r.rto = r.srtt + rttvarK*r.rttvar
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
package tcp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const rtoMs = int64(time.Millisecond)
|
||||
|
||||
// rtoDataSeg builds a data segment of datalen octets starting at seq.
|
||||
func rtoDataSeg(seq uint32, datalen int) Segment {
|
||||
return Segment{SEQ: Value(seq), DATALEN: Size(datalen), Flags: FlagPSH | FlagACK}
|
||||
}
|
||||
|
||||
// rtoAckSeg builds a bare ACK acknowledging up to ack.
|
||||
func rtoAckSeg(ack uint32) Segment {
|
||||
return Segment{ACK: Value(ack), Flags: FlagACK}
|
||||
}
|
||||
|
||||
func newRTO() *RTO {
|
||||
var r RTO
|
||||
r.Reset()
|
||||
return &r
|
||||
}
|
||||
|
||||
func TestRTO_Reset(t *testing.T) {
|
||||
var r RTO
|
||||
r.Reset()
|
||||
if r.rto != rtoInitial {
|
||||
t.Errorf("initial rto=%v, want %v", r.rto, rtoInitial)
|
||||
}
|
||||
if r.CurrentRTO() != rtoInitial {
|
||||
t.Errorf("CurrentRTO=%v, want %v", r.CurrentRTO(), rtoInitial)
|
||||
}
|
||||
if r.haveRTT {
|
||||
t.Error("haveRTT should be false before first sample")
|
||||
}
|
||||
if r.Running() || r.NextDeadline() != 0 {
|
||||
t.Error("timer must be disarmed after Reset")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTO_ArmOnSendSampleOnAck sends data, verifies the timer arms, then acks it
|
||||
// and verifies an RTT sample is taken and the timer stops once all data is acked.
|
||||
func TestRTO_ArmOnSendSampleOnAck(t *testing.T) {
|
||||
r := newRTO()
|
||||
const iss = uint32(1000)
|
||||
|
||||
r.PostTx(rtoDataSeg(iss, 100), 0)
|
||||
if !r.Running() {
|
||||
t.Fatal("timer must arm after sending data")
|
||||
}
|
||||
if r.NextDeadline() != int64(rtoInitial) {
|
||||
t.Errorf("deadline=%d, want %d", r.NextDeadline(), int64(rtoInitial))
|
||||
}
|
||||
|
||||
// ACK arrives one RTT (40ms) later covering all sent data.
|
||||
dir := r.PreRx(rtoAckSeg(iss+100), 40*rtoMs)
|
||||
if !dir.Keep {
|
||||
t.Error("PreRx must keep the segment")
|
||||
}
|
||||
if r.Running() {
|
||||
t.Error("timer must stop once all data is acknowledged")
|
||||
}
|
||||
if r.SmoothedRTT() != 40*time.Millisecond {
|
||||
t.Errorf("srtt=%v, want 40ms", r.SmoothedRTT())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTO_RetransmitOnTimeout verifies PreTx directs a go-back-N retransmit once
|
||||
// the deadline passes with data outstanding, and backs the RTO off.
|
||||
func TestRTO_RetransmitOnTimeout(t *testing.T) {
|
||||
r := newRTO()
|
||||
const iss = uint32(1000)
|
||||
r.PostTx(rtoDataSeg(iss, 100), 0)
|
||||
|
||||
if r.PreTx(int64(rtoInitial) - 1).RetransmitAll {
|
||||
t.Fatal("must not retransmit before the deadline")
|
||||
}
|
||||
dir := r.PreTx(int64(rtoInitial))
|
||||
if !dir.RetransmitAll {
|
||||
t.Fatal("RTO must fire at the deadline with data outstanding")
|
||||
}
|
||||
if r.CurrentRTO() != 2*rtoInitial {
|
||||
t.Errorf("rto=%v after one backoff, want %v", r.CurrentRTO(), 2*rtoInitial)
|
||||
}
|
||||
// The connection resends from snd.UNA; PostTx sees a retransmission.
|
||||
r.PostTx(rtoDataSeg(iss, 100), int64(rtoInitial))
|
||||
if r.timing {
|
||||
t.Error("retransmitted segment must not be RTT-sampled (Karn)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTO_KarnNoSampleOnRetransmittedAck verifies that after a retransmission the
|
||||
// ACK does not produce an RTT sample (Karn's algorithm).
|
||||
func TestRTO_KarnNoSampleOnRetransmittedAck(t *testing.T) {
|
||||
r := newRTO()
|
||||
const iss = uint32(1000)
|
||||
r.PostTx(rtoDataSeg(iss, 100), 0)
|
||||
// Timeout and retransmit.
|
||||
r.PreTx(int64(rtoInitial))
|
||||
r.PostTx(rtoDataSeg(iss, 100), int64(rtoInitial))
|
||||
// ACK now arrives; no sample should be taken since timing was discarded.
|
||||
r.PreRx(rtoAckSeg(iss+100), int64(rtoInitial)+10*rtoMs)
|
||||
if r.haveRTT {
|
||||
t.Error("no RTT sample should exist after a retransmission (Karn)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTO_TimerRestartsWhilePartiallyAcked verifies the timer restarts (not
|
||||
// stops) when an ACK advances UNA but data remains in flight (RFC 6298 §5.3).
|
||||
func TestRTO_TimerRestartsWhilePartiallyAcked(t *testing.T) {
|
||||
r := newRTO()
|
||||
const iss = uint32(1000)
|
||||
r.PostTx(rtoDataSeg(iss, 100), 0)
|
||||
r.PostTx(rtoDataSeg(iss+100, 100), 0) // 200 octets outstanding, iss..iss+200.
|
||||
|
||||
dir := r.PreRx(rtoAckSeg(iss+100), 40*rtoMs) // acks first 100 only.
|
||||
if !r.Running() {
|
||||
t.Fatal("timer must remain armed while data is still in flight")
|
||||
}
|
||||
if r.NextDeadline() != 40*rtoMs+int64(r.CurrentRTO()) {
|
||||
t.Errorf("deadline=%d, want %d", r.NextDeadline(), 40*rtoMs+int64(r.CurrentRTO()))
|
||||
}
|
||||
if !dir.Keep {
|
||||
t.Error("PreRx must keep the segment")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTO_NoArmWithoutData verifies control-only segments neither arm the timer
|
||||
// nor start an RTT sample.
|
||||
func TestRTO_NoArmWithoutData(t *testing.T) {
|
||||
r := newRTO()
|
||||
r.PostTx(Segment{SEQ: 1000, Flags: FlagACK}, 0) // pure ACK, DATALEN==0.
|
||||
if r.Running() || r.timing {
|
||||
t.Error("pure control segment must not arm the timer or start a sample")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTO_BackoffCollapsesOnValidSample verifies a valid RTT measurement
|
||||
// collapses the exponential backoff counter (RFC 6298 §5.7).
|
||||
func TestRTO_BackoffCollapsesOnValidSample(t *testing.T) {
|
||||
r := newRTO()
|
||||
const iss = uint32(1000)
|
||||
r.PostTx(rtoDataSeg(iss, 100), 0)
|
||||
r.PreTx(int64(rtoInitial)) // one timeout: backoff=1.
|
||||
r.PostTx(rtoDataSeg(iss, 100), int64(rtoInitial)) // retransmit (no sample).
|
||||
if r.backoff != 1 {
|
||||
t.Fatalf("backoff=%d, want 1 after a timeout", r.backoff)
|
||||
}
|
||||
// New data sent and freshly sampled, then acked.
|
||||
r.PostTx(rtoDataSeg(iss+100, 100), int64(rtoInitial)+rtoMs)
|
||||
r.PreRx(rtoAckSeg(iss+200), int64(rtoInitial)+30*rtoMs)
|
||||
if r.backoff != 0 {
|
||||
t.Errorf("backoff=%d, want 0 after a valid RTT sample", r.backoff)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTO_Clamped verifies CurrentRTO is clamped to [rtoMin, rtoMax].
|
||||
func TestRTO_Clamped(t *testing.T) {
|
||||
var r RTO
|
||||
r.Reset()
|
||||
r.rto = time.Nanosecond
|
||||
if got := r.CurrentRTO(); got != rtoMin {
|
||||
t.Errorf("CurrentRTO=%v, want floor %v", got, rtoMin)
|
||||
}
|
||||
r.rto = time.Hour
|
||||
if got := r.CurrentRTO(); got != rtoMax {
|
||||
t.Errorf("CurrentRTO=%v, want ceiling %v", got, rtoMax)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTO_UpdateRTTFirstSample verifies the first-measurement initialization of
|
||||
// SRTT/RTTVAR (RFC 6298 §2.2).
|
||||
func TestRTO_UpdateRTTFirstSample(t *testing.T) {
|
||||
var r RTO
|
||||
r.Reset()
|
||||
r.updateRTT(100 * time.Millisecond)
|
||||
if r.srtt != 100*time.Millisecond {
|
||||
t.Errorf("srtt=%v, want 100ms", r.srtt)
|
||||
}
|
||||
if r.rttvar != 50*time.Millisecond {
|
||||
t.Errorf("rttvar=%v, want 50ms", r.rttvar)
|
||||
}
|
||||
// RTO = SRTT + K*RTTVAR = 100 + 4*50 = 300ms.
|
||||
if r.rto != 300*time.Millisecond {
|
||||
t.Errorf("rto=%v, want 300ms", r.rto)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTO_ImplementsLossRecovery exercises RTO through the [LossRecovery]
|
||||
// interface: sending data arms a deadline and a full ACK disarms it.
|
||||
func TestRTO_ImplementsLossRecovery(t *testing.T) {
|
||||
var lr LossRecovery = newRTO()
|
||||
lr.Reset()
|
||||
lr.PostTx(rtoDataSeg(1000, 100), 0)
|
||||
if lr.NextDeadline() == 0 {
|
||||
t.Error("expected an armed deadline after sending data")
|
||||
}
|
||||
if !lr.PreRx(rtoAckSeg(1100), 10*rtoMs).Keep {
|
||||
t.Error("PreRx must keep")
|
||||
}
|
||||
if lr.NextDeadline() != 0 {
|
||||
t.Error("expected disarmed timer after full ack")
|
||||
}
|
||||
}
|
||||
@@ -227,7 +227,7 @@ func BenchmarkSYNCookie_Generate(b *testing.B) {
|
||||
clientISN := Value(0x12345678)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
for b.Loop() {
|
||||
sc.MakeSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN)
|
||||
}
|
||||
}
|
||||
@@ -247,7 +247,7 @@ func BenchmarkSYNCookie_Validate(b *testing.B) {
|
||||
ackNum := cookie + 1
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
for b.Loop() {
|
||||
sc.ValidateSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN, ackNum)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package netdev
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/soypat/lneto/internal"
|
||||
)
|
||||
|
||||
// TODO(soypat): True Zero Copy (TZC)
|
||||
// TODO(soypat): TZC acheived on redesigning [DevEthernet] to not own any buffers and ask the networking stack for buffers in the callback path. TZC already acheived for Polling path.
|
||||
// TODO(soypat): TZC in callback path requires redesign of bufferSelect and [Runner] likely.
|
||||
|
||||
// lenClaimed marks a slot claimed by putRx before its frame copy completes.
|
||||
// The slot is published by storing the frame length, which must be the
|
||||
// claimant's last write so getRx never observes a partially copied frame.
|
||||
const lenClaimed = -1
|
||||
|
||||
// bufferSelect is a fixed pool of frame buffers shared between the runner
|
||||
// goroutine and the device's receive handler goroutine. Slot ownership is
|
||||
// arbitrated exclusively through CAS on lenAcquire:
|
||||
// - 0: slot free.
|
||||
// - lenClaimed(<0): slot claimed by putRx, frame copy in progress.
|
||||
// - n>0: slot owned; if isRx is set the slot holds a published Rx frame.
|
||||
//
|
||||
// Only goroPutRx may be called concurrently with the other methods; all other
|
||||
// methods must be called from a single goroutine (the runner's).
|
||||
type bufferSelect struct {
|
||||
// nextSeq generates arrival-order sequence numbers for Rx frames so getRx
|
||||
// yields frames in the order they were received, not in slot order.
|
||||
nextSeq atomic.Uint32
|
||||
missedAcquire atomic.Uint32
|
||||
bufs []struct {
|
||||
lenAcquire atomic.Int32
|
||||
isRx atomic.Bool
|
||||
seq uint32
|
||||
buf []byte
|
||||
}
|
||||
}
|
||||
|
||||
func (bs *bufferSelect) reset(bufs [][]byte) {
|
||||
internal.SliceReuse(&bs.bufs, len(bufs))
|
||||
bs.bufs = bs.bufs[:len(bufs)]
|
||||
for i := range bs.bufs {
|
||||
bs.bufs[i].buf = bufs[i]
|
||||
}
|
||||
bs.releaseAll()
|
||||
}
|
||||
|
||||
func (bs *bufferSelect) releaseAll() {
|
||||
bs.nextSeq.Store(0)
|
||||
bs.missedAcquire.Store(0)
|
||||
for i := range bs.bufs {
|
||||
bs.bufs[i].isRx.Store(false)
|
||||
bs.bufs[i].lenAcquire.Store(0)
|
||||
}
|
||||
}
|
||||
|
||||
// acquire claims a free slot for exclusive use by the caller and returns it
|
||||
// sized to len. Returns nil if no slot is free or len exceeds slot size.
|
||||
func (bs *bufferSelect) acquire(len int) []byte {
|
||||
if len == 0 {
|
||||
bs.missedAcquire.Add(1)
|
||||
return nil
|
||||
}
|
||||
for i := range bs.bufs {
|
||||
if len > cap(bs.bufs[i].buf) {
|
||||
break // Length too long, would need allocation.
|
||||
}
|
||||
if bs.bufs[i].lenAcquire.CompareAndSwap(0, int32(len)) {
|
||||
return bs.bufs[i].buf[:len]
|
||||
}
|
||||
}
|
||||
bs.missedAcquire.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
// goroPutRx copies an incoming frame into a free slot and publishes it for getRx.
|
||||
// It is the only method safe to call concurrently with the runner goroutine.
|
||||
// Returns false if the frame is empty, oversize or no slot is free.
|
||||
func (bs *bufferSelect) goroPutRx(frame []byte) bool {
|
||||
n := len(frame)
|
||||
if n == 0 {
|
||||
return false
|
||||
}
|
||||
for i := range bs.bufs {
|
||||
if n > cap(bs.bufs[i].buf) {
|
||||
return false
|
||||
}
|
||||
if bs.bufs[i].lenAcquire.CompareAndSwap(0, lenClaimed) {
|
||||
bs.bufs[i].isRx.Store(true)
|
||||
bs.bufs[i].seq = bs.nextSeq.Add(1)
|
||||
copy(bs.bufs[i].buf, frame)
|
||||
bs.bufs[i].lenAcquire.Store(int32(n)) // publish: must be last write.
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (bs *bufferSelect) numFree() (numFree int) {
|
||||
for i := range bs.bufs {
|
||||
if bs.bufs[i].lenAcquire.Load() == 0 {
|
||||
numFree++
|
||||
}
|
||||
}
|
||||
return numFree
|
||||
}
|
||||
|
||||
// getRx returns the oldest published Rx frame, or nil if none is pending.
|
||||
func (bs *bufferSelect) getRx() []byte {
|
||||
oldest := -1
|
||||
var oldestSeq uint32
|
||||
for i := range bs.bufs {
|
||||
n := bs.bufs[i].lenAcquire.Load()
|
||||
if n > 0 && bs.bufs[i].isRx.Load() &&
|
||||
(oldest < 0 || lessThan(bs.bufs[i].seq, oldestSeq)) {
|
||||
oldest = i
|
||||
oldestSeq = bs.bufs[i].seq
|
||||
}
|
||||
}
|
||||
if oldest < 0 {
|
||||
return nil
|
||||
}
|
||||
return bs.bufs[oldest].buf[:bs.bufs[oldest].lenAcquire.Load()]
|
||||
}
|
||||
|
||||
func (bs *bufferSelect) release(buf []byte) {
|
||||
ptr := &buf[0]
|
||||
for i := range bs.bufs {
|
||||
if &bs.bufs[i].buf[0] == ptr {
|
||||
// Clear isRx while still owning the slot so the next claimant
|
||||
// never inherits a stale Rx mark.
|
||||
bs.bufs[i].isRx.Store(false)
|
||||
len := bs.bufs[i].lenAcquire.Load()
|
||||
if len > 0 && bs.bufs[i].lenAcquire.CompareAndSwap(len, 0) {
|
||||
return
|
||||
}
|
||||
panic("bs:race to release")
|
||||
}
|
||||
}
|
||||
panic("bs:buffer not exist or bad offset")
|
||||
}
|
||||
|
||||
func lessThan(aIsLessThan, b uint32) bool {
|
||||
return int32(aIsLessThan-b) < 0
|
||||
}
|
||||
+38
-19
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"unsafe"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
"github.com/soypat/lneto/ethernet"
|
||||
@@ -30,42 +31,48 @@ type DevEthernet interface {
|
||||
// HardwareAddr6 returns the device's 6-byte MAC address.
|
||||
// For PHY-only devices, returns the MAC provided at configuration.
|
||||
HardwareAddr6() ([6]byte, error)
|
||||
// SendEthFrameOffset transmits a complete Ethernet frame at offset given by [DevEthernet.MaxFrameSizeAndOffset].
|
||||
// SendOffsetEthFrame transmits a complete Ethernet frame at offset given by [DevEthernet.MaxFrameSizeAndOffset].
|
||||
// The frame includes the Ethernet header but NOT the FCS/CRC
|
||||
// trailer (device or stack handles CRC as appropriate).
|
||||
// SendEthFrameOffset blocks until the transmission is queued succesfully
|
||||
// SendOffsetEthFrame blocks until the transmission is queued succesfully
|
||||
// or finished sending. Should not be called concurrently
|
||||
// unless user is sure the driver supports it.
|
||||
SendOffsetEthFrame(offsetTxEthFrame []byte) error
|
||||
// SetRecvHandler registers the function called when an Ethernet
|
||||
// SetEthRecvHandler registers the function called when an Ethernet
|
||||
// frame is received. Buffers needed by the device to operate efficiently
|
||||
// should be allocated on its side. This function is mutually exclusive with EthPoll:
|
||||
// use on or the other to receive data.
|
||||
// should be allocated on its side.
|
||||
//
|
||||
// Frames may be delivered via this handler, via EthPoll's buffer, or both:
|
||||
// - Handler unset: EthPoll writes received frames into its argument buffer.
|
||||
// - Handler set: received frames are delivered to the handler. EthPoll must
|
||||
// not write to its argument buffer; it is called with a nil buffer purely
|
||||
// to pump devices that need explicit servicing to drive the handler.
|
||||
//
|
||||
// Quiescence guarantee: SetEthRecvHandler(nil) must not return while a
|
||||
// previously installed handler is executing on another goroutine, and after
|
||||
// it returns the old handler must not be invoked again (analogous to Linux
|
||||
// synchronize_irq semantics). Callers rely on this to safely reuse the
|
||||
// buffers a handler writes into.
|
||||
SetEthRecvHandler(handler func(rxEthframe []byte))
|
||||
// EthPoll services the device. For poll-based devices (e.g. CYW43439
|
||||
// over SPI), reads from the bus and invokes the handler for each
|
||||
// received frame. This method is mutually exclusive with SetEthRecvHandler:
|
||||
// use one or the other to receive data but not return data via both channels.
|
||||
// received frame.
|
||||
//
|
||||
// Behavior depends on whether a handler is set via SetEthRecvHandler:
|
||||
// - No handler: writes a received frame into buf, returning its offset/length.
|
||||
// - Handler set: buf is nil and must not be written to; EthPoll only pumps
|
||||
// the device so frames are delivered through the handler. Return values are ignored.
|
||||
EthPoll(buf []byte) (ethFrameOff, ethernetBytes int, err error)
|
||||
// MaxFrameSizeAndOffset returns the max complete device frame size
|
||||
// (including headers and any overhead) for buffer allocation.
|
||||
// (including headers and any overhead) for Ethernet Rx buffer allocation.
|
||||
// The second value returned is the offset at which the ethernet frame
|
||||
// should be stored when being passed to [DevEthernet.SendOffsetEthFrame].
|
||||
// Buffers allocated should be maxEthernetFrameSize+frameOff where maxEthernetFrameSize
|
||||
// is usually 1500 but less or equal to maxFrameSize-frameOff.
|
||||
// MTU can be calculated doing:
|
||||
// // mfu-(14+4+4) for:
|
||||
// // ethernet header+ethernet CRC if present+ethernet VLAN overhead for VLAN support.
|
||||
// mtu := dev.MaxFrameSizeAndOffset() - ethernet.MaxOverheadSize
|
||||
MaxFrameSizeAndOffset() (maxFrameSize int, frameOff int)
|
||||
// Buffers allocated for Rx should be maxFrameSize.
|
||||
MaxFrameSizeAndOffset() (maxFrameSize int, sendEthFrameOff int)
|
||||
}
|
||||
|
||||
// Stack is an abstraction for a networking stack.
|
||||
type Stack interface {
|
||||
// Configure configures this Stack with the argument mac, ip and gateway addresses.
|
||||
// The Stack must resolve the gateway hardware address if set.
|
||||
// Configure(mac net.HardwareAddr, ip netip.Prefix, gw netip.Addr) error
|
||||
|
||||
// EnableICMP enables responding/sending ICMP echo frames.
|
||||
EnableICMP(enabled bool) error
|
||||
// EnableDHCP enables DHCP on the device if enabled=true and performs a DHCP request.
|
||||
@@ -116,6 +123,18 @@ type InterfaceConfig struct {
|
||||
MTU uint16
|
||||
}
|
||||
|
||||
// RunnerBuffers returns 32-bit aligned contiguous buffers for using with [RunnerConfig].
|
||||
func (iface *Interface[C]) RunnerBuffers(n int) [][]byte {
|
||||
frmlen32 := (iface.frameSize + 3) / 4
|
||||
rawBuf32 := make([]uint32, n*frmlen32) // ensure memory aligned
|
||||
bufs := make([][]byte, n)
|
||||
for i := range n {
|
||||
buf32 := rawBuf32[i*frmlen32 : (i+1)*frmlen32]
|
||||
bufs[i] = unsafe.Slice((*byte)(unsafe.Pointer(&buf32[0])), iface.frameSize)
|
||||
}
|
||||
return bufs
|
||||
}
|
||||
|
||||
// Init initializes the interface from scratch with a netlink and device. If Init fails all methods on Interface are unsafe to call (panic).
|
||||
func (iface *Interface[C]) Init(netlink Netlink[C], dev DevEthernet, cfg InterfaceConfig) (err error) {
|
||||
if netlink == nil || dev == nil {
|
||||
|
||||
+415
-80
@@ -3,91 +3,328 @@ package netdev
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
)
|
||||
|
||||
// Runner orchestrates an Interface and a Stack asynchronously.
|
||||
type Runner[C any] struct {
|
||||
running atomic.Uint32
|
||||
// buflen stores the length of data inside buf. It is used as a buffer acquisition synchronizing primitive.
|
||||
buflen atomic.Uint32
|
||||
// pktlost is incremented each time an incoming packet is lost due to insufficient buffer size.
|
||||
pktlost atomic.Uint64
|
||||
tx, rx atomic.Uint64
|
||||
// buf stores actual data.
|
||||
buf []byte
|
||||
// bufsaux is used as ana argument to stack processing so that no allocations are performed
|
||||
bufsaux [1][]byte
|
||||
sizesaux [1]int
|
||||
handlerTriggered bool
|
||||
deviceIsPollOnly bool
|
||||
var (
|
||||
errRunnerAcquired = errors.New("runner currently running")
|
||||
errNotDriven = errors.New("runner needs to be poll/async driven")
|
||||
errWakeNeedsAsync = errors.New("runner wake needs to be async driven")
|
||||
errNoBackoffNeedsWake = errors.New("wake mechanic not set for omitting backoff")
|
||||
errAsyncHandlingWithRun = errors.New("incompatible use of EnableAsyncHandling with Run- use with RunOnce")
|
||||
errEgressInvalidWrite = errors.New("EgressPackets returned invalid written data given frameOffset and argument buffer size")
|
||||
)
|
||||
|
||||
// RunnerFlags selects how a [Runner] drives its [Interface]. See [RunnerConfig.Flags].
|
||||
type RunnerFlags uint32
|
||||
|
||||
const (
|
||||
// Signals Interface needs to be driven via [DevEthernet.EthPoll].
|
||||
// [RunnerAsync] can be set too to signal packets received via callback instead of written to EthPoll buffer.
|
||||
RunnerInterfacePoll RunnerFlags = 1 << iota
|
||||
// Interface data channel driven exclusively by callback passed to [DevEthernet.SetEthRecvHandler].
|
||||
// If set [DevEthernet.EthPoll] must not write data to argument buffer.
|
||||
RunnerInterfaceAsync
|
||||
// Runner backs off but also wakes up on receiving data asynchronously.
|
||||
// Needs [RunnerInterfaceAsync] to be set to be effective.
|
||||
RunnerAsyncWakeOnRx
|
||||
// Runner will not use the backoff to queue timer wakeups. When setting this option
|
||||
// the user is responsible for waking up the stack so that it can transmit even when not receiving data.
|
||||
// Needs [RunnerAsyncWakeOnRx] to be set to be effective.
|
||||
RunnerNoBackoff
|
||||
)
|
||||
|
||||
// HasAll reports whether every bit in query is set.
|
||||
func (rf RunnerFlags) HasAll(query RunnerFlags) bool { return rf&query == query }
|
||||
|
||||
// HasAny reports whether at least one bit in query is set.
|
||||
func (rf RunnerFlags) HasAny(query RunnerFlags) bool { return rf&query != 0 }
|
||||
|
||||
// Validate reports whether the flag combination is a usable [Runner] configuration.
|
||||
func (rf RunnerFlags) Validate() error {
|
||||
driven := rf.HasAny(RunnerInterfaceAsync | RunnerInterfacePoll)
|
||||
wake := rf.HasAny(RunnerAsyncWakeOnRx)
|
||||
if !driven {
|
||||
return errNotDriven
|
||||
} else if wake && !rf.HasAll(RunnerInterfaceAsync) {
|
||||
return errWakeNeedsAsync
|
||||
} else if rf.HasAny(RunnerNoBackoff) && !wake {
|
||||
return errNoBackoffNeedsWake
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner[C]) Run(ctx context.Context, iface Interface[C], stack Stack, backoff lneto.BackoffStrategy) error {
|
||||
if stack == nil || backoff == nil {
|
||||
return errors.New("nil arguments to Run")
|
||||
// Runner orchestrates an Interface and a Stack asynchronously.
|
||||
// The complexity of Runner stems from it needing to handle three types of devices, see [RunnerConfig.Flags] documentation.
|
||||
type Runner[C any] struct {
|
||||
running atomic.Uint32
|
||||
bufs bufferSelect
|
||||
backoff lneto.BackoffStrategy
|
||||
reconnect *C
|
||||
// pktlost is incremented each time an incoming packet is lost due to insufficient buffer size.
|
||||
// Does not include packets dropped by the stack ([lneto.ErrPacketDrop]); the stack counts those itself.
|
||||
pktlost atomic.Uint64
|
||||
// rx includes ALL data received, even dropped data. xnet.StackAsync keeps track of actual processed data.
|
||||
rx atomic.Uint64
|
||||
// rxStackErrs/rxPollErrs/txStackErrs/txSendErrs count receive/transmit path
|
||||
// errors which are intentionally not propagated out of the run loop nor
|
||||
// printed in the datapath. See [RunnerStatistics] for per-counter semantics.
|
||||
rxStackErrs, rxPollErrs atomic.Uint64
|
||||
txStackErrs, txSendErrs atomic.Uint64
|
||||
// bufsaux is used as an argument to stack processing so that no allocations are performed
|
||||
bufsaux [1][]byte
|
||||
sizesaux [1]int
|
||||
// flags is atomic since [Runner.Wake] reads it from arbitrary goroutines
|
||||
// concurrently with Configure. Configure stores it last so a visible
|
||||
// RunnerAsyncWakeOnRx bit guarantees a non-nil wake channel.
|
||||
flags atomic.Uint32
|
||||
wake chan struct{}
|
||||
waketimer *time.Timer
|
||||
asyncH *Interface[C]
|
||||
}
|
||||
|
||||
// RunnerConfig configures a [Runner]. Used in [Runner.Configure].
|
||||
type RunnerConfig[C any] struct {
|
||||
// Buffers are the Rx/Tx packet buffers. At least one is required. Use
|
||||
// [Interface.RunnerBuffers] to get correctly sized, aligned buffers.
|
||||
Buffers [][]byte
|
||||
// ReconnectParams is stored for use during link reconnection. Optional.
|
||||
ReconnectParams *C
|
||||
// Backoff is the idle wait strategy between loop iterations. Required.
|
||||
Backoff lneto.BackoffStrategy
|
||||
// Flags must be set to be Async, Poll driven, or both.
|
||||
// This selects the kind of device operation:
|
||||
// - [RunnerInterfacePoll]: Entirely poll driven Rx [DevEthernet] i.e: ESP32 family.
|
||||
// - [RunnerInterfaceAsync]: Entirely async driven Rx (IRQ) [DevEthernet] i.e: LAN8720.
|
||||
// - [RunnerInterfacePoll]|[RunnerInterfaceAsync]: Hybrid Rx [DevEthernet] that are poll driven but can receive data outside EthPoll method. i.e: CYW43439.
|
||||
Flags RunnerFlags
|
||||
}
|
||||
|
||||
// RunnerStatistics keeps track of send/receive statistics.
|
||||
// It is an incomplete view that should be combined with the likes of xnet.StackAsync.ReadStatistics.
|
||||
// It also does not have a view into packets dropped by the [DevEthernet] because of insufficient/slow polling.
|
||||
type RunnerStatistics struct {
|
||||
// Rx total bytes received from interface including packets dropped.
|
||||
Rx uint64
|
||||
// RxPacketsDropped incremented by 1 each time there is not enough resources to process an incoming packet.
|
||||
// This does NOT include packets dropped by the Stack with [lneto.ErrPacketDrop].
|
||||
RxPacketsDropped uint64
|
||||
// RxPollErrs increments by 1 each time polling the [Interface] returns an error.
|
||||
// Is irrelevant for callback driven [DevEthernet].
|
||||
RxPollErrs uint64
|
||||
// RxStackErrs increments by 1 each time stack returns a non [lneto.ErrPacketDrop] error.
|
||||
RxStackErrs uint64
|
||||
// TxStackErrs increments by 1 each time stack returns an error generating egress packets.
|
||||
TxStackErrs uint64
|
||||
// TxSendErrs increments by 1 each time sending a frame over [Interface] returns error.
|
||||
TxSendErrs uint64
|
||||
// BufAcquireFail increments by 1 each time a buffer is unable to be acquired for Rx/Tx.
|
||||
BufAcquireFail uint64
|
||||
}
|
||||
|
||||
// ReadStatistics reads statistics of Runner into [RunnerStatistics].
|
||||
func (r *Runner[C]) ReadStatistics(stats *RunnerStatistics) {
|
||||
*stats = RunnerStatistics{
|
||||
Rx: r.rx.Load(),
|
||||
RxPacketsDropped: r.pktlost.Load(),
|
||||
RxPollErrs: r.rxPollErrs.Load(),
|
||||
RxStackErrs: r.rxStackErrs.Load(),
|
||||
TxStackErrs: r.txStackErrs.Load(),
|
||||
TxSendErrs: r.txSendErrs.Load(),
|
||||
BufAcquireFail: uint64(r.bufs.missedAcquire.Load()),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner[C]) getFlags() RunnerFlags { return RunnerFlags(r.flags.Load()) }
|
||||
|
||||
// Wake unblocks a [Runner] sleeping in [RunnerAsyncWakeOnRx] mode so it services the
|
||||
// stack immediately instead of waiting out the backoff. Signals coalesce and never block.
|
||||
// Safe to call from any goroutine.
|
||||
// Returns an error if the Runner is not configured for wake mode.
|
||||
func (r *Runner[C]) Wake() error {
|
||||
if !r.getFlags().HasAny(RunnerAsyncWakeOnRx) {
|
||||
return lneto.ErrInvalidConfig
|
||||
}
|
||||
select {
|
||||
case r.wake <- struct{}{}: // signal waiting runner
|
||||
default: // already pending — coalesce, never block
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Configure validates cfg and applies it to the Runner. Call before [Runner.Run].
|
||||
// Returns an error on invalid flags, missing buffers/backoff, or while the Runner is running.
|
||||
func (r *Runner[C]) Configure(cfg RunnerConfig[C]) error {
|
||||
if err := cfg.Flags.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cfg.Buffers) < 1 {
|
||||
return lneto.ErrInvalidConfig
|
||||
} else if cfg.Backoff == nil {
|
||||
return lneto.ErrMissingHALConfig
|
||||
}
|
||||
if !r.acquire() {
|
||||
return errors.New("runner currently running.")
|
||||
return errRunnerAcquired
|
||||
}
|
||||
defer func() {
|
||||
iface.dev.SetEthRecvHandler(nil)
|
||||
r.release()
|
||||
}()
|
||||
r.rx.Store(0)
|
||||
r.tx.Store(0)
|
||||
r.buflen.Store(0)
|
||||
r.pktlost.Store(0)
|
||||
r.handlerTriggered = false
|
||||
r.deviceIsPollOnly = false
|
||||
defer r.release()
|
||||
r.flags.Store(0) // Disable Wake during reconfiguration.
|
||||
r.teardownAsync()
|
||||
r.bufs.reset(cfg.Buffers)
|
||||
r.backoff = cfg.Backoff
|
||||
r.reconnect = cfg.ReconnectParams
|
||||
if cfg.Flags.HasAny(RunnerAsyncWakeOnRx) && r.wake == nil {
|
||||
r.wake = make(chan struct{}, 1)
|
||||
r.waketimer = time.NewTimer(24 * time.Hour)
|
||||
}
|
||||
r.flags.Store(uint32(cfg.Flags)) // Publish flags last; see field comment.
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunOnce performs a single Rx-then-Tx service cycle and returns the bytes received
|
||||
// and transmitted. It does no backoff, wake wait, or state reset: the caller controls
|
||||
// pacing and must [Runner.Configure] (with a non-nil stack) before the first call.
|
||||
// Returns an error if a [Runner.Run] or another RunOnce is already in progress.
|
||||
//
|
||||
// Unlike [Runner.Run], RunOnce does not install the async receive handler. For a
|
||||
// poll-driven interface ([RunnerInterfacePoll]) it works as-is; for an async interface
|
||||
// ([RunnerInterfaceAsync]) call [Runner.EnableAsyncHandling] once beforehand so delivered
|
||||
// frames are captured.
|
||||
func (r *Runner[C]) RunOnce(iface *Interface[C], stack Stack) (nrx, ntx int, err error) {
|
||||
if stack == nil {
|
||||
return 0, 0, lneto.ErrInvalidConfig
|
||||
}
|
||||
if !r.acquire() {
|
||||
return 0, 0, errRunnerAcquired
|
||||
}
|
||||
defer r.release()
|
||||
flags := r.getFlags()
|
||||
async := flags.HasAny(RunnerInterfaceAsync)
|
||||
poll := flags.HasAny(RunnerInterfacePoll)
|
||||
bufsize := iface.bufsize()
|
||||
if cap(r.buf) < bufsize {
|
||||
r.buf = make([]byte, bufsize)
|
||||
nrx, ntx, err = r.service(iface, stack, bufsize, poll, async)
|
||||
return nrx, ntx, err
|
||||
}
|
||||
|
||||
// EnableAsyncHandling installs the Runner's async receive handler on iface so that
|
||||
// frames delivered via [DevEthernet.SetEthRecvHandler] are captured into the buffer
|
||||
// pool. Use it to drive an async interface with [Runner.RunOnce], which (unlike
|
||||
// [Runner.Run]) does not install the handler itself. [Runner.Run] manages the handler
|
||||
// on its own and does not need this.
|
||||
//
|
||||
// Returns [lneto.ErrUnsupported] if the Runner is not configured async
|
||||
// ([RunnerInterfaceAsync]), or an error if a Run/RunOnce is in progress.
|
||||
func (r *Runner[C]) EnableAsyncHandling(iface *Interface[C]) error {
|
||||
if !r.acquire() {
|
||||
return errRunnerAcquired
|
||||
}
|
||||
r.buf = r.buf[:bufsize]
|
||||
defer r.release()
|
||||
if !r.getFlags().HasAny(RunnerInterfaceAsync) {
|
||||
return lneto.ErrUnsupported
|
||||
}
|
||||
r.asyncH = iface
|
||||
iface.dev.SetEthRecvHandler(r.recvEthHandler)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisableAsyncHandling removes the receive handler installed by [Runner.EnableAsyncHandling],
|
||||
// stopping async frame delivery into the buffer pool. Call before reconfiguring or tearing
|
||||
// down the Runner. No-op if async handling was not enabled.
|
||||
func (r *Runner[C]) DisableAsyncHandling() error {
|
||||
if !r.acquire() {
|
||||
return errRunnerAcquired
|
||||
}
|
||||
defer r.release()
|
||||
r.teardownAsync()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner[C]) teardownAsync() {
|
||||
if r.asyncH != nil {
|
||||
r.asyncH.dev.SetEthRecvHandler(nil)
|
||||
r.asyncH = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Run drives iface and stack until ctx is cancelled, doing one Rx then Tx per iteration
|
||||
// and backing off when idle. Only one Run (and not concurrent with Configure) may execute
|
||||
// at a time. Returns ctx.Err().
|
||||
func (r *Runner[C]) Run(ctx context.Context, iface *Interface[C], stack Stack) error {
|
||||
if stack == nil {
|
||||
return lneto.ErrInvalidConfig
|
||||
}
|
||||
if !r.acquire() {
|
||||
return errRunnerAcquired
|
||||
}
|
||||
defer r.release()
|
||||
if r.asyncH != nil {
|
||||
return errAsyncHandlingWithRun
|
||||
}
|
||||
r.bufs.releaseAll()
|
||||
r.rx.Store(0)
|
||||
r.pktlost.Store(0)
|
||||
r.rxStackErrs.Store(0)
|
||||
r.rxPollErrs.Store(0)
|
||||
r.txStackErrs.Store(0)
|
||||
r.txSendErrs.Store(0)
|
||||
bufsize := iface.bufsize()
|
||||
flags := r.getFlags()
|
||||
async := flags.HasAny(RunnerInterfaceAsync)
|
||||
poll := flags.HasAny(RunnerInterfacePoll)
|
||||
wake := flags.HasAny(RunnerAsyncWakeOnRx)
|
||||
backoffEnabled := !flags.HasAny(RunnerNoBackoff)
|
||||
if wake {
|
||||
r.waketimer.Stop()
|
||||
}
|
||||
backoff := r.backoff
|
||||
if async {
|
||||
iface.dev.SetEthRecvHandler(r.recvEthHandler)
|
||||
// Only tear down the handler Run itself installed. In poll-only mode
|
||||
// any handler on the device is not Run's to clear.
|
||||
defer iface.dev.SetEthRecvHandler(nil)
|
||||
}
|
||||
|
||||
// backoffs stores number of consecutive times no data was sent/received.
|
||||
var backoffs uint
|
||||
for ctx.Err() == nil {
|
||||
n1, _ := r.processRx(stack, 0)
|
||||
eoff, efrm, err := iface.dev.EthPoll(r.buf)
|
||||
n2, _ := r.processRx(stack, 0)
|
||||
if efrm > 0 && n2 == 0 {
|
||||
r.deviceIsPollOnly = true
|
||||
r.buflen.Store(uint32(eoff + efrm))
|
||||
r.processRx(stack, eoff)
|
||||
} else if efrm > 0 && n2 > 0 {
|
||||
return errors.New("device both returns nonzero poll read and calls, choose one")
|
||||
} else if err != nil {
|
||||
println("err EthPoll:", err.Error())
|
||||
}
|
||||
|
||||
// Now do Tx, but first acquire buffer.
|
||||
if !r.buflen.CompareAndSwap(0, 1) {
|
||||
continue // Oh no, async data received, go back to Rx processing.
|
||||
}
|
||||
r.bufsaux = [1][]byte{r.buf}
|
||||
|
||||
err = stack.EgressPackets(r.bufsaux[:], r.sizesaux[:], iface.frameOff)
|
||||
n := r.sizesaux[0]
|
||||
nrx, ntx, err := r.service(iface, stack, bufsize, poll, async)
|
||||
if err != nil {
|
||||
println("err EgressPackets:", err.Error())
|
||||
} else if n > 0 {
|
||||
if n+iface.frameOff > len(r.buf) {
|
||||
return errors.New("EgressPackets returned invalid written data given frameOffset and argument buffer size")
|
||||
}
|
||||
err = iface.dev.SendOffsetEthFrame(r.bufsaux[0][:n+iface.frameOff])
|
||||
r.tx.Add(uint64(n + iface.frameOff))
|
||||
if err != nil {
|
||||
println("err SendOffsetEthFrame:", err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
r.buflen.Store(0) // Release buffer.
|
||||
if n1 > 0 || n2 > 0 || efrm > 0 || n > 0 {
|
||||
if nrx > 0 || ntx > 0 {
|
||||
backoffs = 0
|
||||
} else if wake {
|
||||
if backoffEnabled {
|
||||
d := backoff(backoffs)
|
||||
backoffs++
|
||||
switch d {
|
||||
case lneto.BackoffFlagGosched:
|
||||
runtime.Gosched()
|
||||
fallthrough
|
||||
case lneto.BackoffFlagNop:
|
||||
continue
|
||||
default:
|
||||
d = max(d, 100*time.Microsecond)
|
||||
}
|
||||
// Claude say:
|
||||
// Reset without draining waketimer.C assumes Go 1.23+ timer
|
||||
// semantics (stale expiries do not linger in the channel). On
|
||||
// runtimes with older semantics (e.g. TinyGo) worst case is a
|
||||
// single spurious early wakeup, which is benign here.
|
||||
r.waketimer.Reset(d)
|
||||
}
|
||||
select {
|
||||
case <-r.wake:
|
||||
backoffs = 0 // woke early on data.
|
||||
case <-ctx.Done():
|
||||
case <-r.waketimer.C:
|
||||
}
|
||||
if backoffEnabled {
|
||||
r.waketimer.Stop()
|
||||
}
|
||||
} else {
|
||||
backoff.Do(backoffs)
|
||||
backoffs++
|
||||
@@ -96,20 +333,68 @@ func (r *Runner[C]) Run(ctx context.Context, iface Interface[C], stack Stack, ba
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func (r *Runner[C]) service(iface *Interface[C], stack Stack, bufsize int, poll, async bool) (nrx, ntx int, err error) {
|
||||
nrx, err = r.doRx(iface, stack, poll, async)
|
||||
|
||||
// Now do Tx, but first acquire buffer.
|
||||
txbuf := r.bufs.acquire(bufsize)
|
||||
if txbuf == nil {
|
||||
// We got blocked by Rx. Try draining rx.
|
||||
for range len(r.bufs.bufs) {
|
||||
n, err := r.doRx(iface, stack, poll, async)
|
||||
if err != nil {
|
||||
|
||||
break
|
||||
}
|
||||
nrx += n
|
||||
}
|
||||
txbuf = r.bufs.acquire(bufsize)
|
||||
if txbuf == nil {
|
||||
// Buffers still held by in-flight Rx. Skip Tx this cycle rather
|
||||
// than steal a slot the receive handler may be writing into.
|
||||
return nrx, 0, nil
|
||||
}
|
||||
}
|
||||
r.bufsaux = [1][]byte{txbuf}
|
||||
err = stack.EgressPackets(r.bufsaux[:], r.sizesaux[:], iface.frameOff)
|
||||
ntx = r.sizesaux[0]
|
||||
if err != nil {
|
||||
r.txStackErrs.Add(1)
|
||||
} else if ntx > 0 {
|
||||
if ntx+iface.frameOff > len(txbuf) {
|
||||
r.bufs.release(txbuf)
|
||||
return nrx, 0, errEgressInvalidWrite
|
||||
}
|
||||
err = iface.dev.SendOffsetEthFrame(r.bufsaux[0][:ntx+iface.frameOff])
|
||||
if err != nil {
|
||||
r.txSendErrs.Add(1)
|
||||
}
|
||||
}
|
||||
r.bufs.release(txbuf) // Release buffer.
|
||||
return nrx, ntx, nil
|
||||
}
|
||||
|
||||
// PrintDebug
|
||||
//
|
||||
// Deprecated: Might be given other shape in future, but this is not how we do debugging. use freely meanwhile.
|
||||
func (r *Runner[C]) PrintDebug() {
|
||||
print("RUNNER: tx|rx:", r.tx.Load(), "|", r.rx.Load(),
|
||||
" devpollonly:", r.deviceIsPollOnly, " pktlost:", r.pktlost.Load(),
|
||||
" handles:", r.handlerTriggered, " bufsize:", len(r.buf),
|
||||
flags := r.getFlags()
|
||||
print("RUNNER: rx:", r.rx.Load(),
|
||||
" pktlost:", r.pktlost.Load(),
|
||||
" rxErrs:", r.rxStackErrs.Load(),
|
||||
" txErrs:", r.txSendErrs.Load(),
|
||||
" devPoll:", flags.HasAny(RunnerInterfacePoll),
|
||||
" devAsync:", flags.HasAny(RunnerInterfaceAsync),
|
||||
" devWakeRx:", flags.HasAny(RunnerAsyncWakeOnRx),
|
||||
"\n")
|
||||
}
|
||||
|
||||
// acquire takes the single-use lock, returning false if already held.
|
||||
func (r *Runner[C]) acquire() bool {
|
||||
return r.running.CompareAndSwap(0, 1)
|
||||
}
|
||||
|
||||
// release frees the lock taken by acquire. Panics if not held.
|
||||
func (r *Runner[C]) release() {
|
||||
if r.running.Load()&1 == 0 {
|
||||
panic("release of unacquired resource")
|
||||
@@ -119,23 +404,73 @@ func (r *Runner[C]) release() {
|
||||
|
||||
// recvEthHandler is called asynchronously. Should be as fast as possible. Do not block inside.
|
||||
func (r *Runner[C]) recvEthHandler(incomingEthernet []byte) {
|
||||
if !r.buflen.CompareAndSwap(0, uint32(len(incomingEthernet))) {
|
||||
// Failed to acquire buffer, packet dropped.
|
||||
r.rx.Add(uint64(len(incomingEthernet))) // rx includes dropped data. xnet.StackAsync keeps track of actual received statistics.
|
||||
if !r.bufs.goroPutRx(incomingEthernet) {
|
||||
// No free buffer or frame oversize, packet dropped.
|
||||
r.pktlost.Add(1)
|
||||
return
|
||||
}
|
||||
copy(r.buf, incomingEthernet)
|
||||
r.Wake()
|
||||
}
|
||||
|
||||
// processRx is called after a packet is received asynchronously and compied to buffer via recvEthHandler
|
||||
func (r *Runner[C]) processRx(stack Stack, ethFrameOff int) (int, error) {
|
||||
r.handlerTriggered = true
|
||||
n := r.buflen.Load()
|
||||
if n == 0 {
|
||||
// doRx services one Rx cycle. In poll mode it reads a frame from the device into a buffer
|
||||
// and ingresses it. In async mode it drains frames delivered by recvEthHandler, pumping a
|
||||
// poll-driven device with EthPoll(nil) when buffers are free. Device errors are counted
|
||||
// in rxPollErrs; the returned error is the first stack ingress error encountered.
|
||||
func (r *Runner[C]) doRx(iface *Interface[C], stack Stack, poll, async bool) (n int, gerr error) {
|
||||
if !async { // poll guaranteed to be set as per RunnerFlags.Validate.
|
||||
// Poll-only doRx.
|
||||
buf := r.bufs.acquire(iface.frameSize)
|
||||
if buf == nil {
|
||||
return 0, nil
|
||||
}
|
||||
eoff, efrm, err := iface.dev.EthPoll(buf)
|
||||
if err != nil {
|
||||
r.rxPollErrs.Add(1)
|
||||
}
|
||||
if efrm > 0 {
|
||||
r.bufsaux = [1][]byte{buf[:eoff+efrm]}
|
||||
gerr = stack.IngressPackets(r.bufsaux[:], eoff)
|
||||
if gerr != nil && gerr != lneto.ErrPacketDrop {
|
||||
r.rxStackErrs.Add(1)
|
||||
}
|
||||
}
|
||||
r.bufs.release(buf)
|
||||
r.rx.Add(uint64(efrm))
|
||||
return efrm, gerr
|
||||
}
|
||||
|
||||
// Async branch.
|
||||
n, gerr = r.processAsyncRx(stack)
|
||||
if poll && r.bufs.numFree() > 0 {
|
||||
// Manual polling required by device.
|
||||
// Data not transmitted via this channel as per RunnerFlags documentation.
|
||||
_, _, err := iface.dev.EthPoll(nil)
|
||||
if err != nil {
|
||||
r.rxPollErrs.Add(1)
|
||||
}
|
||||
} else {
|
||||
return n, gerr
|
||||
}
|
||||
n2, err := r.processAsyncRx(stack)
|
||||
if gerr == nil {
|
||||
gerr = err
|
||||
}
|
||||
return n + n2, gerr
|
||||
}
|
||||
|
||||
// processAsyncRx ingresses one frame previously copied to a buffer by recvEthHandler.
|
||||
// Returns the frame length, or 0 if none is pending.
|
||||
func (r *Runner[C]) processAsyncRx(stack Stack) (int, error) {
|
||||
buf := r.bufs.getRx()
|
||||
if buf == nil {
|
||||
return 0, nil
|
||||
}
|
||||
r.rx.Add(uint64(n))
|
||||
defer r.buflen.Store(0)
|
||||
r.bufsaux = [1][]byte{r.buf[:n]}
|
||||
return int(n), stack.IngressPackets(r.bufsaux[:], ethFrameOff)
|
||||
r.bufsaux = [1][]byte{buf}
|
||||
err := stack.IngressPackets(r.bufsaux[:], 0)
|
||||
if err != nil && err != lneto.ErrPacketDrop {
|
||||
r.rxStackErrs.Add(1)
|
||||
}
|
||||
r.bufs.release(buf)
|
||||
return len(buf), err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
package netdev_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
"github.com/soypat/lneto/x/netdev"
|
||||
)
|
||||
|
||||
// mockDev is a DevEthernet test double. The mutex makes SetEthRecvHandler
|
||||
// honor the quiescence guarantee with respect to deliver and EthPoll.
|
||||
type mockDev struct {
|
||||
mu sync.Mutex
|
||||
handler func([]byte)
|
||||
sent [][]byte
|
||||
rxq [][]byte // frames pending delivery via EthPoll (poll mode or pump).
|
||||
pumped int
|
||||
frameSize int
|
||||
frameOff int
|
||||
pollErr error
|
||||
sendErr error
|
||||
}
|
||||
|
||||
func (d *mockDev) HardwareAddr6() ([6]byte, error) {
|
||||
return [6]byte{0xde, 0xad, 0xbe, 0xef, 0, 1}, nil
|
||||
}
|
||||
|
||||
func (d *mockDev) SendOffsetEthFrame(f []byte) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.sendErr != nil {
|
||||
return d.sendErr
|
||||
}
|
||||
d.sent = append(d.sent, append([]byte(nil), f...))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *mockDev) SetEthRecvHandler(h func(rxEthFrame []byte)) {
|
||||
d.mu.Lock()
|
||||
d.handler = h
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
// deliver invokes the installed receive handler as a driver goroutine would.
|
||||
// Returns false if no handler is installed.
|
||||
func (d *mockDev) deliver(frame []byte) bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.handler == nil {
|
||||
return false
|
||||
}
|
||||
d.handler(frame)
|
||||
return true
|
||||
}
|
||||
|
||||
func (d *mockDev) handlerInstalled() bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.handler != nil
|
||||
}
|
||||
|
||||
func (d *mockDev) numSent() int {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return len(d.sent)
|
||||
}
|
||||
|
||||
func (d *mockDev) queueRx(frame []byte) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.rxq = append(d.rxq, append([]byte(nil), frame...))
|
||||
}
|
||||
|
||||
func (d *mockDev) EthPoll(buf []byte) (int, int, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.handler != nil {
|
||||
// Pump mode: frames go through the handler, buf must not be written.
|
||||
if buf != nil {
|
||||
return 0, 0, errors.New("EthPoll got non-nil buf with handler set")
|
||||
}
|
||||
d.pumped++
|
||||
for _, f := range d.rxq {
|
||||
d.handler(f)
|
||||
}
|
||||
d.rxq = nil
|
||||
return 0, 0, d.pollErr
|
||||
}
|
||||
if len(d.rxq) == 0 {
|
||||
return 0, 0, d.pollErr
|
||||
}
|
||||
f := d.rxq[0]
|
||||
d.rxq = d.rxq[1:]
|
||||
n := copy(buf[d.frameOff:], f)
|
||||
return d.frameOff, n, nil
|
||||
}
|
||||
|
||||
func (d *mockDev) MaxFrameSizeAndOffset() (int, int) { return d.frameSize, d.frameOff }
|
||||
|
||||
type mockNetlink struct{}
|
||||
|
||||
func (mockNetlink) LinkConnect(_ struct{}) error { return nil }
|
||||
func (mockNetlink) LinkDisconnect() {}
|
||||
func (mockNetlink) LinkNotify(_ netdev.NotifyCallback[struct{}]) {}
|
||||
|
||||
// mockStack records ingressed frames and emits queued egress frames.
|
||||
type mockStack struct {
|
||||
mu sync.Mutex
|
||||
ingress [][]byte
|
||||
egressq [][]byte
|
||||
ingressErr error
|
||||
egressErr error
|
||||
}
|
||||
|
||||
func (s *mockStack) EnableICMP(bool) error { return nil }
|
||||
|
||||
func (s *mockStack) EnableDHCP(context.Context, bool, netip.Addr) (netip.Addr, netip.Addr, int, error) {
|
||||
return netip.Addr{}, netip.Addr{}, 0, nil
|
||||
}
|
||||
|
||||
func (s *mockStack) Socket(context.Context, string, int, int, netip.AddrPort, netip.AddrPort) (any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *mockStack) EgressPackets(bufs [][]byte, sizes []int, offset int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.egressErr != nil {
|
||||
return s.egressErr
|
||||
}
|
||||
for i := range bufs {
|
||||
sizes[i] = 0
|
||||
if len(s.egressq) == 0 {
|
||||
continue
|
||||
}
|
||||
sizes[i] = copy(bufs[i][offset:], s.egressq[0])
|
||||
s.egressq = s.egressq[1:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mockStack) IngressPackets(bufs [][]byte, offset int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.ingressErr != nil {
|
||||
return s.ingressErr
|
||||
}
|
||||
for _, b := range bufs {
|
||||
s.ingress = append(s.ingress, append([]byte(nil), b[offset:]...))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mockStack) queueEgress(frame []byte) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.egressq = append(s.egressq, append([]byte(nil), frame...))
|
||||
}
|
||||
|
||||
func (s *mockStack) numIngress() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.ingress)
|
||||
}
|
||||
|
||||
func newIface(t *testing.T, dev *mockDev) *netdev.Interface[struct{}] {
|
||||
t.Helper()
|
||||
if dev.frameSize == 0 {
|
||||
dev.frameSize = 1514 + dev.frameOff
|
||||
}
|
||||
var iface netdev.Interface[struct{}]
|
||||
err := iface.Init(mockNetlink{}, dev, netdev.InterfaceConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &iface
|
||||
}
|
||||
|
||||
func newRunner(t *testing.T, iface *netdev.Interface[struct{}], nbufs int, flags netdev.RunnerFlags, backoff lneto.BackoffStrategy) *netdev.Runner[struct{}] {
|
||||
t.Helper()
|
||||
if backoff == nil {
|
||||
backoff = func(uint) time.Duration { return time.Millisecond }
|
||||
}
|
||||
var r netdev.Runner[struct{}]
|
||||
err := r.Configure(netdev.RunnerConfig[struct{}]{
|
||||
Buffers: iface.RunnerBuffers(nbufs),
|
||||
Backoff: backoff,
|
||||
Flags: flags,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &r
|
||||
}
|
||||
|
||||
// testFrame returns a frame whose payload encodes and repeats seq for
|
||||
// integrity checking with checkFrame.
|
||||
func testFrame(seq uint32, size int) []byte {
|
||||
f := make([]byte, size)
|
||||
binary.LittleEndian.PutUint32(f, seq)
|
||||
for i := 4; i < size; i++ {
|
||||
f[i] = byte(seq)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func checkFrame(t *testing.T, f []byte, size int) uint32 {
|
||||
t.Helper()
|
||||
if len(f) != size {
|
||||
t.Fatalf("frame length %d, want %d", len(f), size)
|
||||
}
|
||||
seq := binary.LittleEndian.Uint32(f)
|
||||
for i := 4; i < len(f); i++ {
|
||||
if f[i] != byte(seq) {
|
||||
t.Fatalf("frame seq %d corrupt at byte %d: got %#x want %#x", seq, i, f[i], byte(seq))
|
||||
}
|
||||
}
|
||||
return seq
|
||||
}
|
||||
|
||||
func TestRunnerFlagsValidate(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
flags netdev.RunnerFlags
|
||||
ok bool
|
||||
}{
|
||||
{flags: 0, ok: false},
|
||||
{flags: netdev.RunnerInterfacePoll, ok: true},
|
||||
{flags: netdev.RunnerInterfaceAsync, ok: true},
|
||||
{flags: netdev.RunnerInterfacePoll | netdev.RunnerInterfaceAsync, ok: true},
|
||||
{flags: netdev.RunnerAsyncWakeOnRx, ok: false},
|
||||
{flags: netdev.RunnerInterfacePoll | netdev.RunnerAsyncWakeOnRx, ok: false},
|
||||
{flags: netdev.RunnerInterfaceAsync | netdev.RunnerAsyncWakeOnRx, ok: true},
|
||||
{flags: netdev.RunnerInterfaceAsync | netdev.RunnerNoBackoff, ok: false},
|
||||
{flags: netdev.RunnerInterfaceAsync | netdev.RunnerAsyncWakeOnRx | netdev.RunnerNoBackoff, ok: true},
|
||||
} {
|
||||
err := tc.flags.Validate()
|
||||
if (err == nil) != tc.ok {
|
||||
t.Errorf("flags %#b: got err=%v, want ok=%v", tc.flags, err, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOncePollOnly(t *testing.T) {
|
||||
dev := &mockDev{frameOff: 4}
|
||||
iface := newIface(t, dev)
|
||||
stack := &mockStack{}
|
||||
r := newRunner(t, iface, 2, netdev.RunnerInterfacePoll, nil)
|
||||
|
||||
const fsize = 64
|
||||
dev.queueRx(testFrame(1, fsize))
|
||||
stack.queueEgress(testFrame(2, fsize))
|
||||
|
||||
nrx, ntx, err := r.RunOnce(iface, stack)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nrx != fsize {
|
||||
t.Errorf("nrx=%d, want %d", nrx, fsize)
|
||||
}
|
||||
if ntx != fsize {
|
||||
t.Errorf("ntx=%d, want %d", ntx, fsize)
|
||||
}
|
||||
if stack.numIngress() != 1 {
|
||||
t.Fatalf("ingress=%d, want 1", stack.numIngress())
|
||||
}
|
||||
if got := checkFrame(t, stack.ingress[0], fsize); got != 1 {
|
||||
t.Errorf("ingress seq=%d, want 1", got)
|
||||
}
|
||||
if dev.numSent() != 1 {
|
||||
t.Fatalf("sent=%d, want 1", dev.numSent())
|
||||
}
|
||||
// Sent frame includes the device frame offset prefix.
|
||||
if got := checkFrame(t, dev.sent[0][dev.frameOff:], fsize); got != 2 {
|
||||
t.Errorf("sent seq=%d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunOnceAsyncOrdering checks frames ingress in arrival order, not in
|
||||
// buffer slot order (review: getRx scans slots in index order; reordering
|
||||
// TCP segments triggers dup-ACK/retransmit churn).
|
||||
func TestRunOnceAsyncOrdering(t *testing.T) {
|
||||
dev := &mockDev{}
|
||||
iface := newIface(t, dev)
|
||||
stack := &mockStack{}
|
||||
r := newRunner(t, iface, 4, netdev.RunnerInterfaceAsync, nil)
|
||||
err := r.EnableAsyncHandling(iface)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const fsize = 64
|
||||
for seq := uint32(1); seq <= 3; seq++ {
|
||||
if !dev.deliver(testFrame(seq, fsize)) {
|
||||
t.Fatal("handler not installed")
|
||||
}
|
||||
}
|
||||
for range 3 {
|
||||
_, _, err := r.RunOnce(iface, stack)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if stack.numIngress() != 3 {
|
||||
t.Fatalf("ingress=%d, want 3", stack.numIngress())
|
||||
}
|
||||
for i, f := range stack.ingress {
|
||||
if got := checkFrame(t, f, fsize); got != uint32(i+1) {
|
||||
t.Errorf("ingress[%d] seq=%d, want %d: frames reordered", i, got, i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunOnceAsyncPollPumpSingleBuffer exercises the async+poll pump path with
|
||||
// a single buffer over several cycles. Review (MDr164, buffer.go inline): reset
|
||||
// and release clear lenAcquire but not isRx, so numFree undercounts and the
|
||||
// EthPoll pump is wrongly skipped.
|
||||
func TestRunOnceAsyncPollPumpSingleBuffer(t *testing.T) {
|
||||
dev := &mockDev{}
|
||||
iface := newIface(t, dev)
|
||||
stack := &mockStack{}
|
||||
r := newRunner(t, iface, 1, netdev.RunnerInterfaceAsync|netdev.RunnerInterfacePoll, nil)
|
||||
err := r.EnableAsyncHandling(iface)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const fsize = 64
|
||||
const cycles = 3
|
||||
for seq := uint32(1); seq <= cycles; seq++ {
|
||||
dev.queueRx(testFrame(seq, fsize))
|
||||
_, _, err := r.RunOnce(iface, stack)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if stack.numIngress() != cycles {
|
||||
t.Fatalf("ingress=%d, want %d: EthPoll pump skipped", stack.numIngress(), cycles)
|
||||
}
|
||||
for i, f := range stack.ingress {
|
||||
if got := checkFrame(t, f, fsize); got != uint32(i+1) {
|
||||
t.Errorf("ingress[%d] seq=%d, want %d", i, got, i+1)
|
||||
}
|
||||
}
|
||||
// Deliver a frame that stays pending in the pool, then reconfigure: reset
|
||||
// must fully clear slot state. A stale isRx mark from the abandoned frame
|
||||
// makes numFree undercount and skip the pump.
|
||||
if !dev.deliver(testFrame(cycles+1, fsize)) {
|
||||
t.Fatal("handler not installed")
|
||||
}
|
||||
err = r.Configure(netdev.RunnerConfig[struct{}]{
|
||||
Buffers: iface.RunnerBuffers(1),
|
||||
Backoff: func(uint) time.Duration { return time.Millisecond },
|
||||
Flags: netdev.RunnerInterfaceAsync | netdev.RunnerInterfacePoll,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = r.EnableAsyncHandling(iface)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dev.queueRx(testFrame(cycles+2, fsize))
|
||||
_, _, err = r.RunOnce(iface, stack)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The frame in flight at reconfigure time is legitimately dropped; the
|
||||
// queued frame must still arrive through the pump.
|
||||
if stack.numIngress() != cycles+1 {
|
||||
t.Fatalf("ingress=%d, want %d: pump skipped after reconfigure (stale isRx)", stack.numIngress(), cycles+1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAfterEnableAsyncHandlingFails checks Run rejects a Runner set up via
|
||||
// EnableAsyncHandling WITHOUT tearing down the installed handler. Review issue
|
||||
// 4: the teardown defer is installed before the asyncH check, silently
|
||||
// uninstalling the handler so subsequent RunOnce drops all async frames.
|
||||
func TestRunAfterEnableAsyncHandlingFails(t *testing.T) {
|
||||
dev := &mockDev{}
|
||||
iface := newIface(t, dev)
|
||||
stack := &mockStack{}
|
||||
r := newRunner(t, iface, 2, netdev.RunnerInterfaceAsync, nil)
|
||||
err := r.EnableAsyncHandling(iface)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
err = r.Run(ctx, iface, stack)
|
||||
if err == nil || errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("Run after EnableAsyncHandling: got %v, want immediate config error", err)
|
||||
}
|
||||
// The rejected Run must not tear down the handler EnableAsyncHandling installed.
|
||||
if !dev.handlerInstalled() {
|
||||
t.Fatal("Run teardown removed handler it did not install")
|
||||
}
|
||||
if !dev.deliver(testFrame(1, 64)) {
|
||||
t.Fatal("handler not installed")
|
||||
}
|
||||
nrx, _, err := r.RunOnce(iface, stack)
|
||||
if err != nil || nrx != 64 {
|
||||
t.Fatalf("RunOnce after rejected Run: nrx=%d err=%v", nrx, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOversizeFrameDropped checks a frame larger than the pool buffers is
|
||||
// dropped instead of panicking. Review: acquireNext slices buf[:len] without a
|
||||
// bounds check — a slice-bounds panic inside the receive path.
|
||||
func TestOversizeFrameDropped(t *testing.T) {
|
||||
dev := &mockDev{}
|
||||
iface := newIface(t, dev)
|
||||
stack := &mockStack{}
|
||||
r := newRunner(t, iface, 2, netdev.RunnerInterfaceAsync, nil)
|
||||
err := r.EnableAsyncHandling(iface)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
func() {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
t.Fatalf("receive handler panicked on oversize frame: %v", recovered)
|
||||
}
|
||||
}()
|
||||
dev.deliver(make([]byte, dev.frameSize+1))
|
||||
}()
|
||||
nrx, _, err := r.RunOnce(iface, stack)
|
||||
if err != nil || nrx != 0 || stack.numIngress() != 0 {
|
||||
t.Fatalf("oversize frame ingressed: nrx=%d ingress=%d err=%v", nrx, stack.numIngress(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunBackoffEscalates checks the backoff counter escalates past a
|
||||
// Gosched/Nop opening move. Review issue 5 (MDr164, runner.go inline): continue
|
||||
// skips backoffs++, so a strategy returning Gosched at 0 busy-spins forever.
|
||||
func TestRunBackoffEscalates(t *testing.T) {
|
||||
dev := &mockDev{}
|
||||
iface := newIface(t, dev)
|
||||
stack := &mockStack{}
|
||||
var mu sync.Mutex
|
||||
var recorded []uint
|
||||
backoff := func(consecutive uint) time.Duration {
|
||||
mu.Lock()
|
||||
recorded = append(recorded, consecutive)
|
||||
mu.Unlock()
|
||||
if consecutive == 0 {
|
||||
return lneto.BackoffFlagGosched
|
||||
}
|
||||
return time.Millisecond
|
||||
}
|
||||
r := newRunner(t, iface, 2, netdev.RunnerInterfaceAsync|netdev.RunnerAsyncWakeOnRx, backoff)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
err := r.Run(ctx, iface, stack)
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(recorded) == 0 {
|
||||
t.Fatal("backoff never called")
|
||||
}
|
||||
escalated := false
|
||||
for _, c := range recorded {
|
||||
if c > 0 {
|
||||
escalated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !escalated {
|
||||
t.Fatalf("backoff counter pinned at 0 across %d idle iterations", len(recorded))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAsyncStress hammers the receive handler from a producer goroutine
|
||||
// while Run services the stack under Tx pressure, forcing buffer pool
|
||||
// contention. Run with -race. Review issue 1: forceAcquireTx steals a slot the
|
||||
// receive handler may be concurrently copying into, so partially constructed
|
||||
// frames land on the wire and double release panics the runner. Checks frames
|
||||
// are never corrupted (ingress AND egress) and never reordered.
|
||||
func TestRunAsyncStress(t *testing.T) {
|
||||
dev := &mockDev{}
|
||||
iface := newIface(t, dev)
|
||||
stack := &mockStack{}
|
||||
r := newRunner(t, iface, 3, netdev.RunnerInterfaceAsync|netdev.RunnerAsyncWakeOnRx, nil)
|
||||
|
||||
const fsize = 64
|
||||
const nframes = 2000
|
||||
const egressSeq = 0xffff
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
runDone := make(chan error, 1)
|
||||
go func() { runDone <- r.Run(ctx, iface, stack) }()
|
||||
|
||||
// Egress traffic keeps the Tx path contending with Rx for buffers.
|
||||
for range 200 {
|
||||
stack.queueEgress(testFrame(egressSeq, fsize))
|
||||
}
|
||||
for seq := uint32(1); seq <= nframes; seq++ {
|
||||
for !dev.deliver(testFrame(seq, fsize)) {
|
||||
runtime.Gosched()
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond) // let the runner drain pending frames.
|
||||
cancel()
|
||||
err := <-runDone
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stack.mu.Lock()
|
||||
if len(stack.ingress) == 0 {
|
||||
t.Fatal("no frames ingressed")
|
||||
}
|
||||
prev := uint32(0)
|
||||
for _, f := range stack.ingress {
|
||||
seq := checkFrame(t, f, fsize)
|
||||
if seq <= prev {
|
||||
t.Fatalf("reordered: seq %d after %d", seq, prev)
|
||||
}
|
||||
prev = seq
|
||||
}
|
||||
numIngress := len(stack.ingress)
|
||||
stack.mu.Unlock()
|
||||
dev.mu.Lock()
|
||||
for _, f := range dev.sent {
|
||||
if seq := checkFrame(t, f, fsize); seq != egressSeq {
|
||||
t.Fatalf("egress frame corrupted: seq=%#x", seq)
|
||||
}
|
||||
}
|
||||
numSent := len(dev.sent)
|
||||
dev.mu.Unlock()
|
||||
t.Logf("ingressed %d/%d frames, sent %d", numIngress, nframes, numSent)
|
||||
}
|
||||
|
||||
// TestRunnerStatisticsCounting checks each RunnerStatistics counter increments
|
||||
// per its documented trigger: device poll errors -> RxPollErrs, stack ingress
|
||||
// errors -> RxStackErrs except ErrPacketDrop which -> RxPacketsDropped, stack
|
||||
// egress errors -> TxStackErrs, and device send errors -> TxSendErrs.
|
||||
func TestRunnerStatisticsCounting(t *testing.T) {
|
||||
const fsize = 64
|
||||
errBoom := errors.New("boom")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup func(dev *mockDev, stack *mockStack)
|
||||
want netdev.RunnerStatistics
|
||||
}{
|
||||
{
|
||||
// Successful Rx+Tx must not bump any error counter (regression:
|
||||
// missing nil check counted every successful ingress as RxStackErrs).
|
||||
name: "no errors",
|
||||
setup: func(dev *mockDev, stack *mockStack) {
|
||||
dev.queueRx(testFrame(1, fsize))
|
||||
stack.queueEgress(testFrame(2, fsize))
|
||||
},
|
||||
want: netdev.RunnerStatistics{Rx: fsize},
|
||||
},
|
||||
{
|
||||
name: "poll error",
|
||||
setup: func(dev *mockDev, stack *mockStack) { dev.pollErr = errBoom },
|
||||
want: netdev.RunnerStatistics{RxPollErrs: 1},
|
||||
},
|
||||
{
|
||||
// ErrPacketDrop is counted by the stack itself, not the Runner:
|
||||
// neither RxPacketsDropped nor RxStackErrs may increment.
|
||||
name: "ingress packet drop",
|
||||
setup: func(dev *mockDev, stack *mockStack) {
|
||||
stack.ingressErr = lneto.ErrPacketDrop
|
||||
dev.queueRx(testFrame(1, fsize))
|
||||
},
|
||||
want: netdev.RunnerStatistics{Rx: fsize},
|
||||
},
|
||||
{
|
||||
name: "ingress stack error",
|
||||
setup: func(dev *mockDev, stack *mockStack) {
|
||||
stack.ingressErr = errBoom
|
||||
dev.queueRx(testFrame(1, fsize))
|
||||
},
|
||||
want: netdev.RunnerStatistics{Rx: fsize, RxStackErrs: 1},
|
||||
},
|
||||
{
|
||||
name: "egress stack error",
|
||||
setup: func(dev *mockDev, stack *mockStack) { stack.egressErr = errBoom },
|
||||
want: netdev.RunnerStatistics{TxStackErrs: 1},
|
||||
},
|
||||
{
|
||||
name: "send error",
|
||||
setup: func(dev *mockDev, stack *mockStack) {
|
||||
dev.sendErr = errBoom
|
||||
stack.queueEgress(testFrame(1, fsize))
|
||||
},
|
||||
want: netdev.RunnerStatistics{TxSendErrs: 1},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dev := &mockDev{}
|
||||
iface := newIface(t, dev)
|
||||
stack := &mockStack{}
|
||||
r := newRunner(t, iface, 2, netdev.RunnerInterfacePoll, nil)
|
||||
tc.setup(dev, stack)
|
||||
_, _, err := r.RunOnce(iface, stack)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stats netdev.RunnerStatistics
|
||||
r.ReadStatistics(&stats)
|
||||
if stats != tc.want {
|
||||
t.Errorf("stats=%+v, want %+v", stats, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunResetsStatistics checks Run zeroes all counters accumulated by a
|
||||
// previous session, including RxPollErrs.
|
||||
func TestRunResetsStatistics(t *testing.T) {
|
||||
dev := &mockDev{}
|
||||
iface := newIface(t, dev)
|
||||
stack := &mockStack{}
|
||||
r := newRunner(t, iface, 2, netdev.RunnerInterfacePoll, nil)
|
||||
dev.pollErr = errors.New("boom")
|
||||
if _, _, err := r.RunOnce(iface, stack); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stats netdev.RunnerStatistics
|
||||
r.ReadStatistics(&stats)
|
||||
if stats == (netdev.RunnerStatistics{}) {
|
||||
t.Fatal("expected nonzero statistics before Run")
|
||||
}
|
||||
dev.mu.Lock()
|
||||
dev.pollErr = nil
|
||||
dev.mu.Unlock()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := r.Run(ctx, iface, stack); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.ReadStatistics(&stats)
|
||||
if stats != (netdev.RunnerStatistics{}) {
|
||||
t.Errorf("Run did not reset statistics: %+v", stats)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package tls
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// maxBuilderNest is the deepest length-prefix nesting the [Builder] supports.
|
||||
// The deepest real TLS 1.3 structure is the ClientHello key_share extension:
|
||||
// handshake body, extensions block, extension, client_shares list,
|
||||
// key_exchange. Eight leaves generous headroom.
|
||||
const maxBuilderNest = 8
|
||||
|
||||
// Builder writes TLS wire structures into a caller-supplied buffer that never
|
||||
// grows. It exists because TLS nests length-prefixed vectors several levels
|
||||
// deep and the length of each is only known after its contents are written;
|
||||
// the standard answer, golang.org/x/crypto/cryptobyte, allocates.
|
||||
//
|
||||
// Open a length prefix, write the contents, then Close to backpatch the real
|
||||
// length:
|
||||
//
|
||||
// var b tls.Builder
|
||||
// b.Reset(buf)
|
||||
// b.AddU16(uint16(tls.ExtSupportedVersions))
|
||||
// b.OpenU16() // extension_data length
|
||||
// b.AddU16(tls.VersionTLS13)
|
||||
// b.Close()
|
||||
// out, err := b.Bytes()
|
||||
//
|
||||
// Errors are sticky: once the destination buffer overflows or the nesting is
|
||||
// misused, every later call is a no-op and [Builder.Err] reports the first
|
||||
// failure. Callers therefore need to check only once, at the end.
|
||||
//
|
||||
// The zero Builder is not usable; call [Builder.Reset] first.
|
||||
type Builder struct {
|
||||
buf []byte
|
||||
stack [maxBuilderNest]int32 // offset of each open length field
|
||||
width [maxBuilderNest]int8 // 1, 2 or 3 byte length prefix
|
||||
n int8 // open prefix count
|
||||
err error
|
||||
}
|
||||
|
||||
// Reset prepares the Builder to write into dst, discarding any previous state
|
||||
// and error. The Builder writes into dst[:0] and never reallocates, so dst's
|
||||
// capacity is a hard ceiling on the structure being built.
|
||||
func (b *Builder) Reset(dst []byte) {
|
||||
b.buf = dst[:0]
|
||||
b.n = 0
|
||||
b.err = nil
|
||||
}
|
||||
|
||||
// Err returns the first error encountered since [Builder.Reset], if any.
|
||||
func (b *Builder) Err() error { return b.err }
|
||||
|
||||
// Len returns the number of bytes written so far, including the placeholder
|
||||
// bytes of any currently open length prefix.
|
||||
func (b *Builder) Len() int { return len(b.buf) }
|
||||
|
||||
// Bytes returns the built structure. It reports an error if the Builder failed
|
||||
// at any point, or if a length prefix was opened and never closed, since the
|
||||
// resulting bytes would contain an unpatched placeholder.
|
||||
func (b *Builder) Bytes() ([]byte, error) {
|
||||
if b.err != nil {
|
||||
return nil, b.err
|
||||
}
|
||||
if b.n != 0 {
|
||||
return nil, errBuilderUnbal
|
||||
}
|
||||
return b.buf, nil
|
||||
}
|
||||
|
||||
// fail records err if no error has been recorded yet.
|
||||
func (b *Builder) fail(err error) {
|
||||
if b.err == nil {
|
||||
b.err = err
|
||||
}
|
||||
}
|
||||
|
||||
// grow extends the buffer by n bytes and returns the new region, or nil if the
|
||||
// buffer is full or the Builder has already failed.
|
||||
func (b *Builder) grow(n int) []byte {
|
||||
if b.err != nil {
|
||||
return nil
|
||||
}
|
||||
if n > cap(b.buf)-len(b.buf) {
|
||||
b.fail(errShortBuffer)
|
||||
return nil
|
||||
}
|
||||
start := len(b.buf)
|
||||
b.buf = b.buf[:start+n]
|
||||
return b.buf[start:]
|
||||
}
|
||||
|
||||
// AddU8 appends a single byte.
|
||||
func (b *Builder) AddU8(v uint8) {
|
||||
if p := b.grow(1); p != nil {
|
||||
p[0] = v
|
||||
}
|
||||
}
|
||||
|
||||
// AddU16 appends a big-endian uint16.
|
||||
func (b *Builder) AddU16(v uint16) {
|
||||
if p := b.grow(2); p != nil {
|
||||
binary.BigEndian.PutUint16(p, v)
|
||||
}
|
||||
}
|
||||
|
||||
// AddU24 appends a big-endian 24-bit value, the length encoding used by
|
||||
// handshake message headers and certificate entries.
|
||||
func (b *Builder) AddU24(v uint32) {
|
||||
if p := b.grow(3); p != nil {
|
||||
p[0] = byte(v >> 16)
|
||||
p[1] = byte(v >> 8)
|
||||
p[2] = byte(v)
|
||||
}
|
||||
}
|
||||
|
||||
// AddU32 appends a big-endian uint32.
|
||||
func (b *Builder) AddU32(v uint32) {
|
||||
if p := b.grow(4); p != nil {
|
||||
binary.BigEndian.PutUint32(p, v)
|
||||
}
|
||||
}
|
||||
|
||||
// AddBytes appends raw bytes.
|
||||
func (b *Builder) AddBytes(v []byte) {
|
||||
if p := b.grow(len(v)); p != nil {
|
||||
copy(p, v)
|
||||
}
|
||||
}
|
||||
|
||||
// AddString appends the bytes of s without allocating. TLS labels and ALPN
|
||||
// protocol names are naturally string constants, and converting them with
|
||||
// []byte(s) would copy to the heap on every call.
|
||||
func (b *Builder) AddString(s string) {
|
||||
if len(s) == 0 {
|
||||
return
|
||||
}
|
||||
// unsafe.Slice over the string's backing array; the bytes are only read,
|
||||
// and only for the duration of the copy inside AddBytes.
|
||||
b.AddBytes(unsafe.Slice(unsafe.StringData(s), len(s)))
|
||||
}
|
||||
|
||||
// openN reserves width bytes for a length prefix whose value is filled in by
|
||||
// the matching [Builder.Close].
|
||||
func (b *Builder) openN(width int8) {
|
||||
if b.err != nil {
|
||||
return
|
||||
}
|
||||
if int(b.n) >= maxBuilderNest {
|
||||
b.fail(errBuilderNest)
|
||||
return
|
||||
}
|
||||
start := len(b.buf)
|
||||
if b.grow(int(width)) == nil {
|
||||
return
|
||||
}
|
||||
b.stack[b.n] = int32(start)
|
||||
b.width[b.n] = width
|
||||
b.n++
|
||||
}
|
||||
|
||||
// OpenU8 begins a vector with a one-byte length prefix.
|
||||
func (b *Builder) OpenU8() { b.openN(1) }
|
||||
|
||||
// OpenU16 begins a vector with a two-byte length prefix.
|
||||
func (b *Builder) OpenU16() { b.openN(2) }
|
||||
|
||||
// OpenU24 begins a vector with a three-byte length prefix, as used by the
|
||||
// handshake message header.
|
||||
func (b *Builder) OpenU24() { b.openN(3) }
|
||||
|
||||
// Close ends the innermost open vector and backpatches its length prefix with
|
||||
// the number of bytes written since the matching Open.
|
||||
//
|
||||
// A length that does not fit the reserved prefix width is a failure rather
|
||||
// than a silent truncation: writing a value modulo 2^16 into a two-byte prefix
|
||||
// would produce a structurally valid but semantically wrong record.
|
||||
func (b *Builder) Close() {
|
||||
if b.err != nil {
|
||||
return
|
||||
}
|
||||
if b.n == 0 {
|
||||
b.fail(errBuilderUnbal)
|
||||
return
|
||||
}
|
||||
b.n--
|
||||
start := int(b.stack[b.n])
|
||||
width := int(b.width[b.n])
|
||||
n := len(b.buf) - start - width
|
||||
p := b.buf[start:]
|
||||
switch width {
|
||||
case 1:
|
||||
if n > 0xff {
|
||||
b.fail(errBadLength)
|
||||
return
|
||||
}
|
||||
p[0] = byte(n)
|
||||
case 2:
|
||||
if n > 0xffff {
|
||||
b.fail(errBadLength)
|
||||
return
|
||||
}
|
||||
binary.BigEndian.PutUint16(p, uint16(n))
|
||||
case 3:
|
||||
if n > 0xffffff {
|
||||
b.fail(errBadLength)
|
||||
return
|
||||
}
|
||||
p[0] = byte(n >> 16)
|
||||
p[1] = byte(n >> 8)
|
||||
p[2] = byte(n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package tls_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
"github.com/soypat/lneto/x/tls"
|
||||
)
|
||||
|
||||
// buildSupportedVersions writes the extension a TLS 1.3 ServerHello carries,
|
||||
// exercising one level of nesting.
|
||||
func buildSupportedVersions(b *tls.Builder) {
|
||||
b.AddU16(uint16(tls.ExtSupportedVersions))
|
||||
b.OpenU16()
|
||||
b.AddU16(tls.VersionTLS13)
|
||||
b.Close()
|
||||
}
|
||||
|
||||
func TestBuilderRoundTripThroughParser(t *testing.T) {
|
||||
// Build an extensions block, then walk it back with the parser. Agreement
|
||||
// between the two is the property that matters.
|
||||
var b tls.Builder
|
||||
buf := make([]byte, 64)
|
||||
b.Reset(buf)
|
||||
b.OpenU16() // extensions block length
|
||||
buildSupportedVersions(&b)
|
||||
b.AddU16(uint16(tls.ExtKeyShare))
|
||||
b.OpenU16()
|
||||
b.OpenU16() // client_shares
|
||||
b.AddU16(uint16(tls.GroupX25519))
|
||||
b.OpenU16()
|
||||
b.AddBytes(make([]byte, 8))
|
||||
b.Close()
|
||||
b.Close()
|
||||
b.Close()
|
||||
b.Close()
|
||||
|
||||
out, err := b.Bytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Strip the outer block length the way ClientHelloFrame.Extensions does.
|
||||
if len(out) < 2 {
|
||||
t.Fatal("output too short")
|
||||
}
|
||||
exts := out[2:]
|
||||
if int(out[0])<<8|int(out[1]) != len(exts) {
|
||||
t.Fatalf("outer length %d != %d", int(out[0])<<8|int(out[1]), len(exts))
|
||||
}
|
||||
|
||||
var seen []tls.ExtensionType
|
||||
err = tls.ForEachExtension(exts, func(ext tls.ExtensionType, data []byte) error {
|
||||
seen = append(seen, ext)
|
||||
if ext == tls.ExtKeyShare {
|
||||
return tls.ForEachKeyShare(data, func(g tls.NamedGroup, key []byte) error {
|
||||
if g != tls.GroupX25519 || len(key) != 8 {
|
||||
t.Errorf("key share got %v len %d", g, len(key))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(seen) != 2 || seen[0] != tls.ExtSupportedVersions || seen[1] != tls.ExtKeyShare {
|
||||
t.Errorf("walked %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilderStickyShortBuffer(t *testing.T) {
|
||||
var b tls.Builder
|
||||
b.Reset(make([]byte, 4))
|
||||
b.AddU32(0x01020304) // exactly fills
|
||||
if b.Err() != nil {
|
||||
t.Fatalf("unexpected error filling buffer: %v", b.Err())
|
||||
}
|
||||
b.AddU8(0xff) // overflows
|
||||
if !errors.Is(b.Err(), lneto.ErrShortBuffer) {
|
||||
t.Fatalf("got %v want ErrShortBuffer", b.Err())
|
||||
}
|
||||
// Once failed, later writes must not panic, must not write, and must not
|
||||
// replace the original error.
|
||||
b.AddBytes(make([]byte, 100))
|
||||
b.OpenU16()
|
||||
b.Close()
|
||||
if !errors.Is(b.Err(), lneto.ErrShortBuffer) {
|
||||
t.Fatalf("error changed to %v", b.Err())
|
||||
}
|
||||
if _, err := b.Bytes(); !errors.Is(err, lneto.ErrShortBuffer) {
|
||||
t.Fatalf("Bytes returned %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilderUnclosedPrefixIsAnError(t *testing.T) {
|
||||
// Returning bytes with an unpatched placeholder would emit a structurally
|
||||
// wrong record that looks valid.
|
||||
var b tls.Builder
|
||||
b.Reset(make([]byte, 16))
|
||||
b.OpenU16()
|
||||
b.AddU8(1)
|
||||
if _, err := b.Bytes(); err == nil {
|
||||
t.Error("Bytes accepted an unclosed length prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilderUnbalancedClose(t *testing.T) {
|
||||
var b tls.Builder
|
||||
b.Reset(make([]byte, 16))
|
||||
b.Close()
|
||||
if b.Err() == nil {
|
||||
t.Error("Close without Open accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilderNestingLimit(t *testing.T) {
|
||||
var b tls.Builder
|
||||
b.Reset(make([]byte, 64))
|
||||
for range 8 {
|
||||
b.OpenU8()
|
||||
}
|
||||
if b.Err() != nil {
|
||||
t.Fatalf("8 levels rejected: %v", b.Err())
|
||||
}
|
||||
b.OpenU8()
|
||||
if b.Err() == nil {
|
||||
t.Error("9th nesting level accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilderLengthOverflowRejected(t *testing.T) {
|
||||
// 300 bytes cannot be described by a one-byte prefix. Truncating modulo 256
|
||||
// would produce a valid-looking but wrong structure, so this must fail.
|
||||
var b tls.Builder
|
||||
b.Reset(make([]byte, 512))
|
||||
b.OpenU8()
|
||||
b.AddBytes(make([]byte, 300))
|
||||
b.Close()
|
||||
if !errors.Is(b.Err(), lneto.ErrInvalidLengthField) {
|
||||
t.Errorf("got %v want ErrInvalidLengthField", b.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilderU24RoundTrip(t *testing.T) {
|
||||
var b tls.Builder
|
||||
b.Reset(make([]byte, 8))
|
||||
b.AddU8(byte(tls.HandshakeTypeServerHello))
|
||||
b.OpenU24()
|
||||
b.AddBytes([]byte{1, 2, 3})
|
||||
b.Close()
|
||||
out, err := b.Bytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hf, err := tls.NewHandshakeFrame(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hf.MsgType() != tls.HandshakeTypeServerHello {
|
||||
t.Errorf("type got %v", hf.MsgType())
|
||||
}
|
||||
if hf.Length() != 3 {
|
||||
t.Errorf("length got %d want 3", hf.Length())
|
||||
}
|
||||
if !hf.Complete() || len(hf.Body()) != 3 {
|
||||
t.Errorf("body got % x", hf.Body())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilderAddStringMatchesAddBytes(t *testing.T) {
|
||||
const label = "tls13 derived"
|
||||
var b1, b2 tls.Builder
|
||||
buf1, buf2 := make([]byte, 32), make([]byte, 32)
|
||||
b1.Reset(buf1)
|
||||
b1.AddString(label)
|
||||
b2.Reset(buf2)
|
||||
b2.AddBytes([]byte(label))
|
||||
out1, err1 := b1.Bytes()
|
||||
out2, err2 := b2.Bytes()
|
||||
if err1 != nil || err2 != nil {
|
||||
t.Fatalf("%v %v", err1, err2)
|
||||
}
|
||||
if string(out1) != string(out2) {
|
||||
t.Errorf("AddString %q != AddBytes %q", out1, out2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilderZeroAlloc(t *testing.T) {
|
||||
// The whole point of not using cryptobyte. A regression here means the
|
||||
// outbound handshake path started allocating per connection.
|
||||
buf := make([]byte, 256)
|
||||
var b tls.Builder
|
||||
n := testing.AllocsPerRun(100, func() {
|
||||
b.Reset(buf)
|
||||
b.AddU8(byte(tls.HandshakeTypeServerHello))
|
||||
b.OpenU24()
|
||||
b.AddU16(tls.VersionTLS12)
|
||||
b.AddBytes(make([]byte, 0, 0)) // no-op, must not allocate
|
||||
b.OpenU16()
|
||||
buildSupportedVersions(&b)
|
||||
b.Close()
|
||||
b.AddString("x")
|
||||
b.Close()
|
||||
_, _ = b.Bytes()
|
||||
})
|
||||
if n != 0 {
|
||||
t.Errorf("Builder allocated %v times per run, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzBuilderNeverEscapesBuffer(f *testing.F) {
|
||||
f.Add([]byte{1, 2, 3}, uint8(16))
|
||||
f.Fuzz(func(t *testing.T, payload []byte, size uint8) {
|
||||
buf := make([]byte, size)
|
||||
canary := make([]byte, len(buf))
|
||||
var b tls.Builder
|
||||
b.Reset(buf)
|
||||
b.OpenU16()
|
||||
b.AddBytes(payload)
|
||||
b.OpenU8()
|
||||
b.AddBytes(payload)
|
||||
b.Close()
|
||||
b.Close()
|
||||
out, err := b.Bytes()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(out) > len(buf) {
|
||||
t.Fatalf("wrote %d bytes into a %d byte buffer", len(out), len(buf))
|
||||
}
|
||||
// Whatever was produced must parse back as a well-formed vector.
|
||||
if len(out) < 2 {
|
||||
t.Fatalf("output %d bytes is too short to hold its own prefix", len(out))
|
||||
}
|
||||
if declared := int(out[0])<<8 | int(out[1]); declared != len(out)-2 {
|
||||
t.Fatalf("declared %d != actual %d", declared, len(out)-2)
|
||||
}
|
||||
_ = canary
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// Package tls implements a heapless, preallocated TLS 1.3 server (RFC 8446).
|
||||
//
|
||||
// All buffers are supplied by the caller and never grow. There is no
|
||||
// allocation in the steady-state record datapath, no goroutines, and no
|
||||
// dependency on an operating system. A [Conn] wraps any [io.ReadWriter], so it
|
||||
// works over a lneto tcp.Conn, over net.Pipe, or over a bytes.Buffer in tests.
|
||||
//
|
||||
// # Scope
|
||||
//
|
||||
// TLS 1.3 only. There is deliberately no TLS 1.2 fallback, no renegotiation,
|
||||
// no 0-RTT/early data, and no client certificate support. Each omission
|
||||
// removes a whole class of attack surface.
|
||||
//
|
||||
// # Cryptographic primitives
|
||||
//
|
||||
// This package implements the record layer, the key schedule and the handshake
|
||||
// state machine, but ships no cryptographic primitives of its own. Concrete
|
||||
// hashes, AEADs, key agreements and signers cross the [Hasher], [AEADSuite],
|
||||
// [KeyAgreement] and [Signer] interfaces. Standard library implementations
|
||||
// live in the tlsstd subpackage; a hardware AEAD or a secure-element signer
|
||||
// slots in at the same seam without protocol code changing.
|
||||
//
|
||||
// Note that on embedded targets the Signer is expected to be supplied by the
|
||||
// integrator: Go's crypto/ecdsa carries an 88kB precomputed P-256 basepoint
|
||||
// table that lands in RAM, which is a third of an RP2040's SRAM.
|
||||
//
|
||||
// # Randomness
|
||||
//
|
||||
// Config.Rand is required and has no default. Embedded targets frequently have
|
||||
// a weak or stubbed entropy source, and a biased ECDSA nonce recovers the
|
||||
// signing key from two signatures, so the choice is made explicit rather than
|
||||
// silently defaulted. Never seed anything here from internal.Prand32/64; those
|
||||
// are for TCP initial sequence numbers and DNS IDs, not for key material.
|
||||
package tls
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
)
|
||||
|
||||
// Sizes of fixed length headers and the record size limits of RFC 8446 5.2.
|
||||
const (
|
||||
// SizeHeaderRecord is the size of a TLSPlaintext/TLSCiphertext header:
|
||||
// content type(1) + legacy record version(2) + length(2).
|
||||
SizeHeaderRecord = 5
|
||||
// SizeHeaderHandshake is the size of a Handshake header:
|
||||
// message type(1) + 24-bit length(3).
|
||||
SizeHeaderHandshake = 4
|
||||
|
||||
// MaxPlaintext is the largest legal TLSPlaintext.fragment.
|
||||
MaxPlaintext = 1 << 14 // 16384
|
||||
// MaxCiphertext is the largest legal TLSCiphertext.encrypted_record.
|
||||
// The extra 256 bytes cover the content type byte, padding and the AEAD tag.
|
||||
MaxCiphertext = 1<<14 + 256 // 16640
|
||||
// MaxRecord is the largest legal record as it appears on the wire.
|
||||
MaxRecord = SizeHeaderRecord + MaxCiphertext // 16645
|
||||
|
||||
// MinRecordSizeLimit is the smallest value a peer may advertise in the
|
||||
// record_size_limit extension of RFC 8449 4.
|
||||
MinRecordSizeLimit = 64
|
||||
)
|
||||
|
||||
//go:generate stringer -type=ContentType,HandshakeType,ExtensionType,AlertDescription,AlertLevel,NamedGroup,SignatureScheme,CipherSuite -linecomment -output stringers.go .
|
||||
|
||||
// ContentType is the outermost record demultiplexing tag of RFC 8446 5.1.
|
||||
type ContentType uint8
|
||||
|
||||
// Record content types. Values below are the only ones TLS 1.3 defines;
|
||||
// everything else must be rejected with an unexpected_message alert.
|
||||
const (
|
||||
ContentTypeInvalid ContentType = 0 // invalid
|
||||
ContentTypeChangeCipherSpec ContentType = 20 // change_cipher_spec
|
||||
ContentTypeAlert ContentType = 21 // alert
|
||||
ContentTypeHandshake ContentType = 22 // handshake
|
||||
ContentTypeApplicationData ContentType = 23 // application_data
|
||||
)
|
||||
|
||||
// HandshakeType identifies a handshake message, RFC 8446 4.
|
||||
type HandshakeType uint8
|
||||
|
||||
// Handshake message types. Types this server never sends or accepts are still
|
||||
// listed so that a peer sending one can be logged and rejected precisely.
|
||||
const (
|
||||
HandshakeTypeClientHello HandshakeType = 1 // client_hello
|
||||
HandshakeTypeServerHello HandshakeType = 2 // server_hello
|
||||
HandshakeTypeNewSessionTicket HandshakeType = 4 // new_session_ticket
|
||||
HandshakeTypeEndOfEarlyData HandshakeType = 5 // end_of_early_data
|
||||
HandshakeTypeEncryptedExtensions HandshakeType = 8 // encrypted_extensions
|
||||
HandshakeTypeCertificate HandshakeType = 11 // certificate
|
||||
HandshakeTypeCertificateRequest HandshakeType = 13 // certificate_request
|
||||
HandshakeTypeCertificateVerify HandshakeType = 15 // certificate_verify
|
||||
HandshakeTypeFinished HandshakeType = 20 // finished
|
||||
HandshakeTypeKeyUpdate HandshakeType = 24 // key_update
|
||||
HandshakeTypeMessageHash HandshakeType = 254 // message_hash
|
||||
)
|
||||
|
||||
// ExtensionType identifies a hello extension, RFC 8446 4.2.
|
||||
type ExtensionType uint16
|
||||
|
||||
// Extension types. Those marked "ignored" are ones a browser sends and this
|
||||
// server parses past without erroring; being strict about unknown extensions
|
||||
// breaks real clients.
|
||||
const (
|
||||
ExtServerName ExtensionType = 0 // server_name
|
||||
ExtMaxFragmentLength ExtensionType = 1 // max_fragment_length
|
||||
ExtStatusRequest ExtensionType = 5 // status_request
|
||||
ExtSupportedGroups ExtensionType = 10 // supported_groups
|
||||
ExtSignatureAlgorithms ExtensionType = 13 // signature_algorithms
|
||||
ExtALPN ExtensionType = 16 // application_layer_protocol_negotiation
|
||||
ExtSignedCertificateTimestamp ExtensionType = 18 // signed_certificate_timestamp
|
||||
ExtPadding ExtensionType = 21 // padding
|
||||
ExtExtendedMasterSecret ExtensionType = 23 // extended_master_secret
|
||||
ExtCompressCertificate ExtensionType = 27 // compress_certificate
|
||||
ExtRecordSizeLimit ExtensionType = 28 // record_size_limit
|
||||
ExtSessionTicket ExtensionType = 35 // session_ticket
|
||||
ExtPreSharedKey ExtensionType = 41 // pre_shared_key
|
||||
ExtEarlyData ExtensionType = 42 // early_data
|
||||
ExtSupportedVersions ExtensionType = 43 // supported_versions
|
||||
ExtCookie ExtensionType = 44 // cookie
|
||||
ExtPSKKeyExchangeModes ExtensionType = 45 // psk_key_exchange_modes
|
||||
ExtCertificateAuthorities ExtensionType = 47 // certificate_authorities
|
||||
ExtSignatureAlgorithmsCert ExtensionType = 50 // signature_algorithms_cert
|
||||
ExtKeyShare ExtensionType = 51 // key_share
|
||||
ExtApplicationSettings ExtensionType = 17513 // application_settings
|
||||
ExtEncryptedClientHello ExtensionType = 65037 // encrypted_client_hello
|
||||
ExtRenegotiationInfo ExtensionType = 65281 // renegotiation_info
|
||||
)
|
||||
|
||||
// AlertLevel is the legacy severity byte of an alert. In TLS 1.3 every alert
|
||||
// except close_notify and user_canceled is fatal regardless of this field
|
||||
// (RFC 8446 6.1), so it is carried for wire compatibility only and must never
|
||||
// be used to decide whether to continue.
|
||||
type AlertLevel uint8
|
||||
|
||||
// Alert levels.
|
||||
const (
|
||||
AlertLevelWarning AlertLevel = 1 // warning
|
||||
AlertLevelFatal AlertLevel = 2 // fatal
|
||||
)
|
||||
|
||||
// AlertDescription is the alert code of RFC 8446 6.
|
||||
type AlertDescription uint8
|
||||
|
||||
// Alert descriptions. Only the subset a TLS 1.3 server can legitimately send
|
||||
// or receive is listed.
|
||||
const (
|
||||
AlertCloseNotify AlertDescription = 0 // close_notify
|
||||
AlertUnexpectedMessage AlertDescription = 10 // unexpected_message
|
||||
AlertBadRecordMAC AlertDescription = 20 // bad_record_mac
|
||||
AlertRecordOverflow AlertDescription = 22 // record_overflow
|
||||
AlertHandshakeFailure AlertDescription = 40 // handshake_failure
|
||||
AlertBadCertificate AlertDescription = 42 // bad_certificate
|
||||
AlertUnsupportedCertificate AlertDescription = 43 // unsupported_certificate
|
||||
AlertCertificateRevoked AlertDescription = 44 // certificate_revoked
|
||||
AlertCertificateExpired AlertDescription = 45 // certificate_expired
|
||||
AlertCertificateUnknown AlertDescription = 46 // certificate_unknown
|
||||
AlertIllegalParameter AlertDescription = 47 // illegal_parameter
|
||||
AlertUnknownCA AlertDescription = 48 // unknown_ca
|
||||
AlertAccessDenied AlertDescription = 49 // access_denied
|
||||
AlertDecodeError AlertDescription = 50 // decode_error
|
||||
AlertDecryptError AlertDescription = 51 // decrypt_error
|
||||
AlertProtocolVersion AlertDescription = 70 // protocol_version
|
||||
AlertInsufficientSecurity AlertDescription = 71 // insufficient_security
|
||||
AlertInternalError AlertDescription = 80 // internal_error
|
||||
AlertInappropriateFallback AlertDescription = 86 // inappropriate_fallback
|
||||
AlertUserCanceled AlertDescription = 90 // user_canceled
|
||||
AlertMissingExtension AlertDescription = 109 // missing_extension
|
||||
AlertUnsupportedExtension AlertDescription = 110 // unsupported_extension
|
||||
AlertUnrecognizedName AlertDescription = 112 // unrecognized_name
|
||||
AlertBadCertificateStatusResponse AlertDescription = 113 // bad_certificate_status_response
|
||||
AlertUnknownPSKIdentity AlertDescription = 115 // unknown_psk_identity
|
||||
AlertCertificateRequired AlertDescription = 116 // certificate_required
|
||||
AlertNoApplicationProtocol AlertDescription = 120 // no_application_protocol
|
||||
)
|
||||
|
||||
// Protocol versions as they appear on the wire.
|
||||
const (
|
||||
// VersionTLS12 is the value TLS 1.3 requires in ClientHello.legacy_version
|
||||
// and in ServerHello.legacy_version for middlebox compatibility.
|
||||
VersionTLS12 uint16 = 0x0303
|
||||
// VersionTLS13 is the real negotiated version, carried only in the
|
||||
// supported_versions extension.
|
||||
VersionTLS13 uint16 = 0x0304
|
||||
// VersionTLS10 appears in the legacy_record_version of an initial
|
||||
// ClientHello record. The field is ignored entirely on receipt.
|
||||
VersionTLS10 uint16 = 0x0301
|
||||
)
|
||||
|
||||
// NamedGroup identifies a key exchange group, RFC 8446 4.2.7.
|
||||
type NamedGroup uint16
|
||||
|
||||
// Named groups. Only X25519 is implemented; the rest are recognized so that
|
||||
// group selection and HelloRetryRequest can report precisely what was offered.
|
||||
const (
|
||||
GroupSECP256R1 NamedGroup = 0x0017 // secp256r1
|
||||
GroupSECP384R1 NamedGroup = 0x0018 // secp384r1
|
||||
GroupSECP521R1 NamedGroup = 0x0019 // secp521r1
|
||||
GroupX25519 NamedGroup = 0x001d // x25519
|
||||
GroupX448 NamedGroup = 0x001e // x448
|
||||
GroupX25519MLKEM768 NamedGroup = 0x11ec // x25519mlkem768
|
||||
)
|
||||
|
||||
// SignatureScheme identifies a signature algorithm, RFC 8446 4.2.3.
|
||||
type SignatureScheme uint16
|
||||
|
||||
// Signature schemes. Browsers do not accept Ed25519 certificates, so
|
||||
// [SigECDSAP256SHA256] is the practical minimum for a public-facing server.
|
||||
const (
|
||||
SigRSAPKCS1SHA256 SignatureScheme = 0x0401 // rsa_pkcs1_sha256
|
||||
SigRSAPKCS1SHA384 SignatureScheme = 0x0501 // rsa_pkcs1_sha384
|
||||
SigRSAPKCS1SHA512 SignatureScheme = 0x0601 // rsa_pkcs1_sha512
|
||||
SigECDSAP256SHA256 SignatureScheme = 0x0403 // ecdsa_secp256r1_sha256
|
||||
SigECDSAP384SHA384 SignatureScheme = 0x0503 // ecdsa_secp384r1_sha384
|
||||
SigECDSAP521SHA512 SignatureScheme = 0x0603 // ecdsa_secp521r1_sha512
|
||||
SigRSAPSSRSAESHA256 SignatureScheme = 0x0804 // rsa_pss_rsae_sha256
|
||||
SigRSAPSSRSAESHA384 SignatureScheme = 0x0805 // rsa_pss_rsae_sha384
|
||||
SigRSAPSSRSAESHA512 SignatureScheme = 0x0806 // rsa_pss_rsae_sha512
|
||||
SigEd25519 SignatureScheme = 0x0807 // ed25519
|
||||
SigRSAPSSPSSSHA256 SignatureScheme = 0x0809 // rsa_pss_pss_sha256
|
||||
)
|
||||
|
||||
// CipherSuite identifies an AEAD plus hash pair, RFC 8446 B.4.
|
||||
type CipherSuite uint16
|
||||
|
||||
// TLS 1.3 cipher suites. Only [SuiteAES128GCMSHA256] is implemented; it is the
|
||||
// one suite RFC 8446 9.1 makes mandatory to implement.
|
||||
const (
|
||||
SuiteAES128GCMSHA256 CipherSuite = 0x1301 // TLS_AES_128_GCM_SHA256
|
||||
SuiteAES256GCMSHA384 CipherSuite = 0x1302 // TLS_AES_256_GCM_SHA384
|
||||
SuiteChaCha20Poly1305SHA256 CipherSuite = 0x1303 // TLS_CHACHA20_POLY1305_SHA256
|
||||
SuiteAES128CCMSHA256 CipherSuite = 0x1304 // TLS_AES_128_CCM_SHA256
|
||||
SuiteAES128CCM8SHA256 CipherSuite = 0x1305 // TLS_AES_128_CCM_8_SHA256
|
||||
)
|
||||
|
||||
// IsGREASE reports whether v is one of the 16 reserved GREASE values of
|
||||
// RFC 8701. Chrome injects GREASE values into its offered cipher suites,
|
||||
// supported groups, extensions, ALPN protocol list and key shares. They carry
|
||||
// no meaning and must be skipped wherever they appear; rejecting them breaks
|
||||
// Chrome outright.
|
||||
//
|
||||
// GREASE values have both bytes equal and of the form 0x?a.
|
||||
func IsGREASE(v uint16) bool {
|
||||
return v&0x0f0f == 0x0a0a && v>>8 == v&0xff
|
||||
}
|
||||
|
||||
// ErrNeedMore is returned by incremental parsers and by the record layer when
|
||||
// the input available so far is a valid prefix but not yet a complete unit. It
|
||||
// is not a failure: the caller should retry once more data has arrived.
|
||||
//
|
||||
// It is deliberately distinct from [lneto.ErrTruncatedFrame], which means the
|
||||
// data is complete but malformed.
|
||||
var ErrNeedMore = errors.New("tls: need more data")
|
||||
|
||||
// ErrUnexpectedClose is returned by Conn.Read when the underlying transport
|
||||
// reached EOF before a close_notify alert was received. Returning io.EOF here
|
||||
// instead would let an attacker who can inject a TCP FIN or RST silently
|
||||
// truncate a response or a request body. Callers must treat this as a failure
|
||||
// unless the application layer has independently confirmed the message was
|
||||
// complete, for example by satisfying a Content-Length.
|
||||
var ErrUnexpectedClose = errors.New("tls: connection closed without close_notify")
|
||||
|
||||
// Errors reported by frame constructors and walkers. These reuse the generic
|
||||
// lneto error set so that callers can compare against a single vocabulary.
|
||||
var (
|
||||
errTruncated error = lneto.ErrTruncatedFrame
|
||||
errShortBuffer error = lneto.ErrShortBuffer
|
||||
errBadLength error = lneto.ErrInvalidLengthField
|
||||
errBadField error = lneto.ErrInvalidField
|
||||
errUnsupported error = lneto.ErrUnsupported
|
||||
errTrailingBytes = errors.New("tls: trailing bytes after structure")
|
||||
errBuilderNest = errors.New("tls: builder nesting depth exceeded")
|
||||
errBuilderUnbal = errors.New("tls: builder length prefix unbalanced")
|
||||
errAllZeroPlaintext = errors.New("tls: inner plaintext is all padding")
|
||||
)
|
||||
+485
@@ -0,0 +1,485 @@
|
||||
package tls
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
)
|
||||
|
||||
// RecordFrame provides zero-copy access to a TLS record (RFC 8446 5.1).
|
||||
//
|
||||
// struct {
|
||||
// ContentType type; // 1 byte
|
||||
// ProtocolVersion legacy_record_version; // 2 bytes
|
||||
// uint16 length; // 2 bytes
|
||||
// opaque fragment[length];
|
||||
// } TLSPlaintext;
|
||||
//
|
||||
// A RecordFrame may be constructed over a buffer holding only the header, so
|
||||
// that [RecordFrame.Length] can be consulted to decide how many more bytes to
|
||||
// read. Accessors that reach into the fragment return nil until the whole
|
||||
// record is present; call [RecordFrame.Complete] to test for that explicitly.
|
||||
type RecordFrame struct {
|
||||
buf []byte
|
||||
}
|
||||
|
||||
// NewRecordFrame wraps buf as a [RecordFrame]. It validates only that the
|
||||
// 5-byte header is present, since the fragment commonly arrives later.
|
||||
func NewRecordFrame(buf []byte) (RecordFrame, error) {
|
||||
if len(buf) < SizeHeaderRecord {
|
||||
return RecordFrame{}, lneto.ErrTruncatedFrame
|
||||
}
|
||||
return RecordFrame{buf: buf}, nil
|
||||
}
|
||||
|
||||
// ContentType returns the record's outer content type. For a protected record
|
||||
// this is always [ContentTypeApplicationData]; the real type lives in the
|
||||
// encrypted [InnerPlaintext].
|
||||
func (rf RecordFrame) ContentType() ContentType { return ContentType(rf.buf[0]) }
|
||||
|
||||
// SetContentType sets the outer content type.
|
||||
func (rf RecordFrame) SetContentType(ct ContentType) { rf.buf[0] = byte(ct) }
|
||||
|
||||
// LegacyVersion returns the legacy_record_version field. TLS 1.3 requires
|
||||
// receivers to ignore this field entirely; it is exposed for logging only.
|
||||
func (rf RecordFrame) LegacyVersion() uint16 {
|
||||
return binary.BigEndian.Uint16(rf.buf[1:3])
|
||||
}
|
||||
|
||||
// SetLegacyVersion sets the legacy_record_version field.
|
||||
func (rf RecordFrame) SetLegacyVersion(v uint16) {
|
||||
binary.BigEndian.PutUint16(rf.buf[1:3], v)
|
||||
}
|
||||
|
||||
// Length returns the declared fragment length. It is attacker controlled and
|
||||
// must be checked against [MaxCiphertext] before being used to size a read;
|
||||
// [RecordFrame.ValidateSize] does this.
|
||||
func (rf RecordFrame) Length() uint16 {
|
||||
return binary.BigEndian.Uint16(rf.buf[3:5])
|
||||
}
|
||||
|
||||
// SetLength sets the declared fragment length.
|
||||
func (rf RecordFrame) SetLength(n uint16) {
|
||||
binary.BigEndian.PutUint16(rf.buf[3:5], n)
|
||||
}
|
||||
|
||||
// RecordLength returns the total wire size of this record, header included.
|
||||
func (rf RecordFrame) RecordLength() int {
|
||||
return SizeHeaderRecord + int(rf.Length())
|
||||
}
|
||||
|
||||
// Complete reports whether the whole record, header and fragment, is present
|
||||
// in the underlying buffer.
|
||||
func (rf RecordFrame) Complete() bool {
|
||||
return len(rf.buf) >= rf.RecordLength()
|
||||
}
|
||||
|
||||
// Payload returns the record fragment, or nil if the whole record has not
|
||||
// arrived yet. The result aliases the underlying buffer.
|
||||
func (rf RecordFrame) Payload() []byte {
|
||||
if !rf.Complete() {
|
||||
return nil
|
||||
}
|
||||
return rf.buf[SizeHeaderRecord:rf.RecordLength()]
|
||||
}
|
||||
|
||||
// RawData returns the record bytes, header included, truncated to the declared
|
||||
// length when the full record is present.
|
||||
func (rf RecordFrame) RawData() []byte {
|
||||
if !rf.Complete() {
|
||||
return rf.buf
|
||||
}
|
||||
return rf.buf[:rf.RecordLength()]
|
||||
}
|
||||
|
||||
// ValidateSize adds an error to v if the record is structurally invalid.
|
||||
// It does not require the fragment to have arrived; it only rejects a declared
|
||||
// length that could never be legal.
|
||||
func (rf RecordFrame) ValidateSize(v *lneto.Validator) {
|
||||
if len(rf.buf) < SizeHeaderRecord {
|
||||
v.AddError(lneto.ErrTruncatedFrame)
|
||||
return
|
||||
}
|
||||
if rf.Length() > MaxCiphertext {
|
||||
// Checked before the length is ever used to size a read.
|
||||
v.AddError(lneto.ErrInvalidLengthField)
|
||||
}
|
||||
}
|
||||
|
||||
// InnerPlaintext provides access to a decrypted TLSInnerPlaintext
|
||||
// (RFC 8446 5.2):
|
||||
//
|
||||
// struct {
|
||||
// opaque content[length];
|
||||
// ContentType type;
|
||||
// uint8 zeros[length_of_padding];
|
||||
// } TLSInnerPlaintext;
|
||||
//
|
||||
// The real content type is the last non-zero byte, and everything after the
|
||||
// content is padding that must be stripped before use.
|
||||
type InnerPlaintext struct {
|
||||
buf []byte // content only, padding and type byte already stripped
|
||||
ctype ContentType
|
||||
padding int
|
||||
}
|
||||
|
||||
// NewInnerPlaintext scans decrypted for its trailing content type byte,
|
||||
// stripping any zero padding that follows it.
|
||||
//
|
||||
// A record whose plaintext is entirely zeros carries no content type and is a
|
||||
// protocol violation; it is reported so the caller can send an
|
||||
// unexpected_message alert rather than silently treating it as empty.
|
||||
func NewInnerPlaintext(decrypted []byte) (InnerPlaintext, error) {
|
||||
i := len(decrypted) - 1
|
||||
for i >= 0 && decrypted[i] == 0 {
|
||||
i--
|
||||
}
|
||||
if i < 0 {
|
||||
return InnerPlaintext{}, errAllZeroPlaintext
|
||||
}
|
||||
return InnerPlaintext{
|
||||
buf: decrypted[:i],
|
||||
ctype: ContentType(decrypted[i]),
|
||||
padding: len(decrypted) - i - 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ContentType returns the true content type recovered from the inner plaintext.
|
||||
func (ip InnerPlaintext) ContentType() ContentType { return ip.ctype }
|
||||
|
||||
// Content returns the plaintext with the content type byte and padding removed.
|
||||
// The result aliases the buffer passed to [NewInnerPlaintext].
|
||||
func (ip InnerPlaintext) Content() []byte { return ip.buf }
|
||||
|
||||
// PaddingLen returns how many padding bytes followed the content type byte.
|
||||
func (ip InnerPlaintext) PaddingLen() int { return ip.padding }
|
||||
|
||||
// HandshakeFrame provides zero-copy access to a handshake message
|
||||
// (RFC 8446 4):
|
||||
//
|
||||
// struct {
|
||||
// HandshakeType msg_type; // 1 byte
|
||||
// uint24 length; // 3 bytes
|
||||
// opaque body[length];
|
||||
// } Handshake;
|
||||
//
|
||||
// As with [RecordFrame], a HandshakeFrame may be constructed over a buffer
|
||||
// holding only the 4-byte header so that the body length can be consulted
|
||||
// before the rest has arrived.
|
||||
type HandshakeFrame struct {
|
||||
buf []byte
|
||||
}
|
||||
|
||||
// NewHandshakeFrame wraps buf as a [HandshakeFrame], validating that the
|
||||
// 4-byte header is present.
|
||||
func NewHandshakeFrame(buf []byte) (HandshakeFrame, error) {
|
||||
if len(buf) < SizeHeaderHandshake {
|
||||
return HandshakeFrame{}, lneto.ErrTruncatedFrame
|
||||
}
|
||||
return HandshakeFrame{buf: buf}, nil
|
||||
}
|
||||
|
||||
// MsgType returns the handshake message type.
|
||||
func (hf HandshakeFrame) MsgType() HandshakeType { return HandshakeType(hf.buf[0]) }
|
||||
|
||||
// SetMsgType sets the handshake message type.
|
||||
func (hf HandshakeFrame) SetMsgType(t HandshakeType) { hf.buf[0] = byte(t) }
|
||||
|
||||
// Length returns the declared 24-bit body length. It is attacker controlled;
|
||||
// the value is returned as an int32 rather than an int so that behaviour is
|
||||
// identical on 32- and 64-bit targets.
|
||||
func (hf HandshakeFrame) Length() int32 {
|
||||
return int32(hf.buf[1])<<16 | int32(hf.buf[2])<<8 | int32(hf.buf[3])
|
||||
}
|
||||
|
||||
// SetLength sets the declared 24-bit body length. Values outside the 24-bit
|
||||
// range are silently masked; callers building messages should ensure the body
|
||||
// fits first.
|
||||
func (hf HandshakeFrame) SetLength(n int32) {
|
||||
hf.buf[1] = byte(n >> 16)
|
||||
hf.buf[2] = byte(n >> 8)
|
||||
hf.buf[3] = byte(n)
|
||||
}
|
||||
|
||||
// MessageLength returns the total size of this handshake message, header
|
||||
// included.
|
||||
func (hf HandshakeFrame) MessageLength() int {
|
||||
return SizeHeaderHandshake + int(hf.Length())
|
||||
}
|
||||
|
||||
// Complete reports whether the entire handshake message is present.
|
||||
func (hf HandshakeFrame) Complete() bool {
|
||||
return len(hf.buf) >= hf.MessageLength()
|
||||
}
|
||||
|
||||
// Body returns the handshake message body, or nil if the whole message has not
|
||||
// arrived. The result aliases the underlying buffer.
|
||||
func (hf HandshakeFrame) Body() []byte {
|
||||
if !hf.Complete() {
|
||||
return nil
|
||||
}
|
||||
return hf.buf[SizeHeaderHandshake:hf.MessageLength()]
|
||||
}
|
||||
|
||||
// RawData returns the handshake message bytes, header included. This is what
|
||||
// must be fed to the transcript hash: the header is hashed along with the body,
|
||||
// exactly once per message, even when the message spanned several records.
|
||||
func (hf HandshakeFrame) RawData() []byte {
|
||||
if !hf.Complete() {
|
||||
return hf.buf
|
||||
}
|
||||
return hf.buf[:hf.MessageLength()]
|
||||
}
|
||||
|
||||
// ValidateSize adds an error to v if the handshake header is malformed.
|
||||
func (hf HandshakeFrame) ValidateSize(v *lneto.Validator) {
|
||||
if len(hf.buf) < SizeHeaderHandshake {
|
||||
v.AddError(lneto.ErrTruncatedFrame)
|
||||
return
|
||||
}
|
||||
if hf.Length() > MaxPlaintext {
|
||||
// No handshake message this server accepts approaches 2^14 bytes.
|
||||
v.AddError(lneto.ErrInvalidLengthField)
|
||||
}
|
||||
}
|
||||
|
||||
// ExtensionFrame provides access to a single hello extension (RFC 8446 4.2):
|
||||
//
|
||||
// struct {
|
||||
// ExtensionType extension_type; // 2 bytes
|
||||
// opaque extension_data<0..2^16-1>;
|
||||
// } Extension;
|
||||
type ExtensionFrame struct {
|
||||
buf []byte
|
||||
}
|
||||
|
||||
// NewExtensionFrame wraps buf as an [ExtensionFrame]. Unlike the record and
|
||||
// handshake frames, an extension is only ever parsed out of a fully buffered
|
||||
// hello, so the whole extension must be present.
|
||||
func NewExtensionFrame(buf []byte) (ExtensionFrame, error) {
|
||||
if len(buf) < 4 {
|
||||
return ExtensionFrame{}, lneto.ErrTruncatedFrame
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(buf[2:4]))
|
||||
if 4+n > len(buf) {
|
||||
return ExtensionFrame{}, lneto.ErrTruncatedFrame
|
||||
}
|
||||
return ExtensionFrame{buf: buf[:4+n]}, nil
|
||||
}
|
||||
|
||||
// Type returns the extension type.
|
||||
func (ef ExtensionFrame) Type() ExtensionType {
|
||||
return ExtensionType(binary.BigEndian.Uint16(ef.buf[0:2]))
|
||||
}
|
||||
|
||||
// Length returns the declared extension_data length.
|
||||
func (ef ExtensionFrame) Length() uint16 {
|
||||
return binary.BigEndian.Uint16(ef.buf[2:4])
|
||||
}
|
||||
|
||||
// Data returns the extension_data bytes.
|
||||
func (ef ExtensionFrame) Data() []byte { return ef.buf[4:] }
|
||||
|
||||
// RawData returns the extension bytes, type and length included.
|
||||
func (ef ExtensionFrame) RawData() []byte { return ef.buf }
|
||||
|
||||
// ForEachExtension walks an extension list, calling fn for each extension in
|
||||
// wire order. exts is the contents of the extensions block, with the outer
|
||||
// two-byte list length already stripped; [ClientHelloFrame.Extensions] returns
|
||||
// it in that form.
|
||||
//
|
||||
// The walker is deliberately permissive about extension types: unknown types,
|
||||
// including every GREASE value Chrome injects, are passed to fn like any other.
|
||||
// It is the caller's switch that skips them. The walker is strict about
|
||||
// framing: a length field that overruns the list aborts with
|
||||
// [lneto.ErrTruncatedFrame].
|
||||
//
|
||||
// Modelled on tcp.OptionCodec.ForEachOption.
|
||||
func ForEachExtension(exts []byte, fn func(ExtensionType, []byte) error) error {
|
||||
for off := 0; off < len(exts); {
|
||||
ef, err := NewExtensionFrame(exts[off:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = fn(ef.Type(), ef.Data())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
off += len(ef.RawData())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForEachU16 walks a bare list of big-endian uint16 values, such as the
|
||||
// cipher_suites vector of a ClientHello. b must have even length.
|
||||
func ForEachU16(b []byte, fn func(uint16) error) error {
|
||||
if len(b)%2 != 0 {
|
||||
return lneto.ErrInvalidLengthField
|
||||
}
|
||||
for off := 0; off < len(b); off += 2 {
|
||||
err := fn(binary.BigEndian.Uint16(b[off : off+2]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForEachSupportedGroup walks the contents of a supported_groups extension,
|
||||
// whose payload is a uint16-length-prefixed list of [NamedGroup] values.
|
||||
func ForEachSupportedGroup(extData []byte, fn func(NamedGroup) error) error {
|
||||
body, err := vectorU16(extData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ForEachU16(body, func(v uint16) error { return fn(NamedGroup(v)) })
|
||||
}
|
||||
|
||||
// ForEachSignatureScheme walks the contents of a signature_algorithms (or
|
||||
// signature_algorithms_cert) extension.
|
||||
func ForEachSignatureScheme(extData []byte, fn func(SignatureScheme) error) error {
|
||||
body, err := vectorU16(extData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ForEachU16(body, func(v uint16) error { return fn(SignatureScheme(v)) })
|
||||
}
|
||||
|
||||
// ForEachSupportedVersion walks the contents of a supported_versions extension
|
||||
// as it appears in a ClientHello. Note the prefix here is a single byte, unlike
|
||||
// every other list in the hello; the ServerHello form carries a bare uint16
|
||||
// instead and is not parsed by this function.
|
||||
func ForEachSupportedVersion(extData []byte, fn func(uint16) error) error {
|
||||
body, err := vectorU8(extData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ForEachU16(body, fn)
|
||||
}
|
||||
|
||||
// ForEachKeyShare walks the client_shares list of a key_share extension:
|
||||
//
|
||||
// struct {
|
||||
// NamedGroup group;
|
||||
// opaque key_exchange<1..2^16-1>;
|
||||
// } KeyShareEntry;
|
||||
//
|
||||
// GREASE key shares carry a deliberately absurd body, commonly a single byte,
|
||||
// and must not be treated as malformed. The walker therefore places no
|
||||
// constraint on key_exchange length beyond it fitting inside the list; group
|
||||
// selection is the caller's job.
|
||||
func ForEachKeyShare(extData []byte, fn func(NamedGroup, []byte) error) error {
|
||||
body, err := vectorU16(extData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for off := 0; off < len(body); {
|
||||
if len(body)-off < 4 {
|
||||
return lneto.ErrTruncatedFrame
|
||||
}
|
||||
group := NamedGroup(binary.BigEndian.Uint16(body[off : off+2]))
|
||||
n := int(binary.BigEndian.Uint16(body[off+2 : off+4]))
|
||||
off += 4
|
||||
if n > len(body)-off {
|
||||
return lneto.ErrTruncatedFrame
|
||||
}
|
||||
err = fn(group, body[off:off+n])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
off += n
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForEachALPNProto walks the protocol name list of an ALPN extension. Each
|
||||
// name is a single-byte-length-prefixed string. Chrome includes a GREASE entry
|
||||
// here too, so callers must match against their own offer list rather than
|
||||
// assuming the first entry is meaningful.
|
||||
//
|
||||
// A zero-length protocol name is a protocol violation and aborts the walk.
|
||||
func ForEachALPNProto(extData []byte, fn func([]byte) error) error {
|
||||
body, err := vectorU16(extData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for off := 0; off < len(body); {
|
||||
n := int(body[off])
|
||||
off++
|
||||
if n == 0 {
|
||||
return lneto.ErrInvalidLengthField
|
||||
} else if n > len(body)-off {
|
||||
return lneto.ErrTruncatedFrame
|
||||
}
|
||||
err = fn(body[off : off+n])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
off += n
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForEachServerName walks the server_name_list of an SNI extension:
|
||||
//
|
||||
// struct {
|
||||
// NameType name_type; // 1 byte, 0 = host_name
|
||||
// opaque HostName<1..2^16-1>;
|
||||
// } ServerName;
|
||||
func ForEachServerName(extData []byte, fn func(nameType uint8, name []byte) error) error {
|
||||
body, err := vectorU16(extData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for off := 0; off < len(body); {
|
||||
if len(body)-off < 3 {
|
||||
return lneto.ErrTruncatedFrame
|
||||
}
|
||||
nameType := body[off]
|
||||
n := int(binary.BigEndian.Uint16(body[off+1 : off+3]))
|
||||
off += 3
|
||||
if n > len(body)-off {
|
||||
return lneto.ErrTruncatedFrame
|
||||
}
|
||||
err = fn(nameType, body[off:off+n])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
off += n
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// vectorU16 strips a two-byte length prefix and returns the vector contents.
|
||||
// It requires the prefix to describe the buffer exactly: trailing bytes mean
|
||||
// the sender and this parser disagree on the structure, which is precisely the
|
||||
// ambiguity parser-differential attacks exploit.
|
||||
func vectorU16(b []byte) ([]byte, error) {
|
||||
if len(b) < 2 {
|
||||
return nil, lneto.ErrTruncatedFrame
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(b[0:2]))
|
||||
if n != len(b)-2 {
|
||||
if n > len(b)-2 {
|
||||
return nil, lneto.ErrTruncatedFrame
|
||||
}
|
||||
return nil, errTrailingBytes
|
||||
}
|
||||
return b[2:], nil
|
||||
}
|
||||
|
||||
// vectorU8 strips a one-byte length prefix. See [vectorU16] for why trailing
|
||||
// bytes are rejected.
|
||||
func vectorU8(b []byte) ([]byte, error) {
|
||||
if len(b) < 1 {
|
||||
return nil, lneto.ErrTruncatedFrame
|
||||
}
|
||||
n := int(b[0])
|
||||
if n != len(b)-1 {
|
||||
if n > len(b)-1 {
|
||||
return nil, lneto.ErrTruncatedFrame
|
||||
}
|
||||
return nil, errTrailingBytes
|
||||
}
|
||||
return b[1:], nil
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package tls_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
"github.com/soypat/lneto/x/tls"
|
||||
)
|
||||
|
||||
func TestRecordFrameHeaderBeforeBody(t *testing.T) {
|
||||
// A record layer sees the 5 byte header first and must be able to consult
|
||||
// the length to know how much more to read.
|
||||
hdr := []byte{22, 0x03, 0x01, 0x00, 0x10}
|
||||
rf, err := tls.NewRecordFrame(hdr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rf.ContentType() != tls.ContentTypeHandshake {
|
||||
t.Errorf("content type got %v want handshake", rf.ContentType())
|
||||
}
|
||||
if rf.Length() != 16 {
|
||||
t.Errorf("length got %d want 16", rf.Length())
|
||||
}
|
||||
if rf.RecordLength() != 21 {
|
||||
t.Errorf("record length got %d want 21", rf.RecordLength())
|
||||
}
|
||||
if rf.Complete() {
|
||||
t.Error("record with header only reported complete")
|
||||
}
|
||||
if rf.Payload() != nil {
|
||||
t.Error("payload of incomplete record must be nil, not a short slice")
|
||||
}
|
||||
|
||||
full := append(hdr, make([]byte, 16)...)
|
||||
rf, err = tls.NewRecordFrame(full)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rf.Complete() {
|
||||
t.Fatal("full record reported incomplete")
|
||||
}
|
||||
if len(rf.Payload()) != 16 {
|
||||
t.Errorf("payload len got %d want 16", len(rf.Payload()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordFrameShortHeader(t *testing.T) {
|
||||
for n := range tls.SizeHeaderRecord {
|
||||
_, err := tls.NewRecordFrame(make([]byte, n))
|
||||
if !errors.Is(err, lneto.ErrTruncatedFrame) {
|
||||
t.Errorf("len=%d got %v want ErrTruncatedFrame", n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordFrameRejectsOversizeLength(t *testing.T) {
|
||||
// The length field is attacker controlled and must be refused before it is
|
||||
// ever used to size a read.
|
||||
hdr := []byte{23, 0x03, 0x03, 0xff, 0xff} // 65535 > MaxCiphertext
|
||||
rf, err := tls.NewRecordFrame(hdr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var v lneto.Validator
|
||||
rf.ValidateSize(&v)
|
||||
if err := v.ErrPop(); !errors.Is(err, lneto.ErrInvalidLengthField) {
|
||||
t.Errorf("got %v want ErrInvalidLengthField", err)
|
||||
}
|
||||
|
||||
// Exactly at the limit is legal.
|
||||
maxCT := uint16(tls.MaxCiphertext)
|
||||
hdr[3], hdr[4] = byte(maxCT>>8), byte(maxCT)
|
||||
rf, _ = tls.NewRecordFrame(hdr)
|
||||
rf.ValidateSize(&v)
|
||||
if err := v.ErrPop(); err != nil {
|
||||
t.Errorf("MaxCiphertext rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInnerPlaintextStripsPadding(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
in []byte
|
||||
content string
|
||||
ctype tls.ContentType
|
||||
padding int
|
||||
}{
|
||||
{"no padding", []byte{'h', 'i', 23}, "hi", tls.ContentTypeApplicationData, 0},
|
||||
{"padded", []byte{'h', 'i', 23, 0, 0, 0}, "hi", tls.ContentTypeApplicationData, 3},
|
||||
{"empty content", []byte{22}, "", tls.ContentTypeHandshake, 0},
|
||||
{"content ends in zero", []byte{'a', 0, 'b', 21, 0}, "a\x00b", tls.ContentTypeAlert, 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ip, err := tls.NewInnerPlaintext(tc.in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(ip.Content()) != tc.content {
|
||||
t.Errorf("content got %q want %q", ip.Content(), tc.content)
|
||||
}
|
||||
if ip.ContentType() != tc.ctype {
|
||||
t.Errorf("type got %v want %v", ip.ContentType(), tc.ctype)
|
||||
}
|
||||
if ip.PaddingLen() != tc.padding {
|
||||
t.Errorf("padding got %d want %d", ip.PaddingLen(), tc.padding)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInnerPlaintextAllZeroRejected(t *testing.T) {
|
||||
// An all-zero inner plaintext carries no content type. Silently treating it
|
||||
// as empty would let a peer inject records the state machine cannot
|
||||
// classify; RFC 8446 5.4 requires unexpected_message.
|
||||
for _, n := range []int{0, 1, 8} {
|
||||
_, err := tls.NewInnerPlaintext(make([]byte, n))
|
||||
if err == nil {
|
||||
t.Errorf("len=%d: all-zero plaintext accepted", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandshakeFrame24BitLength(t *testing.T) {
|
||||
hdr := []byte{1, 0x01, 0x02, 0x03} // ClientHello, length 0x010203
|
||||
hf, err := tls.NewHandshakeFrame(hdr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hf.MsgType() != tls.HandshakeTypeClientHello {
|
||||
t.Errorf("type got %v", hf.MsgType())
|
||||
}
|
||||
if hf.Length() != 0x010203 {
|
||||
t.Errorf("length got %#x want 0x010203", hf.Length())
|
||||
}
|
||||
if hf.Complete() {
|
||||
t.Error("header-only message reported complete")
|
||||
}
|
||||
if hf.Body() != nil {
|
||||
t.Error("body of incomplete message must be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandshakeFrameRoundTripLength(t *testing.T) {
|
||||
buf := make([]byte, tls.SizeHeaderHandshake)
|
||||
hf, err := tls.NewHandshakeFrame(buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, n := range []int32{0, 1, 255, 256, 65535, 65536, 0xffffff} {
|
||||
hf.SetLength(n)
|
||||
if got := hf.Length(); got != n {
|
||||
t.Errorf("SetLength(%d) round-tripped to %d", n, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandshakeFrameRawDataIncludesHeader(t *testing.T) {
|
||||
// The transcript hash covers the header plus body, exactly once per
|
||||
// message. Getting this wrong breaks Finished verification.
|
||||
msg := []byte{20, 0, 0, 2, 0xaa, 0xbb}
|
||||
hf, err := tls.NewHandshakeFrame(msg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := hf.RawData(); len(got) != 6 {
|
||||
t.Errorf("RawData len got %d want 6", len(got))
|
||||
}
|
||||
if got := hf.Body(); len(got) != 2 || got[0] != 0xaa {
|
||||
t.Errorf("Body got % x", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForEachExtensionWalksAndToleratesGREASE(t *testing.T) {
|
||||
// Two extensions: a GREASE type with empty data, then supported_versions.
|
||||
exts := []byte{
|
||||
0x0a, 0x0a, 0x00, 0x00, // GREASE, len 0
|
||||
0x00, 0x2b, 0x00, 0x03, 0x02, 0x03, 0x04, // supported_versions
|
||||
}
|
||||
var types []tls.ExtensionType
|
||||
err := tls.ForEachExtension(exts, func(ext tls.ExtensionType, data []byte) error {
|
||||
types = append(types, ext)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(types) != 2 || types[1] != tls.ExtSupportedVersions {
|
||||
t.Fatalf("got %v", types)
|
||||
}
|
||||
if !tls.IsGREASE(uint16(types[0])) {
|
||||
t.Errorf("first extension %#x not recognized as GREASE", types[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestForEachExtensionTruncated(t *testing.T) {
|
||||
for _, tc := range [][]byte{
|
||||
{0x00}, // partial type
|
||||
{0x00, 0x2b, 0x00}, // partial length
|
||||
{0x00, 0x2b, 0x00, 0x05, 0x02, 0x03}, // length overruns
|
||||
} {
|
||||
err := tls.ForEachExtension(tc, func(tls.ExtensionType, []byte) error { return nil })
|
||||
if !errors.Is(err, lneto.ErrTruncatedFrame) {
|
||||
t.Errorf("% x: got %v want ErrTruncatedFrame", tc, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForEachExtensionPropagatesCallbackError(t *testing.T) {
|
||||
sentinel := errors.New("stop")
|
||||
exts := []byte{0x00, 0x2b, 0x00, 0x00, 0x00, 0x33, 0x00, 0x00}
|
||||
n := 0
|
||||
err := tls.ForEachExtension(exts, func(tls.ExtensionType, []byte) error {
|
||||
n++
|
||||
return sentinel
|
||||
})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Errorf("got %v want sentinel", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("walk continued after callback error: %d calls", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForEachKeyShareAcceptsGREASEEntry(t *testing.T) {
|
||||
// Chrome sends a GREASE key share whose key_exchange is a single byte.
|
||||
// Rejecting it as malformed breaks Chrome outright.
|
||||
extData := []byte{
|
||||
0x00, 0x0b, // client_shares length 11
|
||||
0x1a, 0x1a, 0x00, 0x01, 0x00, // GREASE group, 1 byte body
|
||||
0x00, 0x1d, 0x00, 0x02, 0xab, 0xcd, // x25519, 2 byte body
|
||||
}
|
||||
type share struct {
|
||||
g tls.NamedGroup
|
||||
n int
|
||||
}
|
||||
var got []share
|
||||
err := tls.ForEachKeyShare(extData, func(g tls.NamedGroup, key []byte) error {
|
||||
got = append(got, share{g, len(key)})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d shares want 2", len(got))
|
||||
}
|
||||
if !tls.IsGREASE(uint16(got[0].g)) || got[0].n != 1 {
|
||||
t.Errorf("GREASE share mishandled: %+v", got[0])
|
||||
}
|
||||
if got[1].g != tls.GroupX25519 || got[1].n != 2 {
|
||||
t.Errorf("x25519 share mishandled: %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestForEachALPNProtoRejectsEmptyName(t *testing.T) {
|
||||
// A zero-length protocol name would make the walk unable to advance.
|
||||
extData := []byte{0x00, 0x01, 0x00}
|
||||
err := tls.ForEachALPNProto(extData, func([]byte) error { return nil })
|
||||
if !errors.Is(err, lneto.ErrInvalidLengthField) {
|
||||
t.Errorf("got %v want ErrInvalidLengthField", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForEachALPNProto(t *testing.T) {
|
||||
extData := []byte{
|
||||
0x00, 0x0c,
|
||||
0x02, 'h', '2',
|
||||
0x08, 'h', 't', 't', 'p', '/', '1', '.', '1',
|
||||
}
|
||||
var names []string
|
||||
err := tls.ForEachALPNProto(extData, func(b []byte) error {
|
||||
names = append(names, string(b))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(names) != 2 || names[0] != "h2" || names[1] != "http/1.1" {
|
||||
t.Errorf("got %q", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForEachSupportedVersionUsesU8Prefix(t *testing.T) {
|
||||
// supported_versions is the one hello list with a single byte prefix.
|
||||
extData := []byte{0x04, 0x1a, 0x1a, 0x03, 0x04}
|
||||
var vers []uint16
|
||||
err := tls.ForEachSupportedVersion(extData, func(v uint16) error {
|
||||
vers = append(vers, v)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vers) != 2 || vers[1] != tls.VersionTLS13 {
|
||||
t.Errorf("got %#x", vers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVectorRejectsTrailingBytes(t *testing.T) {
|
||||
// A prefix that under-describes its buffer leaves bytes whose meaning this
|
||||
// parser and a middlebox could disagree about.
|
||||
err := tls.ForEachSupportedGroup([]byte{0x00, 0x02, 0x00, 0x1d, 0xff}, func(tls.NamedGroup) error { return nil })
|
||||
if err == nil {
|
||||
t.Error("trailing bytes after vector accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForEachU16RejectsOddLength(t *testing.T) {
|
||||
err := tls.ForEachU16([]byte{0x00, 0x1d, 0x00}, func(uint16) error { return nil })
|
||||
if !errors.Is(err, lneto.ErrInvalidLengthField) {
|
||||
t.Errorf("got %v want ErrInvalidLengthField", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGREASE(t *testing.T) {
|
||||
// The 16 reserved values of RFC 8701.
|
||||
for i := range 16 {
|
||||
v := uint16(i)<<12 | 0x0a00 | uint16(i)<<4 | 0x0a
|
||||
if !tls.IsGREASE(v) {
|
||||
t.Errorf("%#04x not detected as GREASE", v)
|
||||
}
|
||||
}
|
||||
for _, v := range []uint16{
|
||||
0x0000, 0x1301, 0x001d, 0x0403, 0x0a0b, 0x0b0a, 0x1a2a, 0xffff,
|
||||
} {
|
||||
if tls.IsGREASE(v) {
|
||||
t.Errorf("%#04x falsely detected as GREASE", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzNewRecordFrame(f *testing.F) {
|
||||
f.Add([]byte{22, 3, 1, 0, 5, 1, 2, 3, 4, 5})
|
||||
f.Add([]byte{23, 3, 3, 0xff, 0xff})
|
||||
f.Fuzz(func(t *testing.T, b []byte) {
|
||||
rf, err := tls.NewRecordFrame(b)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var v lneto.Validator
|
||||
rf.ValidateSize(&v)
|
||||
// Accessors must stay in bounds regardless of validation outcome.
|
||||
_ = rf.ContentType()
|
||||
_ = rf.LegacyVersion()
|
||||
_ = rf.Length()
|
||||
if p := rf.Payload(); p != nil && len(p) != int(rf.Length()) {
|
||||
t.Fatalf("payload len %d != declared %d", len(p), rf.Length())
|
||||
}
|
||||
if raw := rf.RawData(); len(raw) > len(b) {
|
||||
t.Fatalf("RawData %d longer than input %d", len(raw), len(b))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func FuzzNewHandshakeFrame(f *testing.F) {
|
||||
f.Add([]byte{1, 0, 0, 2, 3, 4})
|
||||
f.Add([]byte{20, 0xff, 0xff, 0xff})
|
||||
f.Fuzz(func(t *testing.T, b []byte) {
|
||||
hf, err := tls.NewHandshakeFrame(b)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var v lneto.Validator
|
||||
hf.ValidateSize(&v)
|
||||
if hf.Length() < 0 {
|
||||
t.Fatalf("negative 24-bit length %d", hf.Length())
|
||||
}
|
||||
if body := hf.Body(); body != nil && len(body) != int(hf.Length()) {
|
||||
t.Fatalf("body len %d != declared %d", len(body), hf.Length())
|
||||
}
|
||||
if raw := hf.RawData(); len(raw) > len(b) {
|
||||
t.Fatalf("RawData %d longer than input %d", len(raw), len(b))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func FuzzNewInnerPlaintext(f *testing.F) {
|
||||
f.Add([]byte{1, 2, 23, 0, 0})
|
||||
f.Add([]byte{0, 0, 0})
|
||||
f.Fuzz(func(t *testing.T, b []byte) {
|
||||
ip, err := tls.NewInnerPlaintext(b)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if ip.ContentType() == 0 {
|
||||
t.Fatal("accepted a zero content type")
|
||||
}
|
||||
if len(ip.Content())+ip.PaddingLen()+1 != len(b) {
|
||||
t.Fatalf("content %d + padding %d + 1 != input %d",
|
||||
len(ip.Content()), ip.PaddingLen(), len(b))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func FuzzForEachExtension(f *testing.F) {
|
||||
f.Add([]byte{0x00, 0x2b, 0x00, 0x03, 0x02, 0x03, 0x04})
|
||||
f.Add([]byte{0x0a, 0x0a, 0x00, 0x00})
|
||||
f.Fuzz(func(t *testing.T, b []byte) {
|
||||
total := 0
|
||||
err := tls.ForEachExtension(b, func(ext tls.ExtensionType, data []byte) error {
|
||||
total += 4 + len(data)
|
||||
if total > len(b) {
|
||||
t.Fatalf("walked %d bytes past input length %d", total, len(b))
|
||||
}
|
||||
// Sub-walkers must also never escape their slice.
|
||||
_ = tls.ForEachKeyShare(data, func(tls.NamedGroup, []byte) error { return nil })
|
||||
_ = tls.ForEachALPNProto(data, func([]byte) error { return nil })
|
||||
_ = tls.ForEachSupportedGroup(data, func(tls.NamedGroup) error { return nil })
|
||||
_ = tls.ForEachSupportedVersion(data, func(uint16) error { return nil })
|
||||
_ = tls.ForEachServerName(data, func(uint8, []byte) error { return nil })
|
||||
return nil
|
||||
})
|
||||
if err == nil && total != len(b) {
|
||||
t.Fatalf("clean walk consumed %d of %d bytes", total, len(b))
|
||||
}
|
||||
})
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
package tls
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
)
|
||||
|
||||
// SizeRandom is the length of the client_random and server_random fields.
|
||||
const SizeRandom = 32
|
||||
|
||||
// MaxSessionIDLen is the largest legal legacy_session_id. TLS 1.3 does not use
|
||||
// session IDs, but a client sends a 32-byte one to trigger middlebox
|
||||
// compatibility mode and the server must echo it verbatim.
|
||||
const MaxSessionIDLen = 32
|
||||
|
||||
// ClientHelloFrame provides zero-copy access to a ClientHello body
|
||||
// (RFC 8446 4.1.2). The frame wraps the handshake message *body*, that is the
|
||||
// bytes returned by [HandshakeFrame.Body], not the handshake header.
|
||||
//
|
||||
// struct {
|
||||
// ProtocolVersion legacy_version = 0x0303;
|
||||
// Random random; // 32 bytes
|
||||
// opaque legacy_session_id<0..32>;
|
||||
// CipherSuite cipher_suites<2..2^16-2>;
|
||||
// opaque legacy_compression_methods<1..2^8-1>;
|
||||
// Extension extensions<8..2^16-1>;
|
||||
// } ClientHello;
|
||||
//
|
||||
// Every variable-length field is bounds-checked once by
|
||||
// [NewClientHelloFrame], so the accessors cannot slice out of range.
|
||||
type ClientHelloFrame struct {
|
||||
buf []byte
|
||||
// Offsets of each variable-length field's contents, resolved once at
|
||||
// construction so accessors stay branch-free.
|
||||
sessionIDOff, sessionIDLen int
|
||||
suitesOff, suitesLen int
|
||||
compOff, compLen int
|
||||
extsOff, extsLen int
|
||||
}
|
||||
|
||||
// NewClientHelloFrame parses the structure of a ClientHello body, validating
|
||||
// that every length prefix is consistent with the buffer. It performs no
|
||||
// policy checks: version negotiation, cipher suite selection and extension
|
||||
// validation are the handshake state machine's job.
|
||||
//
|
||||
// Trailing bytes after the extensions block are rejected. A sender and parser
|
||||
// that disagree about where a structure ends is the ambiguity that
|
||||
// parser-differential attacks are built on.
|
||||
func NewClientHelloFrame(body []byte) (ClientHelloFrame, error) {
|
||||
var ch ClientHelloFrame
|
||||
// legacy_version(2) + random(32) + session_id length(1)
|
||||
const fixed = 2 + SizeRandom + 1
|
||||
if len(body) < fixed {
|
||||
return ch, lneto.ErrTruncatedFrame
|
||||
}
|
||||
off := 2 + SizeRandom
|
||||
|
||||
sidLen := int(body[off])
|
||||
off++
|
||||
if sidLen > MaxSessionIDLen {
|
||||
// Bounds-checked before it can reach a fixed [32]byte echo buffer.
|
||||
return ch, lneto.ErrInvalidLengthField
|
||||
} else if sidLen > len(body)-off {
|
||||
return ch, lneto.ErrTruncatedFrame
|
||||
}
|
||||
ch.sessionIDOff, ch.sessionIDLen = off, sidLen
|
||||
off += sidLen
|
||||
|
||||
if len(body)-off < 2 {
|
||||
return ch, lneto.ErrTruncatedFrame
|
||||
}
|
||||
suitesLen := int(binary.BigEndian.Uint16(body[off : off+2]))
|
||||
off += 2
|
||||
if suitesLen > len(body)-off {
|
||||
return ch, lneto.ErrTruncatedFrame
|
||||
} else if suitesLen%2 != 0 || suitesLen == 0 {
|
||||
return ch, lneto.ErrInvalidLengthField
|
||||
}
|
||||
ch.suitesOff, ch.suitesLen = off, suitesLen
|
||||
off += suitesLen
|
||||
|
||||
if len(body)-off < 1 {
|
||||
return ch, lneto.ErrTruncatedFrame
|
||||
}
|
||||
compLen := int(body[off])
|
||||
off++
|
||||
if compLen > len(body)-off {
|
||||
return ch, lneto.ErrTruncatedFrame
|
||||
}
|
||||
ch.compOff, ch.compLen = off, compLen
|
||||
off += compLen
|
||||
|
||||
// TLS 1.3 requires extensions; a ClientHello without them cannot possibly
|
||||
// carry supported_versions and so cannot be a 1.3 hello.
|
||||
if len(body)-off < 2 {
|
||||
return ch, lneto.ErrTruncatedFrame
|
||||
}
|
||||
extsLen := int(binary.BigEndian.Uint16(body[off : off+2]))
|
||||
off += 2
|
||||
if extsLen > len(body)-off {
|
||||
return ch, lneto.ErrTruncatedFrame
|
||||
} else if extsLen != len(body)-off {
|
||||
return ch, errTrailingBytes
|
||||
}
|
||||
ch.extsOff, ch.extsLen = off, extsLen
|
||||
|
||||
ch.buf = body
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// LegacyVersion returns the legacy_version field, which TLS 1.3 pins to
|
||||
// 0x0303 regardless of the version actually negotiated.
|
||||
func (ch ClientHelloFrame) LegacyVersion() uint16 {
|
||||
return binary.BigEndian.Uint16(ch.buf[0:2])
|
||||
}
|
||||
|
||||
// Random returns the 32-byte client_random.
|
||||
func (ch ClientHelloFrame) Random() *[SizeRandom]byte {
|
||||
return (*[SizeRandom]byte)(ch.buf[2 : 2+SizeRandom])
|
||||
}
|
||||
|
||||
// LegacySessionID returns the legacy_session_id, at most 32 bytes. A TLS 1.3
|
||||
// server must echo this verbatim in its ServerHello; a non-empty value means
|
||||
// the client is using middlebox compatibility mode and expects a dummy
|
||||
// ChangeCipherSpec record.
|
||||
func (ch ClientHelloFrame) LegacySessionID() []byte {
|
||||
return ch.buf[ch.sessionIDOff : ch.sessionIDOff+ch.sessionIDLen]
|
||||
}
|
||||
|
||||
// CipherSuites returns the cipher_suites vector with its length prefix
|
||||
// stripped, ready for [ForEachU16]. The list includes GREASE values.
|
||||
func (ch ClientHelloFrame) CipherSuites() []byte {
|
||||
return ch.buf[ch.suitesOff : ch.suitesOff+ch.suitesLen]
|
||||
}
|
||||
|
||||
// LegacyCompressionMethods returns the legacy_compression_methods vector
|
||||
// contents. For a TLS 1.3 hello this must be exactly one zero byte; see
|
||||
// [ClientHelloFrame.ValidateCompression].
|
||||
func (ch ClientHelloFrame) LegacyCompressionMethods() []byte {
|
||||
return ch.buf[ch.compOff : ch.compOff+ch.compLen]
|
||||
}
|
||||
|
||||
// Extensions returns the extensions block contents with the outer length
|
||||
// prefix stripped, ready for [ForEachExtension].
|
||||
func (ch ClientHelloFrame) Extensions() []byte {
|
||||
return ch.buf[ch.extsOff : ch.extsOff+ch.extsLen]
|
||||
}
|
||||
|
||||
// RawData returns the whole ClientHello body.
|
||||
func (ch ClientHelloFrame) RawData() []byte { return ch.buf }
|
||||
|
||||
// ForEachExtension walks this hello's extensions, rejecting a repeated known
|
||||
// extension type with [lneto.ErrInvalidField]. RFC 8446 4.2 forbids duplicates,
|
||||
// and tolerating them invites parser-differential attacks in which this parser
|
||||
// and a middlebox act on different copies of the same extension.
|
||||
//
|
||||
// Unknown and GREASE extension types are passed through without duplicate
|
||||
// checking, since they are skipped rather than acted upon. Prefer this over
|
||||
// the bare [ForEachExtension] when parsing an untrusted hello.
|
||||
func (ch ClientHelloFrame) ForEachExtension(fn func(ExtensionType, []byte) error) error {
|
||||
var seen extSeen
|
||||
return ForEachExtension(ch.Extensions(), func(ext ExtensionType, data []byte) error {
|
||||
if seen.mark(ext) {
|
||||
return lneto.ErrInvalidField
|
||||
}
|
||||
return fn(ext, data)
|
||||
})
|
||||
}
|
||||
|
||||
// ValidateSize adds an error to v if the ClientHello is structurally invalid.
|
||||
// Since [NewClientHelloFrame] already rejects every inconsistent length, this
|
||||
// only re-checks that the frame was successfully constructed.
|
||||
func (ch ClientHelloFrame) ValidateSize(v *lneto.Validator) {
|
||||
if ch.buf == nil {
|
||||
v.AddError(lneto.ErrTruncatedFrame)
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateCompression reports whether legacy_compression_methods is exactly
|
||||
// the single null method TLS 1.3 mandates (RFC 8446 4.1.2). Anything else must
|
||||
// be rejected with an illegal_parameter alert: a client offering real
|
||||
// compression methods is either pre-1.3 or attempting a downgrade.
|
||||
func (ch ClientHelloFrame) ValidateCompression() bool {
|
||||
return ch.compLen == 1 && ch.buf[ch.compOff] == 0
|
||||
}
|
||||
|
||||
// extSeen tracks which known extension types have already been encountered in
|
||||
// a single hello. RFC 8446 4.2 forbids a duplicate extension type, and
|
||||
// tolerating duplicates invites parser-differential attacks where this parser
|
||||
// and a middlebox act on different copies.
|
||||
//
|
||||
// Only known types are tracked; unknown and GREASE types are exempt because
|
||||
// they are skipped without being acted upon.
|
||||
type extSeen uint64
|
||||
|
||||
// mark records ext and reports whether it had already been seen. Extension
|
||||
// types with no assigned bit are never reported as duplicates.
|
||||
func (s *extSeen) mark(ext ExtensionType) (duplicate bool) {
|
||||
bit, ok := extBit(ext)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if *s&bit != 0 {
|
||||
return true
|
||||
}
|
||||
*s |= bit
|
||||
return false
|
||||
}
|
||||
|
||||
// extBit maps an extension type to its bit in [extSeen]. The mapping covers
|
||||
// every extension this server reads or must reject a duplicate of.
|
||||
func extBit(ext ExtensionType) (extSeen, bool) {
|
||||
var i uint
|
||||
switch ext {
|
||||
case ExtServerName:
|
||||
i = 0
|
||||
case ExtMaxFragmentLength:
|
||||
i = 1
|
||||
case ExtStatusRequest:
|
||||
i = 2
|
||||
case ExtSupportedGroups:
|
||||
i = 3
|
||||
case ExtSignatureAlgorithms:
|
||||
i = 4
|
||||
case ExtALPN:
|
||||
i = 5
|
||||
case ExtSignedCertificateTimestamp:
|
||||
i = 6
|
||||
case ExtPadding:
|
||||
i = 7
|
||||
case ExtExtendedMasterSecret:
|
||||
i = 8
|
||||
case ExtCompressCertificate:
|
||||
i = 9
|
||||
case ExtRecordSizeLimit:
|
||||
i = 10
|
||||
case ExtSessionTicket:
|
||||
i = 11
|
||||
case ExtPreSharedKey:
|
||||
i = 12
|
||||
case ExtEarlyData:
|
||||
i = 13
|
||||
case ExtSupportedVersions:
|
||||
i = 14
|
||||
case ExtCookie:
|
||||
i = 15
|
||||
case ExtPSKKeyExchangeModes:
|
||||
i = 16
|
||||
case ExtCertificateAuthorities:
|
||||
i = 17
|
||||
case ExtSignatureAlgorithmsCert:
|
||||
i = 18
|
||||
case ExtKeyShare:
|
||||
i = 19
|
||||
case ExtApplicationSettings:
|
||||
i = 20
|
||||
case ExtEncryptedClientHello:
|
||||
i = 21
|
||||
case ExtRenegotiationInfo:
|
||||
i = 22
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
return 1 << i, true
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package tls_test
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
ltls "github.com/soypat/lneto/x/tls"
|
||||
)
|
||||
|
||||
// captureClientHello drives a standard library TLS client far enough to emit
|
||||
// its first flight and returns the ClientHello handshake message body.
|
||||
//
|
||||
// Using a real client rather than a hand-written fixture means the parser is
|
||||
// exercised against genuine extension ordering, a 32-byte compatibility
|
||||
// session ID and GREASE-free but otherwise realistic content. Browser captures
|
||||
// arrive at Stage 6; this covers the structure in the meantime.
|
||||
func captureClientHello(t *testing.T) ltls.ClientHelloFrame {
|
||||
t.Helper()
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
go func() {
|
||||
c := tls.Client(client, &tls.Config{
|
||||
ServerName: "example.com",
|
||||
MinVersion: tls.VersionTLS13,
|
||||
MaxVersion: tls.VersionTLS13,
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
})
|
||||
_ = c.Handshake() // will fail; we only need the first flight
|
||||
}()
|
||||
|
||||
server.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
var buf [4096]byte
|
||||
n, err := server.Read(buf[:])
|
||||
if err != nil {
|
||||
t.Fatalf("reading ClientHello: %v", err)
|
||||
}
|
||||
rec, err := ltls.NewRecordFrame(buf[:n])
|
||||
if err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
if rec.ContentType() != ltls.ContentTypeHandshake {
|
||||
t.Fatalf("first record is %v, want handshake", rec.ContentType())
|
||||
}
|
||||
if !rec.Complete() {
|
||||
t.Fatalf("ClientHello record split across reads: have %d want %d",
|
||||
n, rec.RecordLength())
|
||||
}
|
||||
hs, err := ltls.NewHandshakeFrame(rec.Payload())
|
||||
if err != nil {
|
||||
t.Fatalf("handshake: %v", err)
|
||||
}
|
||||
if hs.MsgType() != ltls.HandshakeTypeClientHello {
|
||||
t.Fatalf("first message is %v, want client_hello", hs.MsgType())
|
||||
}
|
||||
if !hs.Complete() {
|
||||
t.Fatal("ClientHello spans multiple records")
|
||||
}
|
||||
ch, err := ltls.NewClientHelloFrame(hs.Body())
|
||||
if err != nil {
|
||||
t.Fatalf("client hello: %v", err)
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
func TestClientHelloParseRealHello(t *testing.T) {
|
||||
ch := captureClientHello(t)
|
||||
|
||||
if ch.LegacyVersion() != ltls.VersionTLS12 {
|
||||
t.Errorf("legacy_version %#04x want 0x0303", ch.LegacyVersion())
|
||||
}
|
||||
if !ch.ValidateCompression() {
|
||||
t.Errorf("legacy_compression_methods % x, want exactly {0}",
|
||||
ch.LegacyCompressionMethods())
|
||||
}
|
||||
if n := len(ch.LegacySessionID()); n != 32 {
|
||||
// A TLS 1.3 client sends a fake 32-byte session ID to trigger
|
||||
// middlebox compatibility mode. The server must echo it.
|
||||
t.Errorf("session id len %d, want 32 for middlebox compat", n)
|
||||
}
|
||||
|
||||
var sawTLS13, sawX25519, sawSNI, sawALPN bool
|
||||
var suites []ltls.CipherSuite
|
||||
err := ch.ForEachExtension(func(ext ltls.ExtensionType, data []byte) error {
|
||||
switch ext {
|
||||
case ltls.ExtSupportedVersions:
|
||||
return ltls.ForEachSupportedVersion(data, func(v uint16) error {
|
||||
if v == ltls.VersionTLS13 {
|
||||
sawTLS13 = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
case ltls.ExtKeyShare:
|
||||
return ltls.ForEachKeyShare(data, func(g ltls.NamedGroup, key []byte) error {
|
||||
if g == ltls.GroupX25519 && len(key) == 32 {
|
||||
sawX25519 = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
case ltls.ExtServerName:
|
||||
return ltls.ForEachServerName(data, func(nameType uint8, name []byte) error {
|
||||
if nameType == 0 && string(name) == "example.com" {
|
||||
sawSNI = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
case ltls.ExtALPN:
|
||||
return ltls.ForEachALPNProto(data, func(p []byte) error {
|
||||
if string(p) == "http/1.1" {
|
||||
sawALPN = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walking extensions: %v", err)
|
||||
}
|
||||
|
||||
if err := ltls.ForEachU16(ch.CipherSuites(), func(v uint16) error {
|
||||
suites = append(suites, ltls.CipherSuite(v))
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("walking cipher suites: %v", err)
|
||||
}
|
||||
|
||||
if !sawTLS13 {
|
||||
t.Error("supported_versions did not offer TLS 1.3")
|
||||
}
|
||||
if !sawX25519 {
|
||||
t.Error("no 32-byte x25519 key share found")
|
||||
}
|
||||
if !sawSNI {
|
||||
t.Error("SNI host not recovered")
|
||||
}
|
||||
if !sawALPN {
|
||||
t.Error("ALPN http/1.1 not recovered")
|
||||
}
|
||||
var mandatory bool
|
||||
for _, s := range suites {
|
||||
if s == ltls.SuiteAES128GCMSHA256 {
|
||||
mandatory = true
|
||||
}
|
||||
}
|
||||
if !mandatory {
|
||||
t.Errorf("TLS_AES_128_GCM_SHA256 not offered; got %v", suites)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientHelloRejectsDuplicateExtension(t *testing.T) {
|
||||
// Duplicating supported_versions must be caught. Tolerating it lets this
|
||||
// parser and a middlebox act on different copies.
|
||||
ch := captureClientHello(t)
|
||||
exts := ch.Extensions()
|
||||
|
||||
// Find the first extension and append a verbatim copy of it.
|
||||
var first []byte
|
||||
err := ltls.ForEachExtension(exts, func(ext ltls.ExtensionType, data []byte) error {
|
||||
if first == nil {
|
||||
first = make([]byte, 4+len(data))
|
||||
copy(first, exts)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil || first == nil {
|
||||
t.Fatalf("could not isolate first extension: %v", err)
|
||||
}
|
||||
|
||||
dup := make([]byte, 0, len(exts)+len(first))
|
||||
dup = append(dup, exts...)
|
||||
dup = append(dup, first...)
|
||||
|
||||
body := rebuildHelloWithExtensions(t, ch, dup)
|
||||
ch2, err := ltls.NewClientHelloFrame(body)
|
||||
if err != nil {
|
||||
t.Fatalf("rebuilt hello did not parse: %v", err)
|
||||
}
|
||||
err = ch2.ForEachExtension(func(ltls.ExtensionType, []byte) error { return nil })
|
||||
if !errors.Is(err, lneto.ErrInvalidField) {
|
||||
t.Errorf("duplicate extension got %v, want ErrInvalidField", err)
|
||||
}
|
||||
}
|
||||
|
||||
// rebuildHelloWithExtensions re-encodes ch with a replacement extensions block,
|
||||
// exercising Builder against a structure produced by a real client.
|
||||
func rebuildHelloWithExtensions(t *testing.T, ch ltls.ClientHelloFrame, exts []byte) []byte {
|
||||
t.Helper()
|
||||
var b ltls.Builder
|
||||
b.Reset(make([]byte, 0, len(ch.RawData())+len(exts)+64))
|
||||
b.AddU16(ch.LegacyVersion())
|
||||
b.AddBytes(ch.Random()[:])
|
||||
b.OpenU8()
|
||||
b.AddBytes(ch.LegacySessionID())
|
||||
b.Close()
|
||||
b.OpenU16()
|
||||
b.AddBytes(ch.CipherSuites())
|
||||
b.Close()
|
||||
b.OpenU8()
|
||||
b.AddBytes(ch.LegacyCompressionMethods())
|
||||
b.Close()
|
||||
b.OpenU16()
|
||||
b.AddBytes(exts)
|
||||
b.Close()
|
||||
out, err := b.Bytes()
|
||||
if err != nil {
|
||||
t.Fatalf("rebuilding hello: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestClientHelloRejectsOversizeSessionID(t *testing.T) {
|
||||
// legacy_session_id feeds a fixed [32]byte echo buffer in the server, so
|
||||
// the bound must be enforced at parse time.
|
||||
body := make([]byte, 0, 128)
|
||||
body = append(body, 0x03, 0x03)
|
||||
body = append(body, make([]byte, ltls.SizeRandom)...)
|
||||
body = append(body, 33) // session id length, one over
|
||||
body = append(body, make([]byte, 33)...) //
|
||||
body = append(body, 0x00, 0x02, 0x13, 0x01) // cipher suites
|
||||
body = append(body, 0x01, 0x00) // compression
|
||||
body = append(body, 0x00, 0x00) // extensions, empty
|
||||
_, err := ltls.NewClientHelloFrame(body)
|
||||
if !errors.Is(err, lneto.ErrInvalidLengthField) {
|
||||
t.Errorf("got %v want ErrInvalidLengthField", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientHelloRejectsTrailingBytes(t *testing.T) {
|
||||
ch := captureClientHello(t)
|
||||
body := append(append([]byte{}, ch.RawData()...), 0xff)
|
||||
if _, err := ltls.NewClientHelloFrame(body); err == nil {
|
||||
t.Error("trailing byte after extensions block accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientHelloTruncatedAtEveryOffset(t *testing.T) {
|
||||
// Truncating a valid hello anywhere must produce an error, never a panic
|
||||
// and never a frame whose accessors read out of bounds.
|
||||
ch := captureClientHello(t)
|
||||
full := ch.RawData()
|
||||
for n := range len(full) {
|
||||
frame, err := ltls.NewClientHelloFrame(full[:n])
|
||||
if err == nil {
|
||||
// A shorter prefix must never parse as a complete hello.
|
||||
t.Errorf("truncation to %d/%d bytes parsed clean", n, len(full))
|
||||
_ = frame.Extensions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzNewClientHelloFrame(f *testing.F) {
|
||||
f.Add([]byte{
|
||||
0x03, 0x03,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0x00, // no session id
|
||||
0x00, 0x02, 0x13, 0x01, // one cipher suite
|
||||
0x01, 0x00, // null compression
|
||||
0x00, 0x00, // no extensions
|
||||
})
|
||||
f.Fuzz(func(t *testing.T, b []byte) {
|
||||
ch, err := ltls.NewClientHelloFrame(b)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Every accessor must stay inside the input.
|
||||
if len(ch.LegacySessionID()) > ltls.MaxSessionIDLen {
|
||||
t.Fatalf("session id %d bytes exceeds max", len(ch.LegacySessionID()))
|
||||
}
|
||||
if len(ch.CipherSuites())%2 != 0 {
|
||||
t.Fatal("cipher suites vector has odd length")
|
||||
}
|
||||
total := 2 + ltls.SizeRandom + 1 + len(ch.LegacySessionID()) +
|
||||
2 + len(ch.CipherSuites()) +
|
||||
1 + len(ch.LegacyCompressionMethods()) +
|
||||
2 + len(ch.Extensions())
|
||||
if total != len(b) {
|
||||
t.Fatalf("fields sum to %d but input is %d bytes", total, len(b))
|
||||
}
|
||||
_ = ch.ValidateCompression()
|
||||
_ = ch.ForEachExtension(func(ltls.ExtensionType, []byte) error { return nil })
|
||||
})
|
||||
}
|
||||
+18
-13
@@ -103,6 +103,8 @@ type StackConfig struct {
|
||||
MTU uint16
|
||||
// Accept multicast ethernet and IP packets. Needed for MDNS.
|
||||
AcceptMulticast bool
|
||||
// Accept broadcast IPv4 packets. Needed for managing access points and DHCPv4 servers.
|
||||
AcceptIPv4Broadcast bool
|
||||
}
|
||||
|
||||
func (cfg *StackConfig) id() uint16 {
|
||||
@@ -163,9 +165,14 @@ func (s *StackAsync) IngressIP(ipFrame []byte) error {
|
||||
func (s *StackAsync) EgressIP(dstIPFrame []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(dstIPFrame) < s.link.MTU() {
|
||||
mtu := s.link.MTU()
|
||||
if len(dstIPFrame) < mtu {
|
||||
return 0, lneto.ErrShortBuffer
|
||||
}
|
||||
// Clip to MTU so downstream layers cannot emit an IP datagram larger than the
|
||||
// link MTU (mirrors StackEthernet.Encapsulate). This also bounds the TCP frame
|
||||
// budget, so the advertised MSS becomes MTU-ipHdr-20 instead of the buffer size.
|
||||
dstIPFrame = dstIPFrame[:mtu]
|
||||
n, err := s.ip4.Encapsulate(dstIPFrame, 0, 0)
|
||||
if s.ipv6enabled && n == 0 {
|
||||
n, err = s.stack6.EgressIPv6(dstIPFrame)
|
||||
@@ -234,7 +241,7 @@ func (s *StackAsync) Reset(cfg StackConfig) (err error) {
|
||||
}
|
||||
s.ip4.SetAddr4(cfg.StaticAddress4)
|
||||
s.setAcceptMulticast4(cfg.AcceptMulticast)
|
||||
|
||||
s.ip4.SetAcceptBroadcast4(cfg.AcceptIPv4Broadcast)
|
||||
s.arpt.passivePeers = uint8(cfg.PassivePeers)
|
||||
err = s.resetARP()
|
||||
if err != nil {
|
||||
@@ -543,8 +550,12 @@ func (s *StackAsync) RegisterUDP4(node lneto.StackNode, remoteAddr [4]byte, remo
|
||||
if idx >= cap(s.userUDPs) {
|
||||
return lneto.ErrExhausted
|
||||
}
|
||||
raddr := remoteAddr[:]
|
||||
if remoteAddr == [4]byte{} {
|
||||
raddr = nil
|
||||
}
|
||||
s.userUDPs = s.userUDPs[:idx+1]
|
||||
s.userUDPs[idx].SetStackNode(node, remoteAddr[:], remotePort)
|
||||
s.userUDPs[idx].SetStackNode(node, raddr, remotePort)
|
||||
return s.udps.RegisterMACFiltered(&s.userUDPs[idx], nil)
|
||||
}
|
||||
|
||||
@@ -831,24 +842,18 @@ func addr4(addr [4]byte, ok bool) netip.Addr {
|
||||
return netip.AddrFrom4(addr)
|
||||
}
|
||||
|
||||
// Debug prints debugging information. Very useful for users when coupled with
|
||||
// the debugheaplog build tag. See [internal.LogAttrs] debugheaplog version.
|
||||
//
|
||||
// go build -tags=debugheaplog ./yourprogram
|
||||
// Debug prints debugging and heap information.
|
||||
func (s *StackAsync) Debug(msg string) {
|
||||
internal.LogAttrs(slog.Default(), slog.LevelDebug, "stackasync",
|
||||
internal.LogAttrsAndAllocs(msg, slog.Default(), slog.LevelDebug, "stackasync",
|
||||
slog.String("umsg", msg),
|
||||
slog.Uint64("sent", s.stats.TotalSent),
|
||||
slog.Uint64("recv", s.stats.TotalReceived),
|
||||
)
|
||||
}
|
||||
|
||||
// DebugErr prints debugging and error info. Very useful for users when coupled with
|
||||
// the debugheaplog build tag. See [internal.LogAttrs] debugheaplog version.
|
||||
//
|
||||
// go build -tags=debugheaplog ./yourprogram
|
||||
// DebugErr prints debugging and heap information.
|
||||
func (s *StackAsync) DebugErr(msg, err string) {
|
||||
internal.LogAttrs(slog.Default(), slog.LevelError, "stackasync",
|
||||
internal.LogAttrsAndAllocs(msg, slog.Default(), slog.LevelError, "stackasync",
|
||||
slog.String("umsg", msg),
|
||||
slog.String("err", err),
|
||||
slog.Uint64("sent", s.stats.TotalSent),
|
||||
|
||||
+17
-20
@@ -9,22 +9,19 @@ import (
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
)
|
||||
|
||||
const (
|
||||
_AF_INET = 0x2
|
||||
_SOCK_STREAM = 0x1
|
||||
_SOCK_DGRAM = 0x2
|
||||
_SOL_SOCKET = 0x1
|
||||
_SO_KEEPALIVE = 0x9
|
||||
_SOL_TCP = 0x6
|
||||
_TCP_KEEPINTVL = 0x5
|
||||
_IPPROTO_TCP = 0x6
|
||||
_IPPROTO_UDP = 0x11
|
||||
AF_INET = 0x2
|
||||
AF_INET6 = 0xa
|
||||
SOCK_STREAM = 0x1
|
||||
SOCK_DGRAM = 0x2
|
||||
|
||||
// Made up, not a real IP protocol number. This is used to create a
|
||||
// TLS socket on the device, assuming the device supports mbed TLS.
|
||||
_IPPROTO_TLS = 0xFE
|
||||
_F_SETFL = 0x4
|
||||
_IPPROTO_TLS = 0xFE // TODO: is this a good idea?
|
||||
)
|
||||
|
||||
// gosocket is the stack abstraction for the baremetal proposal.
|
||||
@@ -92,7 +89,7 @@ func (s *StackBerkeley) SetSockOpt(sockfd int, level int, opt int, value any) er
|
||||
// domain must be AF_INET. stype must be SOCK_STREAM or SOCK_DGRAM.
|
||||
// protocol must be IPPROTO_TCP, IPPROTO_UDP, or IPPROTO_TLS.
|
||||
func (s *StackBerkeley) Socket(domain int, stype int, protocol int) (sockfd int, _ error) {
|
||||
if domain != _AF_INET {
|
||||
if domain != AF_INET {
|
||||
return -1, fmt.Errorf("unsupported domain %d", domain)
|
||||
}
|
||||
s.mu.Lock()
|
||||
@@ -117,23 +114,23 @@ func (s *StackBerkeley) Connect(sockfd int, host string, ip netip.AddrPort) erro
|
||||
var raddr net.Addr
|
||||
var network string
|
||||
var family, sotype int
|
||||
switch pending.sock.protocol {
|
||||
case _IPPROTO_TCP, _IPPROTO_TLS:
|
||||
switch lneto.IPProto(pending.sock.protocol) {
|
||||
case lneto.IPProtoTCP, _IPPROTO_TLS:
|
||||
if pending.sock.boundAddr.IsValid() && pending.sock.boundAddr.Port() > 0 {
|
||||
laddr = &net.TCPAddr{IP: pending.sock.boundAddr.Addr().AsSlice(), Port: int(pending.sock.boundAddr.Port())}
|
||||
}
|
||||
raddr = &net.TCPAddr{IP: ip.Addr().AsSlice(), Port: int(ip.Port())}
|
||||
network = "tcp4"
|
||||
family = _AF_INET
|
||||
sotype = _SOCK_STREAM
|
||||
case _IPPROTO_UDP:
|
||||
family = AF_INET
|
||||
sotype = SOCK_STREAM
|
||||
case lneto.IPProtoUDP:
|
||||
if pending.sock.boundAddr.IsValid() && pending.sock.boundAddr.Port() > 0 {
|
||||
laddr = &net.UDPAddr{IP: pending.sock.boundAddr.Addr().AsSlice(), Port: int(pending.sock.boundAddr.Port())}
|
||||
}
|
||||
raddr = &net.UDPAddr{IP: ip.Addr().AsSlice(), Port: int(ip.Port())}
|
||||
network = "udp4"
|
||||
family = _AF_INET
|
||||
sotype = _SOCK_DGRAM
|
||||
family = AF_INET
|
||||
sotype = SOCK_DGRAM
|
||||
default:
|
||||
return fmt.Errorf("Connect: unsupported protocol %d", pending.sock.protocol)
|
||||
}
|
||||
@@ -166,7 +163,7 @@ func (s *StackBerkeley) Listen(sockfd int, backlog int) error {
|
||||
if pending.sock.boundAddr.IsValid() && pending.sock.boundAddr.Port() > 0 {
|
||||
laddr = &net.TCPAddr{IP: pending.sock.boundAddr.Addr().AsSlice(), Port: int(pending.sock.boundAddr.Port())}
|
||||
}
|
||||
c, err := s.gosocket(context.Background(), "tcp4", _AF_INET, _SOCK_STREAM, laddr, nil)
|
||||
c, err := s.gosocket(context.Background(), "tcp4", AF_INET, SOCK_STREAM, laddr, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+29
-11
@@ -31,8 +31,20 @@ func (s *StackAsync) StackBlocking(stackProtoBackoff lneto.BackoffStrategy) Stac
|
||||
}
|
||||
|
||||
type StackBlocking struct {
|
||||
async *StackAsync
|
||||
_backoff lneto.BackoffStrategy
|
||||
async *StackAsync
|
||||
_backoff lneto.BackoffStrategy
|
||||
_nanotime func() int64
|
||||
}
|
||||
|
||||
func (s StackBlocking) nanotime() int64 {
|
||||
if s._nanotime != nil {
|
||||
return s._nanotime()
|
||||
}
|
||||
return time.Now().UnixNano()
|
||||
}
|
||||
|
||||
func (s StackBlocking) deadlineTO(timeout time.Duration) int64 {
|
||||
return int64(timeout) + s.nanotime()
|
||||
}
|
||||
|
||||
func (s StackBlocking) DoDHCPv4(reqAddr [4]byte, timeout time.Duration) (*DHCPResults, error) {
|
||||
@@ -41,7 +53,7 @@ func (s StackBlocking) DoDHCPv4(reqAddr [4]byte, timeout time.Duration) (*DHCPRe
|
||||
return nil, err
|
||||
}
|
||||
var backoffs uint
|
||||
deadline := time.Now().Add(timeout)
|
||||
deadline := s.deadlineTO(timeout)
|
||||
requested := false
|
||||
var lastState dhcpv4.ClientState
|
||||
for range maxIter {
|
||||
@@ -108,7 +120,7 @@ func (s StackBlocking) DoNTP(hostAddr netip.Addr, timeout time.Duration) (offset
|
||||
return -1, err
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
deadline := s.deadlineTO(timeout)
|
||||
var done bool
|
||||
var backoffs uint
|
||||
for range maxIter {
|
||||
@@ -130,7 +142,7 @@ func (s StackBlocking) DoResolveHardwareAddress6(addr netip.Addr, timeout time.D
|
||||
return hw, err
|
||||
}
|
||||
var backoffs uint
|
||||
deadline := time.Now().Add(timeout)
|
||||
deadline := s.deadlineTO(timeout)
|
||||
for range maxIter {
|
||||
hw, err = s.async.ResultResolveHardwareAddress6(addr)
|
||||
if err == nil {
|
||||
@@ -159,7 +171,7 @@ func (s StackBlocking) DoLookupIPType(host string, timeout time.Duration, qtype
|
||||
return nil, err
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
deadline := s.deadlineTO(timeout)
|
||||
var backoffs uint
|
||||
for range maxIter {
|
||||
addrs, completed, err := s.async.ResultLookupIP(host)
|
||||
@@ -181,7 +193,15 @@ func (s StackBlocking) DoDialTCP(conn *tcp.Conn, localPort uint16, addrp netip.A
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
err = s.waitDialTCP(conn, timeout)
|
||||
if err != nil {
|
||||
conn.Abort()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s StackBlocking) waitDialTCP(conn *tcp.Conn, timeout time.Duration) (err error) {
|
||||
deadline := s.deadlineTO(timeout)
|
||||
var backoffs uint
|
||||
for range maxIter {
|
||||
state := conn.State()
|
||||
@@ -189,12 +209,10 @@ func (s StackBlocking) DoDialTCP(conn *tcp.Conn, localPort uint16, addrp netip.A
|
||||
return nil
|
||||
} else if state == tcp.StateSynSent || state == tcp.StateSynRcvd || conn.AwaitingSynSend() {
|
||||
if err = s.checkDeadline(deadline); err != nil {
|
||||
conn.Abort()
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Unexpected state, abort and terminate connection.
|
||||
conn.Abort()
|
||||
return errTCPFailedToConnect
|
||||
}
|
||||
s.backoff(backoffs)
|
||||
@@ -203,8 +221,8 @@ func (s StackBlocking) DoDialTCP(conn *tcp.Conn, localPort uint16, addrp netip.A
|
||||
return errDeadlineExceed
|
||||
}
|
||||
|
||||
func (s StackBlocking) checkDeadline(deadline time.Time) error {
|
||||
if time.Since(deadline) > 0 {
|
||||
func (s StackBlocking) checkDeadline(deadline int64) error {
|
||||
if s.nanotime() > deadline {
|
||||
return errDeadlineExceed
|
||||
}
|
||||
return nil
|
||||
|
||||
+21
-5
@@ -18,10 +18,15 @@ import (
|
||||
const (
|
||||
sockSTREAM = 0x1
|
||||
sockDGRAM = 0x2
|
||||
|
||||
defaultTCPDialTimeout = 2 * time.Second
|
||||
defaultTCPDialRetries = 1
|
||||
)
|
||||
|
||||
type StackGoConfig struct {
|
||||
ListenerPoolConfig TCPPoolConfig
|
||||
TCPDialTimeout time.Duration
|
||||
TCPDialRetries int
|
||||
}
|
||||
|
||||
func (s *StackAsync) StackGo(stackProtoBackoff lneto.BackoffStrategy, cfg StackGoConfig) StackGo {
|
||||
@@ -32,16 +37,27 @@ func (s *StackAsync) StackGo(stackProtoBackoff lneto.BackoffStrategy, cfg StackG
|
||||
}
|
||||
|
||||
func (s StackBlocking) StackGo(cfg StackGoConfig) StackGo {
|
||||
if cfg.TCPDialRetries <= 0 {
|
||||
cfg.TCPDialRetries = defaultTCPDialRetries
|
||||
}
|
||||
if cfg.TCPDialTimeout <= 0 {
|
||||
cfg.TCPDialTimeout = defaultTCPDialTimeout
|
||||
}
|
||||
|
||||
sg := StackGo{
|
||||
blk: s,
|
||||
plcfg: cfg.ListenerPoolConfig,
|
||||
blk: s,
|
||||
plcfg: cfg.ListenerPoolConfig,
|
||||
tcpDialTimeout: cfg.TCPDialTimeout,
|
||||
tcpDialRetries: cfg.TCPDialRetries,
|
||||
}
|
||||
return sg
|
||||
}
|
||||
|
||||
type StackGo struct {
|
||||
blk StackBlocking
|
||||
plcfg TCPPoolConfig
|
||||
blk StackBlocking
|
||||
plcfg TCPPoolConfig
|
||||
tcpDialTimeout time.Duration
|
||||
tcpDialRetries int
|
||||
}
|
||||
|
||||
func (s StackGo) Socket(ctx context.Context, network string, family, sotype int, laddr, raddr net.Addr) (c any, err error) {
|
||||
@@ -171,7 +187,7 @@ func (s StackGo) SocketNetip(ctx context.Context, network string, family, sotype
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = s.blk.async.DialTCP(&conn, laddr.Port(), raddr)
|
||||
err = s.blk.StackRetrying().DoDialTCP(&conn, laddr.Port(), raddr, s.tcpDialTimeout, s.tcpDialRetries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ func (s *StackAsync) StackRetrying(stackProtoBackoff lneto.BackoffStrategy) Stac
|
||||
if stackProtoBackoff == nil {
|
||||
panic("nil backoff to StackRetrying")
|
||||
}
|
||||
return StackRetrying{
|
||||
block: s.StackBlocking(stackProtoBackoff),
|
||||
}
|
||||
return s.StackBlocking(stackProtoBackoff).StackRetrying()
|
||||
}
|
||||
|
||||
func (s StackBlocking) StackRetrying() StackRetrying {
|
||||
return StackRetrying{block: s}
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -93,13 +95,28 @@ func (s StackRetrying) DoResolveHardwareAddress6(addr netip.Addr, timeout time.D
|
||||
func (s StackRetrying) DoDialTCP(conn *tcp.Conn, localPort uint16, addrp netip.AddrPort, timeout time.Duration, retries int) (err error) {
|
||||
expectEnd := time.Now().Add(timeout * time.Duration(retries))
|
||||
var firstErr error
|
||||
for range retries {
|
||||
err = s.block.DoDialTCP(conn, localPort, addrp, timeout)
|
||||
for i := range retries {
|
||||
if i == 0 || conn.State().IsClosed() {
|
||||
err = s.block.async.DialTCP(conn, localPort, addrp)
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
} else if conn.IsAwaitingControl() {
|
||||
conn.RequeueControl()
|
||||
}
|
||||
|
||||
err = s.block.waitDialTCP(conn, timeout)
|
||||
if err == nil {
|
||||
return nil
|
||||
} else if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
if !conn.IsAwaitingControl() {
|
||||
conn.Abort()
|
||||
}
|
||||
}
|
||||
if time.Now().Before(expectEnd) {
|
||||
if err != firstErr {
|
||||
|
||||
@@ -41,7 +41,7 @@ func BenchmarkARPExchange(b *testing.B) {
|
||||
var buf [frameSize]byte
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
for b.Loop() {
|
||||
err = c1.StartResolveHardwareAddress6(queryAddr)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
@@ -130,7 +130,7 @@ func BenchmarkTCPHandshake(b *testing.B) {
|
||||
var pktbuf [frameSize]byte
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
for b.Loop() {
|
||||
// Setup connections.
|
||||
err = sv.ListenTCP4(svconn, svPort)
|
||||
if err != nil {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/soypat/lneto"
|
||||
"github.com/soypat/lneto/ethernet"
|
||||
"github.com/soypat/lneto/internal"
|
||||
"github.com/soypat/lneto/internal/ltesto"
|
||||
"github.com/soypat/lneto/ipv4"
|
||||
"github.com/soypat/lneto/tcp"
|
||||
)
|
||||
@@ -120,7 +121,7 @@ func TestTCPListener_ConcurrentEcho(t *testing.T) {
|
||||
wg.Add(1)
|
||||
go func(clientID int) {
|
||||
defer wg.Done()
|
||||
if runClient(t, clientID, &clientStacks[clientID], &clientConns[clientID],
|
||||
if runClient(t, ctx, clientID, &clientStacks[clientID], &clientConns[clientID],
|
||||
serverIP, serverPort) {
|
||||
clientSuccess[clientID] = true
|
||||
}
|
||||
@@ -206,7 +207,7 @@ func echoServer(ctx context.Context, listener *tcp.Listener) {
|
||||
}
|
||||
|
||||
if listener.NumberOfReadyToAccept() == 0 {
|
||||
time.Sleep(time.Millisecond)
|
||||
runtime.Gosched()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -240,7 +241,7 @@ func echoServer(ctx context.Context, listener *tcp.Listener) {
|
||||
}
|
||||
}
|
||||
|
||||
func runClient(t *testing.T, id int, stack *StackAsync, conn *tcp.Conn,
|
||||
func runClient(t *testing.T, ctx context.Context, id int, stack *StackAsync, conn *tcp.Conn,
|
||||
serverAddr netip.Addr, serverPort uint16) bool {
|
||||
// Dial server.
|
||||
clientPort := uint16(10000 + id)
|
||||
@@ -250,14 +251,8 @@ func runClient(t *testing.T, id int, stack *StackAsync, conn *tcp.Conn,
|
||||
return false
|
||||
}
|
||||
|
||||
// Wait for connection established (handshake via kernel loop).
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for conn.State() != tcp.StateEstablished {
|
||||
if time.Now().After(deadline) {
|
||||
t.Errorf("client %d: timeout waiting for established state, got %s", id, conn.State())
|
||||
return false
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
for conn.State() != tcp.StateEstablished && ctx.Err() == nil {
|
||||
runtime.Gosched()
|
||||
}
|
||||
|
||||
// Send test data.
|
||||
@@ -268,15 +263,11 @@ func runClient(t *testing.T, id int, stack *StackAsync, conn *tcp.Conn,
|
||||
return false
|
||||
}
|
||||
|
||||
// Read echo response.
|
||||
// Read echo response. conn.Read yields (backoffYield) until data arrives, and the
|
||||
// overall test timeout (ctx) backstops a hang.
|
||||
var buf [64]byte
|
||||
deadline = time.Now().Add(5 * time.Second)
|
||||
var totalRead int
|
||||
for totalRead < len(testData) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Errorf("client %d: timeout waiting for echo response, got %d/%d bytes", id, totalRead, len(testData))
|
||||
return false
|
||||
}
|
||||
for totalRead < len(testData) && ctx.Err() == nil {
|
||||
n, err := conn.Read(buf[totalRead:])
|
||||
if err != nil {
|
||||
t.Errorf("client %d read failed: %v", id, err)
|
||||
@@ -325,11 +316,24 @@ func testCloseTransmitsPending(tst *tester, s1, s2 *StackAsync, c1, c2 *tcp.Conn
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug - 99,
|
||||
}))
|
||||
// When the payload exceeds the Tx buffer, c1.Write must run in a background
|
||||
// goroutine that blocks until the driver drains the buffer. The scheduler turns
|
||||
// that blocking into a deterministic, sleep-free handoff: c1's backoff parks the
|
||||
// writer and the driver releases it after freeing buffer space.
|
||||
async := datalen > tx1Buf
|
||||
var tsched *ltesto.Sched
|
||||
var tgoro ltesto.SchedGoro
|
||||
c1Backoff := backoffYield
|
||||
if async {
|
||||
tsched = ltesto.NewSched(t)
|
||||
tgoro = tsched.Goro()
|
||||
c1Backoff = tgoro.Yield
|
||||
}
|
||||
err := c1.Configure(tcp.ConnConfig{
|
||||
RxBuf: nil,
|
||||
TxBuf: make([]byte, tx1Buf),
|
||||
TxPacketQueueSize: queueSize,
|
||||
RWBackoff: backoffYield,
|
||||
RWBackoff: c1Backoff,
|
||||
Logger: logger,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -350,29 +354,19 @@ func testCloseTransmitsPending(tst *tester, s1, s2 *StackAsync, c1, c2 *tcp.Conn
|
||||
for i := range datalen {
|
||||
data[i] = byte(i)
|
||||
}
|
||||
deadline := time.Now().Add(3600 * time.Second)
|
||||
err = c1.SetDeadline(deadline)
|
||||
err2 := c2.SetDeadline(deadline)
|
||||
if err != nil || err2 != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
async := datalen > tx1Buf
|
||||
if async {
|
||||
// Since data does not fit in TCP Tx buffer the test must be run asynchronously.
|
||||
c1.InternalHandler().SetLoggers(logger, logger)
|
||||
// c1.InternalHandler().SetLoggers(nil, nil)
|
||||
go func() {
|
||||
n, err := c1.Write(data)
|
||||
if err != nil {
|
||||
t.Error("async write", err)
|
||||
} else if n != len(data) {
|
||||
t.Error("io.Writer faulty implementation")
|
||||
n, werr := c1.Write(data)
|
||||
if werr == nil && n != len(data) {
|
||||
werr = fmt.Errorf("async write %d of %d bytes", n, len(data))
|
||||
}
|
||||
err = c1.Close()
|
||||
if err != nil {
|
||||
t.Fatal("async close", err)
|
||||
if werr == nil {
|
||||
werr = c1.Close()
|
||||
}
|
||||
tgoro.FinishWithErr(werr)
|
||||
}()
|
||||
} else {
|
||||
n, err := c1.Write(data)
|
||||
@@ -389,7 +383,20 @@ func testCloseTransmitsPending(tst *tester, s1, s2 *StackAsync, c1, c2 *tcp.Conn
|
||||
exchanging := 1
|
||||
tcpData := 0
|
||||
totalRead := 0
|
||||
writerDone := false
|
||||
for exchanging > 0 || c1.State().TxDataOpen() {
|
||||
if async && !writerDone {
|
||||
// Block until the writer parks on a full Tx buffer (or finishes). Servicing
|
||||
// each park with exactly one pump round below keeps progress deterministic
|
||||
// without sleeping or guessing whether the writer will park again.
|
||||
done, werr := tsched.AwaitGoroYieldOrDone()
|
||||
if werr != nil {
|
||||
t.Error("async write/close:", werr)
|
||||
}
|
||||
if done {
|
||||
writerDone = true
|
||||
}
|
||||
}
|
||||
exchanges++
|
||||
exchanging = exchangeEthernetOnce(t, s1, s2, buf)
|
||||
frm, ok := getTCPFrame(buf[:exchanging])
|
||||
@@ -401,18 +408,21 @@ func testCloseTransmitsPending(tst *tester, s1, s2 *StackAsync, c1, c2 *tcp.Conn
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
} else if ngot != n {
|
||||
t.Errorf("want %d data read c1->c1, got %d", n, ngot)
|
||||
t.Errorf("want %d data read c1->c2, got %d", n, ngot)
|
||||
} else if !internal.BytesEqual(buf[:n], data[totalRead:totalRead+n]) {
|
||||
t.Errorf("exch%d data rx mismatch, want:\n%q\ngot:\n%q\n", exchanges, data[totalRead:totalRead+n], buf[:n])
|
||||
}
|
||||
totalRead += ngot
|
||||
runtime.Gosched() // Yield to let c1 write via goroutine.
|
||||
acks := exchangeEthernetOnce(t, s2, s1, buf) // Send ACK s1's way.
|
||||
acks := exchangeEthernetOnce(t, s2, s1, buf) // Send ACK s1's way, freeing its Tx buffer.
|
||||
if acks == 0 {
|
||||
t.Error("no data sent back to s1")
|
||||
}
|
||||
}
|
||||
}
|
||||
if async && !writerDone {
|
||||
// The ACK above freed Tx buffer space; release the writer to fill it and re-park.
|
||||
tsched.YieldToGoro()
|
||||
}
|
||||
}
|
||||
if c1.BufferedUnsent() != 0 {
|
||||
t.Errorf("done %s: want no data left unsent got %d/%d", c1.State(), c1.BufferedUnsent(), len(data))
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
package xnet
|
||||
|
||||
import "os"
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/soypat/lneto/internal"
|
||||
)
|
||||
|
||||
var _pcap CapturePrinter
|
||||
|
||||
@@ -14,4 +18,7 @@ func init() {
|
||||
|
||||
func debugPacket(msg string, b []byte) {
|
||||
_pcap.PrintEthernet(msg, b)
|
||||
if internal.HeapAllocDebugging {
|
||||
internal.LogAllocs(msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package xnet
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
"github.com/soypat/lneto/ethernet"
|
||||
@@ -172,7 +171,7 @@ func TestStackAsyncListener_MultiSequentialConn(t *testing.T) {
|
||||
chw := [6]byte{0xbe, 0xef, 0, 0, 0, 1}
|
||||
sv.SetGatewayHardwareAddr(chw)
|
||||
tst := testerFrom(t, MTU)
|
||||
doRequest := func(caddrp netip.AddrPort, sleep time.Duration, data []byte) {
|
||||
doRequest := func(caddrp netip.AddrPort, data []byte) {
|
||||
var client StackAsync
|
||||
err := client.Reset(StackConfig{
|
||||
Hostname: "Client",
|
||||
@@ -222,15 +221,12 @@ func TestStackAsyncListener_MultiSequentialConn(t *testing.T) {
|
||||
if len(data) > 0 {
|
||||
tst.TestTCPEstablishedSingleData(&client, sv, &clConn, svconn, data)
|
||||
}
|
||||
if sleep > 0 {
|
||||
time.Sleep(sleep)
|
||||
}
|
||||
tst.TestTCPClose(&client, sv, &clConn, svconn)
|
||||
}
|
||||
|
||||
for range 1000 {
|
||||
caddr := caddr.Next()
|
||||
doRequest(netip.AddrPortFrom(caddr, uint16(sv.Prand32())), 0, []byte("HTTP 1.0\r\n"))
|
||||
doRequest(netip.AddrPortFrom(caddr, uint16(sv.Prand32())), []byte("HTTP 1.0\r\n"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+164
-19
@@ -2,10 +2,12 @@ package xnet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"math/rand"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -35,6 +37,21 @@ func TestTCPConn_ReadBlocksUntilDataAvailable(t *testing.T) {
|
||||
client, sv, clconn, svconn := newTCPStacks(t, seed, MTU)
|
||||
tst := testerFrom(t, MTU)
|
||||
|
||||
// Drive svconn.Read from a single background goroutine whose backoff is
|
||||
// controlled by the scheduler. This lets the test thread know deterministically
|
||||
// when Read has parked waiting for data, instead of sleeping and hoping it blocked.
|
||||
tsched := ltesto.NewSched(t)
|
||||
tgoro := tsched.Goro()
|
||||
err := svconn.Configure(tcp.ConnConfig{
|
||||
RxBuf: make([]byte, MTU),
|
||||
TxBuf: make([]byte, MTU),
|
||||
TxPacketQueueSize: 4,
|
||||
RWBackoff: tgoro.Yield,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tst.TestTCPSetupAndEstablish(sv, client, svconn, clconn, svPort, 1337)
|
||||
|
||||
// Verify no data buffered initially.
|
||||
@@ -43,27 +60,25 @@ func TestTCPConn_ReadBlocksUntilDataAvailable(t *testing.T) {
|
||||
}
|
||||
|
||||
sendData := []byte("blocking test data")
|
||||
readDone := make(chan struct{})
|
||||
var readN int
|
||||
var readErr error
|
||||
var readBuf [64]byte
|
||||
|
||||
// Start a goroutine to read from svconn - this should block since no data available.
|
||||
go func() {
|
||||
readN, readErr = svconn.Read(readBuf[:])
|
||||
close(readDone)
|
||||
n, err := svconn.Read(readBuf[:])
|
||||
readN = n
|
||||
tgoro.FinishWithErr(err)
|
||||
}()
|
||||
|
||||
// Give Read time to enter blocking state.
|
||||
select {
|
||||
case <-readDone:
|
||||
t.Fatal("Read returned immediately without data - expected blocking")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
// Good - Read is blocking as expected.
|
||||
// AwaitGoroYield blocks until Read parks in backoff: deterministic proof that
|
||||
// Read found no data available and is waiting.
|
||||
tsched.AwaitGoroYield()
|
||||
if readN != 0 {
|
||||
t.Fatal("Read returned before data was available")
|
||||
}
|
||||
|
||||
// Write data on client side.
|
||||
_, err := clconn.Write(sendData)
|
||||
_, err = clconn.Write(sendData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -86,14 +101,9 @@ func TestTCPConn_ReadBlocksUntilDataAvailable(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Now Read should unblock and return data.
|
||||
select {
|
||||
case <-readDone:
|
||||
// Good - Read unblocked.
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("Read did not unblock after data became available")
|
||||
}
|
||||
|
||||
// Release Read; the data is now available so it returns instead of backing off again.
|
||||
tsched.YieldToGoro()
|
||||
readErr := <-tsched.Done()
|
||||
if readErr != nil {
|
||||
t.Fatalf("Read returned error: %v", readErr)
|
||||
}
|
||||
@@ -105,6 +115,80 @@ func TestTCPConn_ReadBlocksUntilDataAvailable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStackGoTCPDialRetriesPendingControl(t *testing.T) {
|
||||
const seed = 5678
|
||||
const MTU = ethernet.MaxMTU
|
||||
const tcptimeout = time.Second
|
||||
const yield = 1 * time.Millisecond
|
||||
client, sv, _, _ := newTCPStacks(t, seed, MTU)
|
||||
tsched := ltesto.NewSched(t)
|
||||
tgoro := tsched.Goro()
|
||||
sg := client.StackBlocking(tgoro.Yield).StackGo(StackGoConfig{
|
||||
ListenerPoolConfig: TCPPoolConfig{
|
||||
QueueSize: 4,
|
||||
TxBufSize: MTU,
|
||||
RxBufSize: MTU,
|
||||
NewBackoff: func() lneto.BackoffStrategy {
|
||||
return backoffYield
|
||||
},
|
||||
},
|
||||
TCPDialTimeout: tcptimeout,
|
||||
TCPDialRetries: 2,
|
||||
})
|
||||
t.Log("start")
|
||||
// closure to simulate time.
|
||||
var now time.Duration
|
||||
sg.blk._nanotime = func() int64 { return int64(now) }
|
||||
|
||||
laddr := netip.AddrPortFrom(netip.AddrFrom4(client.Addr4()), 1234)
|
||||
raddr := netip.AddrPortFrom(netip.AddrFrom4(sv.Addr4()), 22)
|
||||
go func() {
|
||||
_, err := sg.SocketNetip(context.Background(), "tcp", syscall.AF_INET, sockSTREAM, laddr, raddr)
|
||||
tgoro.FinishWithErr(err)
|
||||
}()
|
||||
npacket := 0
|
||||
ntcppacket := 0
|
||||
var buf [ethernet.MaxMTU + ethernet.MaxOverheadSize]byte
|
||||
for !t.Failed() { // Tinygo does not implement failnow.
|
||||
tsched.AwaitGoroYield()
|
||||
n, err := client.EgressEthernet(buf[:])
|
||||
now += tcptimeout / 100
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
} else if n == 0 {
|
||||
t.Fatal("expected packet from socketnetip", npacket, ntcppacket)
|
||||
}
|
||||
npacket++
|
||||
frm, ok := getTCPFrame(buf[:])
|
||||
if !ok {
|
||||
tsched.YieldToGoro()
|
||||
continue
|
||||
}
|
||||
ntcppacket++
|
||||
_, flags := frm.OffsetAndFlags()
|
||||
if flags != tcp.FlagSYN {
|
||||
t.Fatal("expected SYN packet")
|
||||
}
|
||||
switch ntcppacket {
|
||||
case 1:
|
||||
now += 2 * tcptimeout
|
||||
tsched.YieldToGoro()
|
||||
case 2:
|
||||
now += 2 * tcptimeout
|
||||
tsched.YieldToGoro()
|
||||
select {
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("SocketNetip hanging")
|
||||
case err := <-tsched.Done():
|
||||
if err != errDeadlineExceed {
|
||||
t.Fatal("expected deadline exceeded", err)
|
||||
}
|
||||
return // Test success.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStackAsyncTCP_multipacket(t *testing.T) {
|
||||
const seed = 1234
|
||||
const MTU = 512
|
||||
@@ -1051,3 +1135,64 @@ func getTCPFrame(etherFrame []byte) (tcp.Frame, bool) {
|
||||
func backoffYield(consecutiveBackoffs uint) time.Duration {
|
||||
return lneto.BackoffFlagGosched
|
||||
}
|
||||
|
||||
// TestEgressIP_TCPMSSAdvertisesMTU guards against advertising a Maximum Segment Size
|
||||
// derived from the (oversized) egress buffer instead of the link MTU. See the
|
||||
// EgressIP clip in StackAsync: without it the SYN advertises ~65479 rather than
|
||||
// MTU-ipHdr-20.
|
||||
func TestEgressIP_TCPMSSAdvertisesMTU(t *testing.T) {
|
||||
const mtu = 1280
|
||||
const wantMSS = uint16(mtu - 20 - 20) // -IPv4 header -TCP header = 1240.
|
||||
s1, s2, c1, _ := newTCPStacks(t, 4, mtu)
|
||||
|
||||
raddr := s2.Addr4()
|
||||
err := s1.DialTCP4(c1, 12345, raddr, 80)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Emit the client SYN through the IP (TUN) egress path using a buffer far
|
||||
// larger than the MTU. The advertised MSS must reflect the MTU, not len(buf).
|
||||
buf := make([]byte, 65535)
|
||||
n, err := s1.EgressIP(buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
} else if n == 0 {
|
||||
t.Fatal("no SYN emitted")
|
||||
} else if n > mtu {
|
||||
t.Fatalf("emitted IP datagram %d exceeds MTU %d", n, mtu)
|
||||
}
|
||||
|
||||
ifrm, err := ipv4.NewFrame(buf[:n])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tfrm, err := tcp.NewFrame(ifrm.Payload())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seg := tfrm.Segment(len(tfrm.Payload()))
|
||||
if !seg.Flags.HasAny(tcp.FlagSYN) {
|
||||
t.Fatalf("expected SYN, got flags %s", seg.Flags.String())
|
||||
}
|
||||
|
||||
var op tcp.OptionCodec
|
||||
gotMSS := uint16(0)
|
||||
found := false
|
||||
err = op.ForEachOption(tfrm.Options(), func(kind tcp.OptionKind, data []byte) error {
|
||||
if kind == tcp.OptMaxSegmentSize && len(data) == 2 {
|
||||
gotMSS = uint16(data[0])<<8 | uint16(data[1])
|
||||
found = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("no MSS option in SYN")
|
||||
}
|
||||
if gotMSS != wantMSS {
|
||||
t.Errorf("advertised MSS = %d, want %d (MTU %d - 40)", gotMSS, wantMSS, mtu)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
//go:build !tinygo
|
||||
|
||||
// Exclude tinygo compiler from build since exec package and t.Skip() are unimplemented.
|
||||
// Also: wouldn't including tinygo cause a recursive call to tinygo test?
|
||||
package xnet
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTinyGoTest(t *testing.T) {
|
||||
if exec.Command("tinygo", "version").Run() != nil {
|
||||
t.Skip("tinygo not installed")
|
||||
}
|
||||
// This takes a long time. Consider running only important
|
||||
// tests with -run=TestXxx flag: `go test ./... -run=TestXxx`
|
||||
out, err := exec.Command("tinygo", "test", ".").CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatal("tinygo failed to test:", err, "\n", string(out))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//go:build !tinygo
|
||||
|
||||
// Exclude tinygo compiler from build since exec package and t.Skip() are unimplemented.
|
||||
// Also: wouldn't including tinygo cause a recursive call to tinygo test?
|
||||
package xnet
|
||||
|
||||
import (
|
||||
"debug/elf"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTinyGoTest(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("`tinygo test` skipped on short test")
|
||||
}
|
||||
if exec.Command("tinygo", "version").Run() != nil {
|
||||
t.Skip("tinygo not installed")
|
||||
}
|
||||
// This takes a long time. Consider running only important
|
||||
// tests with -run=TestXxx flag: `go test ./... -run=TestXxx`
|
||||
out, err := exec.Command("tinygo", "test", ".").CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatal("tinygo failed to test:", err, "\n", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStackAsyncNoIPv6(t *testing.T) {
|
||||
const name = "mwe.elf"
|
||||
cmd := exec.Command("go", "build", "-o="+name, "../../examples/min-working-example")
|
||||
cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=amd64")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatal(err, "\n", string(out))
|
||||
}
|
||||
defer os.Remove(name)
|
||||
f, err := os.Open(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
file, err := elf.NewFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
syms, err := file.Symbols()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Sanity check that symbol/debug information was compiled in normally.
|
||||
// If this exported method is missing the binary was likely stripped
|
||||
// (e.g. -ldflags="-s -w") and the IPv6 absence check below would be
|
||||
// meaningless since every symbol would be gone.
|
||||
const wantSym = "(*StackAsync).AssimilateDHCPResults"
|
||||
if !elfContainsSymbol(t, syms, wantSym) {
|
||||
t.Fatalf("expected symbol %q in %s; symbol table may be stripped", wantSym, name)
|
||||
}
|
||||
|
||||
// Guarantee the compiler did not aggressively include the IPv6 stack in
|
||||
// this IPv4-only example. Methods on the unexported stack6 type are the
|
||||
// IPv6 implementation; their presence means IPv6 code leaked into the
|
||||
// binary and was not dead-code eliminated.
|
||||
const ipv6Sym = "(*stack6)"
|
||||
if elfContainsSymbol(t, syms, ipv6Sym) {
|
||||
t.Errorf("unexpected IPv6 symbol %q found in %s; IPv6 functionality was not eliminated from IPv4-only build", ipv6Sym, name)
|
||||
}
|
||||
}
|
||||
|
||||
// elfContainsSymbol reports whether the ELF binary at path defines a symbol
|
||||
// whose name contains identifier. It reads the symbol table (.symtab), which a
|
||||
// normal go build retains.
|
||||
func elfContainsSymbol(t *testing.T, syms []elf.Symbol, identifier string) bool {
|
||||
t.Helper()
|
||||
for i := range syms {
|
||||
if strings.Contains(syms[i].Name, identifier) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user