- alkcall 0.9.0 -> 0.10.0 (the gateway ownership-seam release): the GatewayDispatch ownership-provider threading + the loud unwired-spine default are not on this crate's paths (no gateway module use; registry dispatch on the native wire untouched). Zero API/wire change here. - alktunnels 0.3.0 -> 0.4.0 (its dependency-major ride): no alktunnels API/wire change; the composed providers see none of it. - fuzz/shared: the harness's own alkcall pin bumped in lockstep (its ChannelOpenError construction makes a drifting pin a duplicate-type compile failure, not silent drift) — the only bump contact. - Docs: CHANGELOG 0.3.0 entry, README version blurb (alktunnels style), AGENTS.md pin update, deps tree in overview.md. - Minor-not-patch for the established reason: semver-incompatible dependency bumps + this crate's public ChannelOpenError re-exports (SocksOpenError::Open/open_ref) are semver-visible downstream. Verification: cargo test (default 145 unit + suites green), --features local, --all-features (171 unit + all suites), clippy --all-targets --all-features -D warnings, fmt --check, wasm32-unknown-unknown check + clippy, fuzz corpus replay 3/3, publish --dry-run clean.
271 lines
13 KiB
Markdown
271 lines
13 KiB
Markdown
# alksocks
|
||
|
||
SOCKS5 (RFC 1928) as a produced resource on
|
||
[alkcall](https://crates.io/crates/alkcall) channels: the `ssh -D`
|
||
(dynamic / SOCKS5 proxy) flavor as a first-class protocol crate —
|
||
the sibling of [alktty](https://crates.io/crates/alktty) (terminals)
|
||
and [alktunnels](https://crates.io/crates/alktunnels) (`-L`/`-R`
|
||
tunnels). The RFC 1928 conversation rides inside a channels data
|
||
channel like any other produced resource, and target dials happen on
|
||
the producing side.
|
||
|
||
The defining posture: **the protocol never binds a port unless
|
||
explicitly configured to.** Kernel binds exist only as explicit,
|
||
feature-gated assembly shapes on either side — the consumer may
|
||
expose the resource on a local port (the front door vanilla clients —
|
||
curl, browsers, tun2proxy — point at), and the producer may serve
|
||
from a real listener. A producer/consumer protocol crate, no
|
||
transport dependencies; the transport is whatever alkcall runs over.
|
||
|
||
## Quick start
|
||
|
||
### Producer — register the SOCKS5 open op
|
||
|
||
```rust
|
||
use std::sync::Arc;
|
||
|
||
use alkcall::channels::ChannelCore;
|
||
use alkcall::registry::registration::OperationRegistry;
|
||
use alkcall::core::auth::AuthContext;
|
||
use alksocks::producer::Socks5Producer;
|
||
|
||
// The three seams (ADR-004): dial (CONNECT), egress (UDP ASSOCIATE),
|
||
// accept (BIND). Missing seams = those commands refuse
|
||
// RFC-faithfully; behind `local`, alksocks::local::{tcp_dial,
|
||
// udp_egress, bind_accept} are the reference socket-backed
|
||
// constructors, and the composed providers (feature `composed`) ride
|
||
// alktunnels' direct-op/bind-first resources instead.
|
||
let producer = Socks5Producer::builder()
|
||
.dial(alksocks::local::tcp_dial())
|
||
.egress(alksocks::local::udp_egress())
|
||
.accept(alksocks::local::bind_accept("127.0.0.1".parse()?))
|
||
.build();
|
||
|
||
let core = ChannelCore::new(manager, default_policy());
|
||
let op_registry = Arc::new(OperationRegistry::new());
|
||
|
||
// Registers `channels/socks5/sub` on the `alk/socks5` ALPN: the
|
||
// registry's ACL (scope `socks5:open`) gates the open op, the
|
||
// session-gate establisher checks the opener identity, and the pump
|
||
// handler drives the RFC 1928 state machine in-stream over the
|
||
// channel's `BiStream`. Wire `core` + `op_registry` into your
|
||
// channels serving stack — this call is the socks5-specific step:
|
||
producer.register(&core, &op_registry, AuthContext::anonymous(b"alk/socks5"), None)?;
|
||
```
|
||
|
||
### Consumer — open a SOCKS5 channel (CONNECT)
|
||
|
||
```rust
|
||
use alkcall::channels::client::ChannelClient;
|
||
use alksocks::rfc1928::util::target_addr::TargetAddr;
|
||
use alksocks::Socks5ChannelSession;
|
||
|
||
let client: ChannelClient = /* your alk/channels connection */;
|
||
|
||
// One session = one channel = one SOCKS5 session. The RFC handshake
|
||
// runs in-stream over the channel; a refused target surfaces the RFC
|
||
// reply code (typed errors, no phantom channel — the adopted channel
|
||
// is torn down before the error returns).
|
||
let mut session = Socks5ChannelSession::connect(
|
||
&client,
|
||
TargetAddr::Domain { addr: "example.internal".into(), port: 80 },
|
||
).await?;
|
||
|
||
let (mut read, mut write) = session.split().expect("pipe");
|
||
// ... raw pass-through: the channel IS the target connection ...
|
||
```
|
||
|
||
### Consumer — UDP ASSOCIATE (the framed datagram stage)
|
||
|
||
```rust
|
||
use alksocks::AssociateSession;
|
||
use alksocks::rfc1928::util::target_addr::TargetAddr;
|
||
|
||
// One channel per association; datagrams ride `[len: u16 BE]
|
||
// [addr + data]` frames in-band (ADR-003) — the RFC codec exists at
|
||
// the front doors only.
|
||
let mut assoc = AssociateSession::associate(&client).await?;
|
||
let dest = TargetAddr::Domain { addr: "dns.internal".into(), port: 53 };
|
||
assoc.send_datagram(&dest, b"query").await?;
|
||
let (src, payload) = assoc.recv_datagram().await?.expect("a datagram");
|
||
```
|
||
|
||
### Consumer — BIND (the reply#1/reply#2 reader)
|
||
|
||
```rust
|
||
use alksocks::BindSession;
|
||
use alksocks::rfc1928::util::target_addr::TargetAddr;
|
||
|
||
// Requires the `socks5:bind` grant on the producing side (a separate
|
||
// capability from `socks5:open` — never implied, ADR-006). The
|
||
// session drives the phase model: reply#1 → quiet wait → exactly one
|
||
// accept → reply#2 → raw pass-through (ADR-007).
|
||
let mut bind = BindSession::bind(&client, TargetAddr::Ip("0.0.0.0:0".parse()?)).await?;
|
||
let reply1 = bind.reply1().await?; // the BND.ADDR the producer bound
|
||
// ... hand reply1's address to the peer, await the accept + reply#2 ...
|
||
```
|
||
|
||
### Consumer — the front door (vanilla clients)
|
||
|
||
```rust
|
||
use std::sync::Arc;
|
||
|
||
use alkcall::channels::client::ChannelClient;
|
||
use alksocks::{CredentialTable, FrontDoor, UdpFrontDoor};
|
||
|
||
let client: Arc<ChannelClient> = /* your alk/channels connection */;
|
||
|
||
// The ssh `-D` local-exposure shape: a real `socks5://127.0.0.1:port`
|
||
// endpoint relaying each accepted connection into a channel. NoAuth
|
||
// raw pipe by default; a `CredentialTable` turns the door into an
|
||
// RFC 1929 cred-bearing endpoint whose credentials map to producer
|
||
// identities (ADR-005). `UdpFrontDoor` serves one association per
|
||
// bound relay socket.
|
||
let door = FrontDoor::builder()
|
||
.bind("127.0.0.1:1080".parse()?)
|
||
.build()?;
|
||
door.serve(client).await?;
|
||
```
|
||
|
||
### Producer — the local listener shape (the other door)
|
||
|
||
```rust
|
||
use alksocks::producer::Socks5Producer;
|
||
|
||
// Feature `local`: serve a produced SOCKS5 resource from a real
|
||
// listener — vanilla clients dial it, each accepted connection is
|
||
// served via `serve_socks5_over` (the NoAuth in-stream RFC
|
||
// conversation; the seams dial locally). The bind is explicit — the
|
||
// builder requires it; the protocol layer never binds.
|
||
let producer = Socks5Producer::builder()
|
||
.dial(alksocks::local::tcp_dial())
|
||
.build();
|
||
let door = alksocks::ProducerDoor::builder()
|
||
.producer(producer)
|
||
.bind("127.0.0.1:1081".parse()?)
|
||
.build()?;
|
||
let guard = door.serve().await?;
|
||
```
|
||
|
||
## Architecture
|
||
|
||
Two roles, symmetric per the alk convention (avoid "server"/"client":
|
||
both sides of a connection can be either; role follows the resource):
|
||
|
||
| Role | Via `alk/channels` |
|
||
|------|--------------------|
|
||
| **Producer** (dials targets, serves the RFC conversation) | [`Socks5Producer`](https://docs.rs/alksocks/latest/alksocks/producer/struct.Socks5Producer.html) / [`register_socks5_openable`](https://docs.rs/alksocks/latest/alksocks/producer/fn.register_socks5_openable.html) — the open op (`channels/socks5/sub`) with the session-gate establisher and the command-dispatch pump; the RFC 1928 typestate core drives negotiation, command read, and replies in-stream over the channel's `BiStream`; CONNECT/BIND data planes ride [`alkcall::channels::pump_bidi`](https://docs.rs/alkcall/latest/alkcall/channels/pump/fn.pump_bidi.html) |
|
||
| **Consumer** (wants the proxy) | [`Socks5ChannelSession`](https://docs.rs/alksocks/latest/alksocks/consumer/struct.Socks5ChannelSession.html) (CONNECT) / [`AssociateSession`](https://docs.rs/alksocks/latest/alksocks/consumer/struct.AssociateSession.html) (ASSOCIATE) / [`BindSession`](https://docs.rs/alksocks/latest/alksocks/consumer/struct.BindSession.html) (BIND) — typed channels-native sessions, each owning its channel's teardown |
|
||
|
||
One connection hosts many SOCKS5 sessions (one per channel); sessions
|
||
are independent. Both roles are callable from either connection side
|
||
(producer and consumer are roles, not sides); RFC 1928's own
|
||
client/server roles keep their RFC names inside the protocol layer.
|
||
|
||
### The RFC conversation is the data plane
|
||
|
||
The open op's params are deliberately trivial (`{}`, ADR-001) —
|
||
there is nothing to negotiate at open time (the SOCKS5 target is
|
||
unknown until the RFC command arrives). Everything else is RFC 1928,
|
||
in-stream:
|
||
|
||
- **CONNECT**: raw pass-through, 0 B overhead — the channel's halves
|
||
are the target connection (the two-pump shape).
|
||
- **ASSOCIATE**: the reply's BND.ADDR is a pure RFC handle (the
|
||
sentinel in-band); datagrams ride `[len: u16 BE][addr + data]`
|
||
frames in-band (ADR-003, the handle swap) to the egress seam.
|
||
One channel per association; the association lifetime is the
|
||
channel's.
|
||
- **BIND**: reply#1 → quiet wait → exactly one accept (the accept
|
||
seam) → reply#2 → raw pass-through (ADR-007).
|
||
|
||
Authorization is two grants, never implied (ADR-006): the open gate
|
||
(`socks5:open`) at the registry, the command gate (`socks5:bind`) at
|
||
BIND dispatch. The target policy is the dialer (ADR-012): the
|
||
producer's dial/egress/accept closures are the security boundary —
|
||
whatever they allow is what the resource reaches.
|
||
|
||
### The seams
|
||
|
||
The protocol layer is substrate-agnostic by construction (ADR-004):
|
||
`DialFn` / `EgressFactory` / `BindAcceptFactory` are *functions, not
|
||
traits* at the three I/O planes; ownership rides the handle
|
||
structurally. A channels `BiStream`, a `TcpStream`, or an in-process
|
||
pipe drops in behind the same seams — no socket type leaks into the
|
||
protocol layer.
|
||
|
||
### WASM target
|
||
|
||
The default crate (no features) compiles to `wasm32-unknown-unknown`
|
||
— the protocol layer for a sandboxed TS/Python adapter. The RFC 1928
|
||
core is a targeted fork of
|
||
[fast-socks5](https://github.com/dizda/fast-socks5) v1.0.0's
|
||
`T`-generic subset, extracted in-tree as fully owned code
|
||
(`src/rfc1928/`, MIT notice bundled; ADR-011/013): no kernel-net
|
||
code, no `net` feature — the socket I/O rides `local` alone, and the
|
||
default build is wasm-clean by construction.
|
||
|
||
## Features
|
||
|
||
| Feature | Contents | wasm |
|
||
|---------|----------|------|
|
||
| *(default)* | the RFC 1928 core (`src/rfc1928/` — codecs, typestate, client `use_stream`), the ASSOCIATE wire codec, the open-op spec, the three seams, the producer + consumer session types — protocol only | yes |
|
||
| `local` | the socket-backed seam providers ([`tcp_dial`](https://docs.rs/alksocks/latest/alksocks/local/fn.tcp_dial.html) / [`udp_egress`](https://docs.rs/alksocks/latest/alksocks/local/fn.udp_egress.html) / [`bind_accept`](https://docs.rs/alksocks/latest/alksocks/local/fn.bind_accept.html)), the front doors ([`FrontDoor`](https://docs.rs/alksocks/latest/alksocks/door/struct.FrontDoor.html) + [`UdpFrontDoor`](https://docs.rs/alksocks/latest/alksocks/door/struct.UdpFrontDoor.html), the consumer-side exposure; [`ProducerDoor`](https://docs.rs/alksocks/latest/alksocks/local/producer_door/struct.ProducerDoor.html), the producer-side listener) | no |
|
||
| `composed` | the alktunnels-composed providers: [`direct_egress`](https://docs.rs/alksocks/latest/alksocks/composed/fn.direct_egress.html) (the direct-op egress, a lazy destination→tunnel table) + [`bindfirst_accept`](https://docs.rs/alksocks/latest/alksocks/composed/fn.bindfirst_accept.html) (the BIND accept whose listener lives on a bind-first listen resource) | yes |
|
||
|
||
## Version 0.1
|
||
|
||
The first release rides alkcall 0.8.1 (the establisher reply
|
||
projection is not consumed yet — the open op's params are trivial;
|
||
the 0.8.1 duplicate-adopt fix is directly relevant to the consumer
|
||
open/adopt paths) and alktunnels 0.2.1 (the direct op + the
|
||
bind-first/forwarded flow power the composed providers). The
|
||
`fuzz/` workspace (excluded from the published package) fuzzes the
|
||
two attacker-reachable parser classes — the ASSOCIATE datagram codec
|
||
and the RFC 1928 handshake state machine. Wire-stable surface: the
|
||
`alk/socks5` ALPN, the `channels/socks5/sub` open op with `{}` params,
|
||
and the two scope constants — all pinned by ADRs 001–014.
|
||
|
||
0.2.0 rides the alkcall bump 0.8.1 → 0.9.0 (the stub-sweep release —
|
||
the four registered-but-always-failing stubs became real
|
||
implementations; none were on this crate's paths) and the alktunnels
|
||
bump 0.2.1 → 0.3.0. No alksocks API change, no wire change, zero code
|
||
contact with the 0.9.0 breaking surface; minor rather than patch
|
||
contact with the 0.9.0 breaking surface; minor rather than patch
|
||
because the semver-incompatible dependency bumps + this crate's
|
||
alkcall type re-exports make it semver-visible downstream.
|
||
|
||
0.3.0 rides the alkcall bump 0.9.0 → 0.10.0 (the gateway
|
||
ownership-seam release — `GatewayDispatch` ownership-provider
|
||
threading and the loud unwired-spine default; the gateway module
|
||
isn't on this crate's paths, registry dispatch on the native wire is
|
||
untouched) and the alktunnels bump 0.3.0 → 0.4.0 (the dependency-major
|
||
ride on alkcall 0.10.0). No alksocks API change, no wire change, zero
|
||
code contact with the 0.10.0 breaking surface (the only contact is the
|
||
fuzz/shared harness's own alkcall pin — a mechanical lockstep bump);
|
||
minor rather than patch for the same semver-visible-dependency reason
|
||
as 0.2.0.
|
||
|
||
## Verification
|
||
|
||
```bash
|
||
cargo test # default crate (wasm-clean)
|
||
cargo test --features local # + real-socket suites
|
||
cargo test --all-features # + the composed suites
|
||
cargo clippy --all-targets -- -D warnings
|
||
cargo fmt --check
|
||
cargo check --target wasm32-unknown-unknown # the wasm-clean guard
|
||
```
|
||
|
||
## Documentation
|
||
|
||
- [Architecture docs](docs/architecture/README.md) — the
|
||
authoritative spec set (in the repository, not the published
|
||
package): ADRs (001–014), the wire format, the producer/consumer
|
||
contracts, and the fork provenance.
|
||
- [API docs](https://docs.rs/alksocks) — full crate documentation on
|
||
docs.rs.
|
||
|
||
## License
|
||
|
||
MIT OR Apache-2.0 |