Multipath file transfer over Tailscale exit-node relays. Before every large transfer, tailroad calibrates all available paths in parallel and allocates chunks to the paths that actually deliver.
tailroad is a single static binary with three modes:
| Mode | Role | Purpose |
|---|---|---|
tailroad server |
B (receiver) | Receives the file, writes chunks at their offsets, verifies SHA-256 |
tailroad relay |
exit node | Token-authenticated relay agent that forwards A's traffic through itself to B |
tailroad send |
A (sender) | Auto-discovers every exit node, calibrates paths in parallel, schedules chunks dynamically |
tailroad tui |
A (sender) | Interactive sender: pick B from the tailnet, enter token/file, watch calibration and progress live |
┌─ relay: exit-node-1 ─┐
├─ relay: exit-node-2 ─┤
A (tailroad send) ───┼─ relay: exit-node-3 ─┼──> B (tailroad server)
├─ ... ─┤
└─ direct A -> B ─┘
Each row is an independent TCP path used simultaneously:
direct : A -> B
relay-x : A -> exit-node-x -> B (one path per exit node)
Path discovery comes from tailscale status --json: every peer with
ExitNodeOption=true becomes a candidate relay, plus the direct path.
- Parallel calibration before transfer. All paths send
--calibratebytes (default 10 MiB per path) at the same time. tailroad records each path's contribution under contention, not its solo speed. - Stream allocation from parallel contribution.
streams_i = round(total_streams * mbps_i / sum(mbps)), minimum 1 per path. - Dynamic chunk queue. The file is split into
--chunk-sized chunks. Any idle stream grabs the next chunk, so faster paths naturally carry more. - Slow-chunk timeout and re-queue. Every chunk gets a deadline estimated from that path's calibrated bandwidth (minimum 15s). Timed-out chunks return to the queue and can be claimed by another stream. Writes are offset-based and idempotent, so re-sending a chunk is harmless.
- Integrity. Every chunk carries a SHA-256 digest; B verifies before
writing. On completion the
.partfile is atomically renamed.
Requires Go 1.27+:
git clone git@github.com:amogadget/tailroad.git
cd tailroad
make build # go build -o tailroad .
# Cross-compile all supported targets
make all
# bin/tailroad-darwin-arm64
# bin/tailroad-linux-amd64
# bin/tailroad-linux-arm64TOKEN=$(openssl rand -hex 16)A, B and every relay use the same --token. Never commit it.
mkdir -p /data/incoming
tailroad server -listen 100.64.0.2:7443 -root /data/incoming -token "$TOKEN"Replace 100.64.0.2 with B's own Tailscale IP (tailscale ip -4).
Binding only the Tailscale IP keeps the port off the public internet.
Upload the binary for the node's architecture, then:
tailroad relay -listen 100.64.0.3:9443 -token "$TOKEN"Replace 100.64.0.3 with that node's own Tailscale IP. If the port is taken
(8443 is commonly occupied by nginx), pick another and pass the same port to
the sender with --relay-port.
systemd unit example (/etc/systemd/system/tailroad-relay.service):
[Unit]
Description=tailroad relay
After=tailscaled.service
[Service]
ExecStart=/usr/local/bin/tailroad relay -listen 100.64.0.3:9443 -token REPLACE_WITH_TOKEN
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.targettailroad send \
-server 100.64.0.2:7443 \
-file /path/to/big.iso \
-token "$TOKEN"Auto-discovery is on by default. When Tailscale runs inside Docker (e.g. CasaOS), tell tailroad how to reach the CLI:
tailroad send \
-server 100.64.0.2:7443 \
-file /path/to/big.iso \
-token "$TOKEN" \
--tailscale-cmd 'docker exec tailscale tailscale'Manual relay list (skips discovery):
tailroad send -server 100.64.0.2:7443 -file big.iso -token "$TOKEN" \
--relay-hosts 100.64.0.3:9443,100.64.0.4:9443tailroad tuiThe TUI lists online tailnet peers for receiver selection, then asks for the
token and file path. During the run it shows the live calibration table, an
overall progress bar, aggregate throughput, retried chunks, and per-path
bytes. q quits; if a transfer is running it keeps running in the background
(this will be made resumable later).
No manifest needed. With SSH aliases in ~/.ssh/config and make all done:
tailroad deployThat is it. By default:
- B is the current machine (
--server local); use--server <ssh-alias>to deploy B remotely - every exit node found in the tailnet is matched against your SSH aliases and gets a relay automatically
- the token is created once at
~/.config/tailroad/tokenand reused - services bind to each node's own Tailscale IP
tailroad deploy --server my-receiver --root /data/incoming --systemd
tailroad deploy --dry-run # print the plan without touching anything
# when tailscale runs in Docker (e.g. CasaOS):
tailroad deploy --tailscale-cmd 'docker exec tailscale tailscale'An explicit JSON manifest is still supported for unusual topologies:
tailroad deploy -config deploy.jsonSee deploy.example.json. JSON fields: token_file, server{ssh|local,root,port,systemd,tailscale_ip},
and relays[{...}].
| Option | Default | Description |
|---|---|---|
-server |
required | B address host:port (port defaults to 7443) |
-file |
required | Local file on A |
-token |
required | Shared auth token |
--relay-port |
9443 |
Relay listen port |
--auto-relays |
true |
Discover all tailnet exit nodes automatically |
--tailscale-cmd |
tailscale |
Command used for discovery, e.g. docker exec tailscale tailscale |
--relay-hosts |
empty | Manual host:port,... list (overrides auto-discovery) |
--calibrate |
10485760 |
Calibration bytes per path (0 disables calibration) |
--bench-streams |
2 |
Parallel streams per path during calibration |
--chunk |
4194304 |
Transfer chunk size |
--streams |
12 |
Total transfer streams |
--max-per-path |
4 |
Maximum streams on a single path |
1 GB transfer with 5 exit-node relays plus direct:
- Parallel calibration total: 220 Mbps
- Transfer: 39.9s, ~215 Mbps
- 8 slow chunks timed out and were re-queued,
errors=0, SHA-256 matched
- All data flows inside Tailscale WireGuard tunnels and is not exposed to the public internet.
--tokenis the only authentication: server and every relay validate it. Generate it withopenssl rand -hex 16and inject via env/file instead of leaving it in shell history.- Bind relays to the node's Tailscale IP; do not use
0.0.0.0. - If a token leaks: rotate it and restart the server and all relays.
- Upload direction only (A -> B).
- The server handles one transfer at a time.
- No resume yet (
.partis kept after failures but a new transfer truncates it). - No compression.
基于 Tailscale exit-node relay 的多路径文件传输。每次大文件传输前,先对所有可用路径做并行校准,再把分块动态分配给真正能跑出带宽的路径。
tailroad 是单二进制,三种模式:
| 模式 | 角色 | 作用 |
|---|---|---|
tailroad server |
B(接收端) | 接收文件,按 offset 写盘,校验 SHA-256 |
tailroad relay |
exit node | 带 token 认证的转发 agent,把 A 的流量经自己中转到 B |
tailroad send |
A(发送端) | 自动发现所有 exit node,并行校准,动态分块调度 |
tailroad tui |
A(发送端) | 交互式发送:从 tailnet 选 B、填 token/文件、实时看校准和进度 |
┌─ relay: exit-node-1 ─┐
├─ relay: exit-node-2 ─┤
A (tailroad send) ───┼─ relay: exit-node-3 ─┼──> B (tailroad server)
├─ ... ─┤
└─ direct A -> B ─┘
每一行都是一条同时使用的独立 TCP 路径:
direct : A -> B
relay-x : A -> exit-node-x -> B (每个 exit node 一条)
路径发现来自 tailscale status --json:所有 ExitNodeOption=true 的节点都会成为候选 relay,另外始终包含 direct 路径。
- 传输前并行校准:所有路径同时各传
--calibrate字节(默认每条路 10 MiB),记录的是路径在“互相竞争”下的真实贡献,而不是单独测速。 - 按并行贡献分配并发流:
streams_i = round(total_streams * mbps_i / sum(mbps)),每条路至少 1 条流。 - 动态 chunk 队列:文件切成
--chunk大小的块,任何空闲流都可以抢下一个块,快路自动多传。 - 慢 chunk 超时重新入队:每个块按该路径校准带宽估算超时预算(最少 15s);超时后回到队列,其他流可以抢。写入按 offset 幂等,重复发送无害。
- 完整性:每个 chunk 带 SHA-256,B 校验通过才写盘;完成后
.part原子 rename 成最终文件。
需要 Go 1.27+:
git clone git@github.com:amogadget/tailroad.git
cd tailroad
make build # go build -o tailroad .
# 交叉编译全部平台
make all
# bin/tailroad-darwin-arm64
# bin/tailroad-linux-amd64
# bin/tailroad-linux-arm64TOKEN=$(openssl rand -hex 16)A、B 和所有 relay 使用同一个 --token,不要提交到仓库。
mkdir -p /data/incoming
tailroad server -listen 100.64.0.2:7443 -root /data/incoming -token "$TOKEN"把 100.64.0.2 换成 B 自己的 Tailscale IP(tailscale ip -4)。
只监听 Tailscale IP 时端口不会暴露到公网。
上传对应架构的二进制后:
tailroad relay -listen 100.64.0.3:9443 -token "$TOKEN"把 100.64.0.3 换成该节点自己的 Tailscale IP。端口被占用(8443 常被 nginx 占用)就换一个,并在发送端用 --relay-port 指定相同端口。
systemd 示例(/etc/systemd/system/tailroad-relay.service):
[Unit]
Description=tailroad relay
After=tailscaled.service
[Service]
ExecStart=/usr/local/bin/tailroad relay -listen 100.64.0.3:9443 -token REPLACE_WITH_TOKEN
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.targettailroad send \
-server 100.64.0.2:7443 \
-file /path/to/big.iso \
-token "$TOKEN"默认开启自动发现。Tailscale 跑在 Docker 里时(如 CasaOS):
tailroad send \
-server 100.64.0.2:7443 \
-file /path/to/big.iso \
-token "$TOKEN" \
--tailscale-cmd 'docker exec tailscale tailscale'手动指定 relay(跳过发现):
tailroad send -server 100.64.0.2:7443 -file big.iso -token "$TOKEN" \
--relay-hosts 100.64.0.3:9443,100.64.0.4:9443tailroad tuiTUI 会列出 tailnet 在线节点供选择接收端 B,然后输入 token 和文件路径。运行中显示实时校准表、总进度条、聚合吞吐、重试 chunk 数和每条路的字节数。q 退出;若传输还在跑,进程会在后台继续(后续会做成可恢复)。
不需要写清单。只要 ~/.ssh/config 里有 SSH 别名,并且先 make all:
tailroad deploy就这一条。默认行为:
- B 是当前机器(
--server local);用--server <ssh-alias>可部署到远端 - 自动发现 tailnet 里所有 exit node,并和你的 SSH 别名匹配,逐个装上 relay
- token 首次生成在
~/.config/tailroad/token,之后复用 - 所有服务都绑定各节点自己的 Tailscale IP
tailroad deploy --server my-receiver --root /data/incoming --systemd
tailroad deploy --dry-run # 只打印计划,不做任何修改
# Tailscale 跑在 Docker 里时(如 CasaOS):
tailroad deploy --tailscale-cmd 'docker exec tailscale tailscale'特殊拓扑仍可用显式 JSON 清单:
tailroad deploy -config deploy.json见 deploy.example.json。JSON 字段:token_file、server{ssh|local,root,port,systemd,tailscale_ip}、
relays[{...}]。
| 参数 | 默认 | 说明 |
|---|---|---|
-server |
必填 | B 地址 host:port(无端口时默认 7443) |
-file |
必填 | A 上的本地文件 |
-token |
必填 | 共享认证 token |
--relay-port |
9443 |
relay 监听端口 |
--auto-relays |
true |
自动发现 tailnet 所有 exit node |
--tailscale-cmd |
tailscale |
发现用的 tailscale 命令,如 docker exec tailscale tailscale |
--relay-hosts |
空 | 手动 host:port,... 列表(覆盖自动发现) |
--calibrate |
10485760 |
每条路校准字节数(0 跳过校准) |
--bench-streams |
2 |
校准阶段每条路并发流数 |
--chunk |
4194304 |
传输分块大小 |
--streams |
12 |
传输阶段总并发流数 |
--max-per-path |
4 |
单条路最大并发流数 |
5 个 exit-node relay + direct 的 1 GB 传输:
- 校准并行总带宽:220 Mbps
- 传输:39.9s,约 215 Mbps
- 8 个慢 chunk 超时后重新入队,最终
errors=0,SHA-256 一致
- 所有数据都在 Tailscale WireGuard 隧道内,公网不可见。
--token是唯一认证:server 和所有 relay 都会校验。用openssl rand -hex 16生成,并通过环境变量或文件注入,避免留在 shell 历史里。- relay 请绑定节点的 Tailscale IP,不要用
0.0.0.0。 - token 泄露时:轮换 token,并重启 server 和所有 relay。
- 只支持 A → B 上传方向。
- server 同一时间只处理一个传输。
- 断点续传未实现(失败后
.part会保留,但新传输会重新 truncate)。 - 不做压缩。