Files
alksocks/README.md
T
glm-5.3-flash 398e9340e3 docs(readme): drop the Version 0.1 section
Per-version release notes (0.1 through 0.3.2) duplicated the
changelog; that section was also stale-titled. The section is
removed; the changelog stays the release-history home.
2026-11-03 19:37:02 +00:00

238 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 with 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 |
## 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