Software 1.0.21. Protocol 1.0 (loft/1.0). Spec: protocol.md. Layout: architecture.md.
A reference implementation is described.
Go 1.24 or newer, from the repository root:
make test
make node
make client # dist/clients/go/loft
make probe # dist/clients/go/loft-probe
make macos # dist/clients/macOS/loft.app
make macos-dmg # dist/clients/macOS/loft-*-macos-<arch>.dmg (Applications symlink)
make macos-dmgs # both arm64 and x86_64 DMGs (CI)
make dist # node tarballs/.deb + CLI archives
go test ./...
go build -ldflags "-s -w -X github.com/a3app/loft/internal/version.Version=$(cat VERSION)" -o loft-node ./server/cmd/loft-node
go build -ldflags "-s -w -X github.com/a3app/loft/internal/version.Version=$(cat VERSION)" -o dist/clients/go/loft ./clients/go/cmd/loft
go build -ldflags "-s -w -X github.com/a3app/loft/internal/version.Version=$(cat VERSION)" -o dist/clients/go/loft-probe ./clients/go/cmd/loft-probe
--version prints program name, semver, and protocol range.
make client is dist/clients/go/loft. make probe is loft-probe. The macOS app also needs loft-front:
go build -ldflags "-s -w -X github.com/a3app/loft/internal/version.Version=$(cat VERSION)" -o dist/clients/go/loft-front ./clients/go/cmd/loft-front
A controller holds keys, leases, ACLs, and the signing key for roster and lists. It MAY listen on 443 for members.
A relay terminates public TLS and copies plaintext to the controller on a trunk. Its IP is disposable. Member identity and overlay IP remain on the controller.
Both roles are the same binary. Role is YAML role: controller or role: relay.
Path: {dir}/network.conf (legacy {dir}/network.yaml still loads). The node will not start without it.
network home
name home
enable
ipif overlay 10.8.0.0/24
gateway 10.8.0.1
pool 10.8.0.10 10.8.0.220
mtu 1280
peers hub
p2p enable
dns enable
dns 10.8.0.1
dns upstream https://1.1.1.1/dns-query
dns upstream tls://1.1.1.1
listen 0.0.0.0 443 front ws
create member alice
config member alice multi enable 4
config member alice key_sha256 <hex>
enable member alice
ip access-list overlay
permit tcp host 10.8.0.2 host 10.8.0.5 eq 22
dns.servers is pushed in Status and programmed on the client TUN. Queries to the gateway on port 53 (UDP CmdDgram and TCP Open) are answered by the node. dns.upstream is how the node fetches answers: https:// is DNS-over-HTTPS, tls:// is DNS-over-TLS (port 853), a bare IP is UDP/53. quic:// is reserved. Empty servers means the gateway. enabled: false leaves OS DNS alone.
A member key is lt_ plus 48 hex characters (24 bytes). network.conf stores SHA-256. Plaintext lives in {dir}/members.keys (mode 0600), because HMAC and HKDF cannot run from the hash. With keep_keys enable (the default) an operator can re-read that file to emit client YAML again. keep_keys disable shows YAML only at issue; the node still needs the plaintext file.
Reload. systemctl reload loft-node (SIGHUP) re-reads network.conf and members.keys without stopping the node. Members whose entry did not change stay connected; a removed, disabled or edited member is dropped and reconnects with the new settings. A change that clients learn at Hello (CIDR, gateway, MTU, peers, p2p, DNS, routes, VLANs, session timers; rules when p2p is on) reconnects everyone, still without a restart. A file that does not load is logged and the running config stays. node.conf, the certificate and the token still need a restart.
Exit channels. By default members leave through the server’s own route. egress.conf next to network.conf (written by loft-panel) sends them elsewhere:
channel wg-nl dev wg-nl fail px # a kernel interface (WireGuard, OpenVPN, ZeroTier)
channel px socks5 192.0.2.40:1080 user u pass "p w" udp proxy fail direct
channel web http 192.0.2.41:3128 tls fail block # HTTP or HTTPS proxy (CONNECT)
channel office sstp vpn.example.org:443 user office pass x auth mschapv2
rule member bob via px
rule tag office via wg-nl
rule list blocked via web # a list from node.conf lists
default direct
Rules match in order, then default. A channel that is off, has no interface, or failed a connect in the last 20 s hands over to its fail (a channel, direct, block). A proxy may go via another channel. dev binds the socket to the interface (SO_BINDTODEVICE); the system needs a routing rule for it (ip rule add oif DEV lookup TABLE with a default route in TABLE), which loft-panel sets up. TCP streams and member datagrams follow the rules; ICMP and DNS queries to the node leave directly. The node re-reads the file on SIGHUP and writes egress-status.json (state, bytes, connections) every 5 s.
LOFT_TOKEN is the node secret (mask, controller seed). Clients use per-member keys in members.keys. auth shared enable (auth_shared: true in YAML) accepts LOFT_TOKEN as the client secret when the ring is empty — the old one-token overlay. A non-empty ring never mixes the node token, even with that flag on. New installs leave auth shared disable and issue an operator key.
The client YAML carries member_id and that member’s token. Hello member_id is bound from the matching key, not from the client’s claim.
peers |
Overlay Open to another member |
|---|---|
hub |
Reset policy_drop. Internet Open unchanged. Managed CIDR behind a member still splices. |
mesh |
Direct TCP if p2p allows, otherwise splice through the node. |
vlan |
As mesh, same VLAN only. |
Optional commands: create vlan, ip route DST VIA|member ID|tag NAME (next hop is an overlay IP, a member id, or the first live/sticky member with that tag), ip access-list (Cisco extended ACL, first match, permit/deny, tag NAME / group NAME, default deny), config member ID tags …, session timers (handshake_ms, idle_ms, keepalive_ms, lease_hold_ms, gap_hold_ms, ttl_ms) and session rate_bps. ttl_ms is the live-session max (0 = off). lease_hold_ms is IP hold after disconnect. rate_bps is the bulk Data/Dgram cap for a member session, both directions (0 = off). Override with config member ID rate_bps N.
config member lab ipaddress 10.8.0.5
config member lab tags lab
config member alice tags ops
ip route 192.168.10.0/24 member lab
ip route 10.20.0.0/16 tag lab
ip access-list overlay
default deny
permit tcp tag ops tag db eq 5432
permit ip tag ops 192.168.10.0/24
session ttl_ms 86400000
default deny still allows the overlay gateway (DNS). Mesh without that line stays open between members. Status pushes the CIDR to every member except the one whose overlay IP is VIA (that host already has the LAN). TCP Open to 192.168.10.5:22 splices as 192.168.10.5:22 on the lab member; overlay-IP Open still splices as 127.0.0.1:port. Direct P2P uses the same ACL (Status policy). An old Status without policy skips that check.
The client MAY set p2p: false and then uses hub splice only. Direct TCP: the client listens, announces loft.peer:<port> plus a 32-byte X25519 pub, and Status carries the host the node observed on TLS. The preface is LFT (pair ECDH). Behind NAT the attempt fails and the same flow uses the node. Through a relay the observed host is the relay. The client re-handshakes at about 80% of session_ttl_ms.
Example: server/configs/server.yaml.
export LOFT_TOKEN='long-secret'
./loft-node -c server/configs/server.yaml -dir /var/lib/loft
| Flag | Meaning |
|---|---|
-c |
YAML: role, decoy, fronts, roster, TLS |
-dir |
network.conf, members.keys, controller.seed |
-listen |
Override listen address |
-cert -key |
PEM if omitted from YAML |
--version |
Versions |
| Field | Meaning |
|---|---|
listen |
:443 |
cert key |
Node certificate. With decoy+mask a loft client is shown a dest-shaped leaf. |
token |
Node secret (LOFT_TOKEN): mask, controller seed. Member keys are members.keys. |
get_path ws_path h2_path h3_path |
Front paths |
get_h2 |
GET HTTP/2 connections, 1–4 (default 1). Total inflight is shared across them. |
fronts |
Empty = get, ws, h2, quic, h3. connect is mux-off HTTP CONNECT splice; off unless listed |
decoy |
host:port list. First entry is the node identity. Empty = off |
decoy_proxy |
enable: each TCP splice starts with a PROXY v1 line carrying the client address. Only for your own web server (nginx listen … proxy_protocol); a public site would break. Default off. UDP is not affected |
mask |
Dest-shaped ECDSA handshake for loft clients; probes still splice |
role |
controller |
advertise |
Public URL of this front in the roster |
roster_path |
Public GET, default /static/v3/manifest.json |
enrol |
list: id must be in relays:. open: any CapRelay trunk |
auth shared |
enable: LOFT_TOKEN as client secret when members.keys is empty. Default disable. |
relays |
Allowlist: id, url, decoy, fronts, weight, path |
lists |
Name → addresses (domain, .suffix, CIDR) |
lists_path |
Public GET, default /static/v3/lists.json |
canary |
Path canary: fat_hello, split_hello, pad |
cover_dir |
Static site root. Empty = built-in page |
replicas |
Unsigned extra host:port for failover |
quic_alpn |
Default h3 |
Signing seed: {dir}/controller.seed. Give the public key to clients as controller_pub.
Live roster = advertise plus heartbeating relays that are not drain. Three missed beats (~30 s) or a dead ctl stream drop the relay and increment serial.
Paths SHOULD look like assets or APIs of an ordinary site. Defaults in this tree: /static/v3/build, /api/v1/sync, /api/v1/tunnel, /static/v3/manifest.json, /static/v3/lists.json.
Example: server/configs/relay.yaml.
./loft-node -c server/configs/relay.yaml -dir /var/lib/loft-relay
| Field | Meaning |
|---|---|
role |
relay |
id |
Must match relays[].id when enrol: list |
controller |
Hub URL: wss://host/api/v1/sync or https://host |
advertise |
URL clients use for this edge |
decoy mask |
Identity of this IP |
token |
Same as the controller, or relay_token on both |
fronts |
Fronts this edge serves |
cert key |
Public TLS of this IP |
The relay opens a trunk with Hello cap relay, then ctl:1 (enrol, beat) and, per member, hub:1 or hub:2.
Certificates, DNS, and the machine itself are provisioned out of band. Enrol is how the controller learns that the edge is live.
Binary loft. Example clients/go/configs/client.yaml.
./dist/clients/go/loft -c clients/go/configs/client.yaml
ALL_PROXY=socks5://127.0.0.1:1080 curl https://example.com
Local listeners: SOCKS5 (127.0.0.1:1080 in client.yaml and the GUI). CLI loft / loft-front without an explicit port bind 127.0.0.1:18080 so they can sit beside the open client. TUN if tun: auto. -if en0 (or a local/gateway IPv4) pins front sockets to that path; same flag on loft-front, loft-probe, loft-bench.
Wi-Fi / LTE / VPN path change: SOCKS and TUN stay up, overlay IP stays, the helper process stays. The front TCP is redialed with a fresh 0x02 and the same instance_id. With cap hold, mux streams stay for gap_hold_ms (default 8 s) and inner TCP stays up. Leave still tears the session.
Client YAML pause: starlink skips sending mux Data around the known 15 s LEO handover windows. Control frames still go. This is local; it is not a protocol field.
loft-probe checks ICMP, TCP, UDP, DNS, HTTP, and HTTPS through loft without rewriting system routes and without opening utun. Use it when ping works and sites do not, or when default-route is off.
make probe
./dist/clients/go/loft-probe # icmp + tcp + dns + http/https
./dist/clients/go/loft-probe 8.8.8.8 # ICMP echo via the overlay
./dist/clients/go/loft-probe -proto tcp 1.1.1.1:443
./dist/clients/go/loft-probe -proto http https://ifconfig.me/ip # public IP as the node sees it
./dist/clients/go/loft-probe -via socks -proto tcp example.com:443 # live loft SOCKS, no extra session
./dist/clients/go/loft-probe -c "$HOME/Library/Application Support/loft/profiles/example.yaml" 8.8.8.8
./dist/clients/go/loft-probe -if en0 -c "$HOME/Library/Application Support/loft/profiles/example.yaml" 8.8.8.8
-via mux opens its own GET/WS session (covers ICMP). -via socks uses a live SOCKS5 (GUI 127.0.0.1:1080, debug CLI 127.0.0.1:18080; TCP/UDP/HTTP; ICMP skipped). -via auto prefers a profile, then live SOCKS.
| Field | Meaning |
|---|---|
token |
Member key |
instance_id |
Overlay instance. Empty: {config}.instance next to the YAML, created on first run |
select |
auto or an outbound name |
fronts |
Fronts this client refuses |
bootstrap |
HTTPS URL of the roster manifest, before auth |
controller_pub |
32-byte Ed25519 (64 hex). Required to accept a roster |
tofu |
true pins the first valid signer when controller_pub is empty (default off) |
outbounds |
type, url, path, cdn, fingerprint |
use |
List name → direct | via | drop |
default |
Action when no list matches (default via) |
p2p |
false disables direct overlay attempts. Direct TCP is LFT (pair ECDH) |
lists_url |
Override GET for address lists |
listen.socks5 |
Bind address |
listen.tun |
Empty = off; auto creates utun |
listen.tun_addr |
Prefix for the TUN; the address is the overlay IP from Status |
listen.kill_switch |
Hold native default when TUN dies |
Outbound fields: name, type (get | ws | h2 | quic | h3), url, path, cdn (yandex | cloudflare | empty), fingerprint (chrome | firefox | go), header_budget, inflight, insecure_skip_verify.
Omitted header_budget is 16 KiB of HTTP header (Yandex CDN profile pins 12 KiB). Omitted inflight is 8 in this tree (protocol range 2–8, spec default 4). Later GET uplink is header X-Request-Id; the first GET is Cookie session= plus X-Auth: 1. Overlay ICMP is CmdDgram to dst:1; the node answers the gateway and emits internet echo.
The client does not choose decoy SNI. The node advertises it. A CDN front keeps the CDN hostname.
Yandex Cloud CDN (origin group, resource, cache off, 12 KiB GET): yandex-cdn_ru.md.
bootstrap MUST be a host with a real certificate or a CDN. GET to a decoy IP without session HMAC is spliced to the origin.
Roster: GET /static/v3/manifest.json → {"v":1,"p":"<base64url>"}. No member key in the file. After handshake, Status updates the roster. On failure the client tries fronts, unsigned replicas, then the roster.
The controller publishes named sets. The client maps names to actions:
# server.yaml
lists:
banks: [example-bank.test, .bank.example]
video: [youtube.com]
ads: [ads.example]
# client.yaml
default: via
use:
banks: direct
video: via
ads: drop
If a host matches several mapped lists: drop, then direct, then via. SOCKS matches hostname. TUN matches IP (CIDR). Fetch: GET /static/v3/lists.json. Status carries serial and hash.
List names are operator vocabulary. The protocol treats them as opaque strings.
Unauthorized TCP (any SNI, broken hello, plaintext) is spliced to decoy:. UDP on the same port is spliced to the same origin. A loft ClientHello (valid session_id) is handled by the node. With mask: true that path presents a dest-shaped ECDSA leaf; probes still see the origin. cdn_peers lists CIDRs allowed to pull origin on own SNI without sid. When the origin is this host’s nginx, decoy_proxy enable lets it see the real client address (logs, limits, the panel’s login guard).
session_id is 32 bytes. sid v3 is HKDF under ECDH with the node X25519 (mask_pub / share-URI pbk). sid v2 (fallback) is HKDF under the member key. A stolen member key marks v2 hellos only.
Origin hostnames in example YAML are examples. Choose origins whose public site the path is unwilling to drop, and whose TLS the node can splice. They are not compiled into the protocol.
Virtual machines, DNS, public certificates, and binary updates are outside the protocol. The wire carries enrol, beat, slice, drain, roster, and lists.
A typical deployment:
network.conf and start the controller with -dir.controller_pub and a bootstrap URL on a host that is not a decoy-spliced IP.id matching relays:.client.yaml (or a .loft file).| Symptom | Check |
|---|---|
token is required |
LOFT_TOKEN or token: |
network disabled |
enable in network.conf |
| TLS hangs | decoy SNI, mask, certificate, session_id |
| Manifest is foreign HTML | bootstrap hit a decoy IP without HMAC |
Relay enrol_no id |
id missing from relays: with enrol: list |
QUIC fails with h3 on |
UDP ALPN h3 is taken by CONNECT; fail over to h3 |
auth_already_online |
another instance holds the key; enable multi or stop the other |
auth_limit |
multi_limit |
policy_drop |
overlay Open under peers: hub, or an ACL drop |
| SOCKS works, TUN silent | tun: auto, privileges, excluded outbound hosts |
| Native default gone after TUN death | kill_switch; restore routes or reconnect |
| Ping ~1 s on GET | idle GET pace is keepalive/2 (floor 1 s); reconnect so the client has catching-down after Dgram |
| Upload stalls / TLS bad record | GET inflight omitted used to default to 1; this tree now defaults to 8 |
The node on a Debian host: server/debian/README.md.
make dist-node
# copy dist/node/linux-amd64/loft-node-<version>-linux-amd64.tar.gz to the host
tar -xzf loft-node-*-linux-amd64.tar.gz
cd loft-node-*-linux-amd64
sudo ./install.sh --unattended
Relay:
sudo ./install.sh --role=relay --id=ru1 \
--controller=wss://controller.example/api/v1/sync \
--advertise=https://203.0.113.10
Debian package: make dist-node, then dpkg -i dist/node/linux-amd64/loft-node_*_amd64.deb.
The installer creates user loft, /etc/loft/loft.env (token), a self-signed certificate, systemd unit, and /root/loft-client.yaml. Token stays in the environment file. loft-node -print-pub prints controller_pub.
make test
go test ./internal/wire ./internal/crypto ./internal/mux ./internal/netconf ./internal/transport
Wire tests cover frames, Hello/Status, roster tamper, lists, fleet. Transport tests cover decoy session_id, roster fetch, overlay policy. Mask on TCP loopback MAY skip on some hosts. make test also runs server/debian/install_os_test.sh.
Push and pull request on main run make test.
A tag vX.Y.Z must match the root VERSION file. That tag builds:
.deb (linux amd64, arm64)loft, loft-front, loft-probe, loft-bench for linux/darwin, amd64/arm64)loft-*-macos-arm64.dmg and loft-*-macos-x86_64.dmg (BUMP=0 so CI does not rewrite clients/macOS/VERSION). The runner is Apple Silicon; Intel is a Swift/Go cross-compile (ARCH=x86_64).and publishes a GitHub Release. Dist also fetches the tag into proxy.golang.org and requests pkg.go.dev so the module page follows the release. The same files are also uploaded without the version in the name (loft-macos-arm64.dmg, loft-linux-amd64.tar.gz, …) so README download links at /releases/latest/download/… follow the current tag. Re-running the workflow on the same tag replaces the files. Without a tag, Actions → Dist → Run workflow only uploads artifacts.
Local packages: make dist. Local DMG without bumping the macOS marketing version: make macos-dmg BUMP=0. Developer ID and notarization stay on a Mac with a keychain; they are not in Actions.