Skip to content

Repository files navigation

tailroad

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

Architecture

                     ┌─ 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.

How it works

  1. Parallel calibration before transfer. All paths send --calibrate bytes (default 10 MiB per path) at the same time. tailroad records each path's contribution under contention, not its solo speed.
  2. Stream allocation from parallel contribution. streams_i = round(total_streams * mbps_i / sum(mbps)), minimum 1 per path.
  3. Dynamic chunk queue. The file is split into --chunk-sized chunks. Any idle stream grabs the next chunk, so faster paths naturally carry more.
  4. 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.
  5. Integrity. Every chunk carries a SHA-256 digest; B verifies before writing. On completion the .part file is atomically renamed.

Build

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-arm64

Deployment

0. Generate a shared token

TOKEN=$(openssl rand -hex 16)

A, B and every relay use the same --token. Never commit it.

1. Start the server on B

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.

2. Deploy a relay on every exit node

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.target

3. Send from A

tailroad 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:9443

Interactive TUI

tailroad tui

The 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).

One-command deployment

No manifest needed. With SSH aliases in ~/.ssh/config and make all done:

tailroad deploy

That 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/token and 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.json

See deploy.example.json. JSON fields: token_file, server{ssh|local,root,port,systemd,tailscale_ip}, and relays[{...}].

send options

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

Benchmark reference

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

Security notes

  • All data flows inside Tailscale WireGuard tunnels and is not exposed to the public internet.
  • --token is the only authentication: server and every relay validate it. Generate it with openssl rand -hex 16 and 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.

Current limitations

  • Upload direction only (A -> B).
  • The server handles one transfer at a time.
  • No resume yet (.part is kept after failures but a new transfer truncates it).
  • No compression.

tailroad(中文)

基于 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 路径。

工作机制

  1. 传输前并行校准:所有路径同时各传 --calibrate 字节(默认每条路 10 MiB),记录的是路径在“互相竞争”下的真实贡献,而不是单独测速。
  2. 按并行贡献分配并发流:streams_i = round(total_streams * mbps_i / sum(mbps)),每条路至少 1 条流。
  3. 动态 chunk 队列:文件切成 --chunk 大小的块,任何空闲流都可以抢下一个块,快路自动多传。
  4. 慢 chunk 超时重新入队:每个块按该路径校准带宽估算超时预算(最少 15s);超时后回到队列,其他流可以抢。写入按 offset 幂等,重复发送无害。
  5. 完整性:每个 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-arm64

部署

0. 生成共享 token

TOKEN=$(openssl rand -hex 16)

A、B 和所有 relay 使用同一个 --token,不要提交到仓库。

1. 在 B 端启动 server

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 时端口不会暴露到公网。

2. 在每个 exit node 部署 relay

上传对应架构的二进制后:

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.target

3. 从 A 端发送

tailroad 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:9443

交互式 TUI

tailroad tui

TUI 会列出 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[{...}]。

send 参数

参数 默认 说明
-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)。
  • 不做压缩。

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages