Repository navigation
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #254 +/- ##
==========================================
+ Coverage 93.76% 93.78% +0.02%
==========================================
Files 72 72
Lines 3866 3880 +14
==========================================
+ Hits 3625 3639 +14
Misses 241 241 ☔ View full report in Codecov by Harness. |
The ATDECC proxy serves HTTP CONNECT (IEEE 1722.1-2021 Clause C.1) through FlyingFox's HTTPResponse(tunnel:), which is not yet in a FlyingFox release (swhitty/FlyingFox#254). The CONNECT transport is now behind a package trait, HTTPTunnel, off by default, so that ATDECCProxy, and the packages depending on it, build against released FlyingFox. With the trait off, the APS serves WebSocket on /ws only, and refuses CONNECT with 501 Not Implemented and Allow: GET: the server supports CONNECT for no resource (RFC 9110 Section 15.6.2), which 405, meaning not for this resource, would misstate; Allow points at the method it does support. With the trait on, CONNECT is served as before. The loopback tests open their sessions with CONNECT when the trait is on and with a WebSocket upgrade when it is off, so the transport-neutral behaviour (the connection limit, two APCs at once, entity IDs refused when advertised on the LAN, link down, a connection that has not sent its request using no slot) is tested either way. Tests of CONNECT itself (the Annex C session, APPDUs sent with the request, a bad subtype, and the write timeout of an APC that stops reading) compile only with the trait; without it, a test checks the 501 refusal. The CONNECT session's head is checked for its status line and the absence of length fields, as FlyingFox's main branch adds a Date field. Verified with swift build and swift test on Linux (Swift 6.4): trait off against FlyingFox 0.27.1 as released, 50 proxy tests, 10 runs clean; trait on against swhitty/FlyingFox#254's branch (http-tunnel-upstream) through swift package edit, 53 proxy tests, 10 runs clean; the whole suite passes both ways. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SGwDLWKAC1NJDijG6Ss6YH
…n and listener fixes Review of the FlyingFox-based server. - The APS's own host is allowed as a WebSocket Origin by host alone: a page at http://10.10.32.8 opening ws://10.10.32.8:17221 has a Host field with the port and an Origin without it, so the two never matched. Bracketed IPv6 literals ([fe80::1]:8080) keep their brackets when the port is removed. - An APC whose WebSocket closes before the transfer starts is answered with its close status, as one closing later is. - A CONNECT tunnel shuts its socket down only while it has it: the shutdowns of a write timeout, a stalled APC or the drain deadline go through a guard that is invalidated before the handler returns, so none can reach a descriptor FlyingFox has closed and the system has given to another socket. - A listener's failures are logged whenever the error changes, not only the first, and a listener on the IPv6 wildcard that cannot bind for want of IPv6 listens on the IPv4 wildcard instead. - With the HTTPTunnel trait, whose FlyingFox (main) bounds WebSocket frames, each frame is bounded to AppWebSocketMaximumMessageLength (webSocketMaxFrameSize), so an oversized one fails before it is read whole; without it, messages are still checked once assembled. - Advertisements whose valid_time has passed are pruned from the entity ID table as frames are observed. FlyingFox's listener still ends, cancelling the connections it serves, when an accept fails (EMFILE, ECONNABORTED, ...); the server starts it again. A fix, accepting again after such failures, is on PADL/FlyingFox accept-transient-errors. Verified with swift build and swift test on Linux (Swift 6.4): new tests for the origin's host, an early close answered, pruning, and (with the trait) an oversized frame ending the connection. Trait off, against FlyingFox 0.27.1 as released: 53 proxy tests, 10 runs clean. Trait on, against swhitty/FlyingFox#254's branch: 57 proxy tests, 10 runs clean. The whole suite passes both ways. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SGwDLWKAC1NJDijG6Ss6YH
AtdeccProxyServer.setAddresses(_:) changes where the proxy listens while it runs: a FlyingFox listener starts at each address newly given, and one at an address no longer given stops, closing its connections; the others, and their APCs, are untouched. A device whose own addresses change (a DHCP renewal, a global IPv6 address arriving after boot) follows them this way, rather than starting the proxy again or polling. Listeners are made with the server, at the configured addresses, and start when it runs; an address that is not an IP literal is refused, changing nothing. Verified with swift build and swift test on Linux (Swift 6.4): a new loopback test moves the listener from 127.0.0.1 to 127.0.0.2, the APC on the old address losing its connection and one at the new address being served. Trait off, against FlyingFox 0.27.1 as released: 54 proxy tests, 10 runs clean. Trait on, against swhitty/FlyingFox#254's branch: 58 proxy tests, 10 runs clean. The whole suite passes both ways. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SGwDLWKAC1NJDijG6Ss6YH
…evice needs The proxy listens on the IPv6 wildcard, which accepts IPv4 too: one FlyingFox HTTPServer, with no addresses in its configuration. A server that cannot listen ends run() with an error, for its host to log; the device runs on without the proxy. Gone, as machinery for misbehaviour a single-purpose box does not see: - listeners per address, setAddresses(_:), and the restart loop with its log-when-the-error-changes and IPv4 fallback; - for CONNECT, keepalive, TCP_USER_TIMEOUT, the fixed send buffer, the per-write timeout, the guarded descriptor and the 2 s drain: the tunnel shuts its socket down when the connection closes or its APC stops reading, both before the handler returns; - the queue of frames for the LAN: they are sent as the state machine produces them; - in the entity-ID table, the set of live connections and the separate received and sent paths: one observe(_:sentBy:), and what an APC advertised is forgotten with its connection. Kept: the Annex C state machine and messages; WebSocket on /ws with its Origin check and close codes; CONNECT behind the HTTPTunnel trait (501 without it); the limit of 8 connections, as a slot given back however a connection ends; the bounded output queue that ends the connection of an APC that stops reading; the ADP-based entity-ID check; the frame bound with the trait. Appdu is encoded and decoded with swift-binary-parsing, as the ATDECC PDUs are: init(parsing:) reads the version, message type, big-endian payload_length, address and payload, refusing what it refused before, and serialize(into:) writes them; the entity ID payload is a big-endian UInt64 through the parser. AppduStreamDecoder parses whole APPDUs from its buffer the same way. ATDECCProxy's sources are 1320 lines, from 1617. Verified with swift build and swift test on Linux (Swift 6.4): new tests for an APPDU round trip through serialize and parse, and for a connection whose APC stops reading being ended once its output is full; the tests of address following and of the CONNECT write timeout are gone with them. Trait off, against FlyingFox 0.27.1 as released: 55 proxy tests, 10 runs clean. Trait on, against swhitty/FlyingFox#254's branch: 58 proxy tests, 10 runs clean. The whole suite passes both ways. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SGwDLWKAC1NJDijG6Ss6YH
When the APS closed a WebSocket connection itself (CLOSED for an AVTPDU outside Table C.7, Clause C.5.2.2.4, or no entity ID left to assign), FlyingFox sent the close and closed the socket, but the task reading the client's frames stayed suspended on it, and with it the connection's slot, its state and its AF_PACKET port: with one slot, a client that sent a bad subtype and stayed connected locked every later one out with 503. The read is now cancelled once the connection's state machine ends, whoever ended it, as FlyingFox's MessageFrameWSHandler ends its reader. A client that stops reading has its connection ended the same way: the full output ends the state machine, and the slot is given back while FlyingFox's write waits on the socket. run() logs the port it listens on once listening, rather than the configured one, which is 0 when the kernel is to choose. Verified with swift build and swift test on Linux (Swift 6.4): a new test, with one slot, has a client send a bad subtype and stay connected, and a second client served; it fails without this change. Trait off, against FlyingFox 0.27.1 as released: 56 proxy tests, 10 runs clean. Trait on, against swhitty/FlyingFox#254's branch: 59 proxy tests, 10 runs clean. The whole suite passes both ways. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SGwDLWKAC1NJDijG6Ss6YH
There was a problem hiding this comment.
Thanks for this — the tunnel abstraction is great. Handing the connection over once the response head is written is the right primitive, and I'd like to move the WebSocket handler onto it in the future (an upgrade is really a tunnel with a frame codec on top).
One change I'd like before merging: rather than passing the raw AsyncSocket to the handler, could we wrap it in a small type with a minimal interface? Something like:
public struct HTTPTunnel: Sendable {
public var bytes: some AsyncBufferedSequence<UInt8>
public func write(_ data: Data) async throws
public func setValue<O: SocketOption>(_ value: O.Value, for option: O) throws
}
public protocol HTTPTunnelHandler: Sendable {
func handleTunnel(_ tunnel: HTTPTunnel) async throws
}- The raw socket offers a second read path (
socket.bytes,read(...)) that bypasses the connection's buffer, so bytes already buffered past the request can be skipped or reordered. - If a handler calls
socket.close(), the fd can be reused by the next accepted connection beforeHTTPServerremoves and closes its own (connections are keyed by fd), so the server could close the wrong client. The connection should own closing. - A client disconnect could simply end
bytes, asHTTPRequestSequencedoes, rather than throwSocketError.disconnectedinto every handler. - A struct can grow later (e.g.
shutdownWrite(), a stop signal, the originating request) without breaking conformers.
You keep socket options through setValue(_:for:) and backpressure through the awaited write.
🤖 Claude helped with this review
|
|
||
| let connection = HTTPConnection(socket: s1) | ||
| // the client sends its first bytes before the response, as an APC may | ||
| try await s2.writeString("CONNECT / HTTP/1.1\r\nHost: example:17221\r\n\r\nping") |
There was a problem hiding this comment.
CONNECT targets are authority-form (host:port, RFC 9110 §9.3.6), so could this send CONNECT example:17221 HTTP/1.1 rather than CONNECT /? The test only checks the method and the bytes, so it should still pass.
The decoder currently mangles authority-form targets into request.path (e.g. example.com:443 becomes "443"). I'll follow up separately with some parsing improvements there; until then a wildcard CONNECT route matches, and the authority can be read from request.target.
A server answering CONNECT (RFC 9110 Section 9.3.6), or switching to a protocol other than WebSocket, needs the connection itself once the response head is written. HTTPResponse.Payload had only a body or a WebSocket handler, and nothing else could reach the socket. HTTPResponse(tunnel:) gives the new payload, .tunnel, whose HTTPTunnelHandler is called once the head is written with the bytes the client sends (the connection's buffered stream, so bytes it sent with its request come first) and the AsyncSocket, for writing and for socket options. The connection is closed when the handler returns. The head carries neither Content-Length nor Transfer-Encoding, as a 2xx answer to CONNECT must not. Adding a case to Payload breaks exhaustive switches over it outside the package, as HTTPEncoderTests' had. Tested with a new HTTPConnectionTests case: bytes sent with a CONNECT request reach the handler first, and what it writes reaches the client. HTTPDecoderTests' standardizedPath and naughtyPath_IsParsed fail on Linux (Swift 6.4) with or without this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SGwDLWKAC1NJDijG6Ss6YH
The handler is called once the head is written whatever its status; for a non-2xx status, what to do with the connection is left to it, and it may simply return. The protocol's summary also says "assumes the connection" for a protocol other than CONNECT that has it after the response. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SGwDLWKAC1NJDijG6Ss6YH
d312b2a to
bfe01b2
Compare
A server answering
CONNECT(RFC 9110 §9.3.6), or switching to a protocol other than WebSocket, needs the connection itself once the response head has been written.HTTPResponse.Payloadoffers a body or a WebSocket handler, and there is no other way to reach the socket.This adds a payload for that:
HTTPResponse(tunnel:)creates a response whose payload is.tunnel(any HTTPTunnelHandler).HTTPTunnelHandleris called with the bytes the client sends and theAsyncSocket, for writing and for socket options. The bytes are the connection's buffered stream, so anything the client sent along with its request comes first.Datebut neitherContent-LengthnorTransfer-Encoding, since a 2xx answer to CONNECT must not have them.Our use is the ATDECC Proxy Protocol (IEEE 1722.1-2021 Annex C), which tunnels ATDECC messages over an HTTP CONNECT connection on the same port as its WebSocket variant. We'd rather serve both from one FlyingFox server than parse HTTP ourselves.
Adding a case to
Payloadbreaks exhaustive switches over it outside the package, so this probably wants a minor version bump.HTTPEncoderTestshad one, which is updated.The handler is given the
AsyncSocketitself, a wider surface thanWSHandlerexposes. That's deliberate: a tunnel needs socket options (send buffer, keepalive,TCP_USER_TIMEOUT) and write timeouts that only the socket can provide. If you'd rather wrap it in a narrower type, I'm happy to do that.Tests: a new
HTTPConnectionTestscase checks that bytes sent with a CONNECT request reach the handler first, and that what the handler writes reaches the client. On Linux (Swift 6.4) the full suite runs 314 tests. The only failures areHTTPDecoderTests'standardizedPathandnaughtyPath_IsParsed, which fail the same way onmainwithout this change.Happy to rename or reshape it if you'd prefer a different API.
🤖 Generated with Claude Code
https://claude.ai/code/session_01SGwDLWKAC1NJDijG6Ss6YH