Software 1.0.21. Protocol 1.0 (loft/1.0). Spec: protocol.md. Operation: guide.md.
The Go tree in this repository is described. A third-party implementation MAY differ in layout and still speak the protocol.
The reference node is a single binary, loft-node, in two roles: controller and relay. The reference client is loft (CLI: SOCKS5 and optional TUN). loft-front is the same session without CLI flags for system routes; it is launched as a helper by the macOS menu-bar application. ICMP, TCP, UDP, DNS, and HTTP through a session are checked by loft-probe. Shared code is kept in internal/.
docs/ protocol, architecture, guide
internal/
wire/ frames, Hello, Status, roster, lists, fleet, caps, codes
crypto/ HKDF, ChaCha20-Poly1305, auth 0x02 + X25519, Ed25519 seed
identity/ member key lt_ + 48 hex; SHA-256 in network.conf; plaintext in members.keys
mux/ streams, backpressure, CmdDgram, CmdLeave
netconf/ network.conf, leases, ACL, VLAN, DNS, session timers
transport/ fronts, decoy, mask, relay trunk, overlay, p2p, DNS stub, roster fetch
config/ client.yaml, server.yaml
cdn/ edge profiles (Yandex, Cloudflare); not caps. Operator: docs/yandex-cdn_ru.md
version/ program name, semver, protocol range
server/
cmd/loft-node
origin/ cover page, GET, WebSocket, HTTP/2 CONNECT, public roster/lists
configs/ server.yaml, relay.yaml
debian/ installer, systemd unit, .deb
panel/ loft-panel web admin (plan and mock: panel/design)
clients/
go/cmd/loft CLI: SOCKS5, TUN, kill-switch
go/cmd/loft-front session helper (macOS bundle; Aurora RPM libexec)
go/cmd/loft-probe overlay ICMP/TCP/UDP/DNS/HTTP without rewriting routes
go/tools/wsprobe one-shot GET handshake (manual; not in dist)
macOS/Sources Swift 5 + AppKit menu bar; launches loft-front
macOS/pack DMG (Applications symlink), sign, entitlements
macOS/design UX spec and mock
macOS/tests RFC 8439 AEAD vector (make -C clients/macOS test-crypto)
Keenetic/ Entware OpkgTun catalog (plan and mock; no binary yet)
iOS/ Packet Tunnel (thin host + appex; gomobile xcframework; Apple TV in PLAN phase 6)
Android/ VpnService (thin Java host; gomobile AAR; UX mock; Android TV / Google TV in PLAN phase 4)
Aurora/ ConnMan overlay (QML host + loft-connman plugin + loft-front)
mobile/ shared Go engine for iOS/Android (loft, SSTP, VLESS)
brand/ mark and icons
wire is the protocol. transport is how this tree carries it. cdn is a deployment profile: header order and SNI of a named edge. It MUST NOT appear on the wire as a capability.
A member session is established by a dial, an auth blob, and a Status; mux follows.
client.Holder
Supervise(cfg) rotate outbounds and fronts
Open(target) get | ws | h2 | quic | h3
TLS 1.3 (uTLS)
auth 0x02 (nonce || eph_c || HMAC)
Hello MemberCaps (no CapRelay; ExtraCaps on loft-node relay)
eph_n || AEAD(Status) // 1.0.16 clients: HMAC(lt_, nonce), Seal only, 0x03 ticket; gone in 1.1
mux Open / Data / Close / Dgram / Leave
Supervise tries configured outbounds in list order when select: auto. After the first Status it keeps only the intersection of fronts. On failure it moves to the next remaining front, then unsigned replicas, then the signed roster.
WatchPath samples the default IPv4 egress about four times a second. A Wi-Fi / LTE / VPN address change drops the live front so Supervise can resume with a fresh 0x02 and the same instance_id. SOCKS5 and TUN stay bound (Holder); the overlay IP does not change. With cap hold, mux streams stay for gap_hold_ms (default 8 s) and inner TCP does not RST. GET X-Auth adopts a parked mux by instance_id the same way WS does (new HTTP sid, same mux). loft and loft-front both run this loop.
Roster fetch needs controller_pub. tofu: true pins the first valid signer and writes {instanceDir}/controller.pub. Empty pub without that flag leaves the roster unused; static outbounds still dial.
Inner AEAD nonce is unique per session key (32-bit prefix + 64-bit counter). Park with the same key continues the counter. A new 0x02 derives a new key from X25519; HKDF ikm is the DH secret.
The client does not choose decoy SNI. Status, roster, or a probe certificate supplies it. A CDN outbound keeps the CDN hostname.
Local inbound (SOCKS5, TUN) calls Holder.Open(host:port) or sends a datagram. Overlay destinations are resolved by NetSwitch on the node. Status dns is a list of resolver IPs; the node answers gateway:53 from dns.upstream (DoH, DoT, or UDP). Optional Status tail ip6 / gateway6 is IPv4-only when omitted.
Holds:
network.conf; plaintext in {dir}/members.keys (keep_keys lets an operator re-read that file). auth shared enable accepts LOFT_TOKEN as the client secret only when that ring is empty.{dir}/controller.seedIt MAY listen on 443 for members. It MUST listen for relay trunks.
Signing seed is created on first start. The public key is given to clients as controller_pub. The reference client will not TOFU an unknown signer unless tofu: true.
Terminates public TLS. Opens one trunk to the controller with Hello cap relay. On that session:
ctl:1 — enrol, beat, slice, drainhub:1 (HTTP/1.1) or hub:2 (HTTP/2)The relay copies TLS-plaintext HTTP onto hub:1 / hub:2. Inner mux AEAD and FinishAuth stay on the controller. The relay does not decrypt overlay frames. It does not mint leases. It does not store member keys. Overlay IP is assigned on the controller.
A relay IP is disposable. Removing it from the roster is a serial bump. Clients already holding a Status keep the session until it fails, then fetch the new roster.
server/origin is the HTTPS application the public path sees:
cover_dir, or a built-in page)A decoy-spliced probe never reaches this handler: it is forwarded to the configured origin before HTTP.
systemd unit, installer, and .deb: server/debian/README.md. Token in /etc/loft/loft.env. Overlay document in /var/lib/loft. Process user loft, bind 443 with CAP_NET_BIND_SERVICE.
transport.WrapDecoy peeks the TCP ClientHello.
Accept
read ClientHello (or leftover bytes)
if valid session_id and own SNI
node certificate (member on advertise, CDN after sid)
else if own SNI from cdn_peers
node certificate (CDN origin pull)
else if session_id = sid v3 (node X25519) or sid v2 (member HMAC)
if mask ready → dest-shaped ECDSA leaf + record pad
else → loft HTTP handler
else
TCP splice to decoy origin (SNI match, else primary)
UDP
decoy on → datagram splice to dest
decoy off → loft QUIC/H3
The splice destination is node configuration. The client discovers it. Example hostnames in YAML are examples. They are not protocol constants.
Mask mints an ECDSA P-256 leaf that copies the origin identity and signs CertificateVerify with ecdsa_secp256r1_sha256. Serial is bound to ECDH with the node X25519. Probes still splice. Application records MAY follow origin ciphertext lengths.
transport.Overlay binds each admitted session to an overlay IP. Open to an address inside the network CIDR is classified by peers:
| mode | action |
|---|---|
| hub | Reset policy_drop |
| mesh | splice to the peer’s mux session as Open 127.0.0.1:port |
| vlan | mesh if VLAN matches |
TCP Open and overlay ICMP go through Network.Allow (tags, CIDR, VLAN, default deny). ip route DST VIA|member ID|tag NAME is a CIDR behind a member: Open host:port splices onto that member with the original target. Status routes lists those CIDRs with the resolved overlay IP; the via member is omitted. Internet Open (outside the CIDR and those routes) is unchanged. Direct P2P uses Status policy for the same Allow. session ttl_ms closes the live session; lease_hold_ms keeps the overlay IP after disconnect.
The receiving client accepts that Open and dials the target. Overlay-IP splice uses 127.0.0.1:port. Routed splice uses host:port on the member’s host stack.
Optional P2P: the client listens, announces loft.peer:<port> plus a 32-byte X25519 pub, and Status carries TLS-observed host, port, and pub. Direct TCP uses the 42-byte LFT preface (HMAC under pairwise ECDH with the peer pub), then the same ACL as hub splice when Status carries policy. Failure falls back to hub splice. p2p: false on the client skips the listen. The seed is {instanceDir}/p2p.seed.
The controller publishes named sets. The client maps names to actions (use:). Matching order when several lists hit: drop, then direct, then via. Default for unmatched destinations is via.
SOCKS matches the hostname of the request. TUN matches the destination IP against CIDR entries. Domain kinds do not apply to raw IP sockets.
Lists are fetched when Status lists_serial or lists_hash is newer than the cache. Signature is the same Ed25519 key as the roster.
| listener | default | notes |
|---|---|---|
| SOCKS5 | 127.0.0.1:1080 (GUI / client.yaml); 127.0.0.1:18080 (loft / loft-front when the port is omitted) |
RFC 1928. CONNECT and UDP ASSOCIATE when dgram is on. |
| TUN | off; tun: auto creates utun |
gVisor user stack. UDP via CmdDgram. ICMP echo: self locally; overlay and internet dests as Dgram :1 (node emits internet echo). Darwin NATs dummy 169.254.8.1 ↔ overlay IP on ICMP. |
| kill-switch | listen.kill_switch |
when the last TUN dies, native default stays down until the user restores it |
TUN excludes outbound hosts of the loft session (CDN, node) so the tunnel does not swallow its own path.
The macOS menu-bar client is Swift 5 + AppKit under clients/macOS/Sources/. UX: clients/macOS/design/DESIGN.md. A live session is loft-front in the app bundle (Contents/MacOS/loft-front), not the in-process GETFront.swift type. Hello of that helper is the Go client (GET and WS). Route hold, failover, and split lists in the design document are machine policy. They sit above the Go CLI flags.
Order of attempts after a dead session:
select: auto order.replicas from Status.Reconnect timers come from Status: reconnect_min_ms, reconnect_max_ms, reconnect_fast_n, reconnect_fast_for_ms. Fast tries first, then exponential backoff.
A replica accepts a fresh 0x02. Hello instance_id keeps the overlay lease.
Mux Pull drains Open/Ping/Pong/Dgram/Leave and small Data (≤ 4 KiB) before bulk Data/Pad so a full download does not stall ICMP, TCP ACKs, and Cloudflare loaded-ping. Close/Reset stay in the Data queue so a graceful close cannot skip remaining bytes. The Data queue is 48 frames. WS prefetch is 2×48 KiB; control frames get their own 8 KiB message.
session rate_bps (network default, config member ID rate_bps override) is a token bucket on bulk CmdData/CmdDgram of that member session, both directions. 0 is off. Control and small frames skip it. Status rate_bps advertises the cap; the node enforces. Relay trunks are uncapped.
One mux session per member instance. Streams are goroutines with a bounded receive buffer. Mux payload is ≤ 16384; Stream.Write and EncodeBatch split CmdData so a 32 KiB io.Copy does not drop the frame.
The GET front has a bounded inflight window (protocol 2–8, default 4; this tree defaults to 48). The client pump is the sole uplink Puller and assigns GET seq in Pull order. The node applies uplink in seq order; only the in-order GET Pulls mux downlink so a dropped HTTP response cannot lose frames already taken from the mux (YouTube/Speedtest TLS). Idle GET pace is keepalive_ms / 2 (floor 1 s) with ±⅓ jitter. Open/tiny Data wait 15 ms and datagrams 80 ms so ICMP can ride the same body. Generic GET header_budget in this tree is 16 KiB of HTTP header (decoded mux ~12 KiB after base64url+AEAD, ~13 KiB after Cookie Z85); the Yandex profile pins 16 KiB encoded. Status h2_conns (node get_h2, 1–4) is extra HTTP/2 connections for a second header-budget envelope; inflight is still the total. A GET whose uplink already has ≥ 2 KiB CmdData caps the body at 96 KiB so Cookie slots recycle; download-only stays 256 KiB.
Relay trunks are one mux session per relay process, with many hub:* streams.
Supervise is a single loop per client process. Multiple YAML profiles (macOS multi) are multiple processes or multiple Holders. They are not cap multi. Cap multi is several instance_id values on one member key.
Virtual machines, public DNS, public certificates, and binary updates are outside this protocol. systemd units, the POSIX installer, and the .deb live in server/debian/. The wire carries enrol, beat, slice, drain, roster, and lists. An operator issues per-member keys and writes network.conf plus members.keys.