Tailcat

Tailcat Logo

"Tailscale without Tailscale, by Tailscale"

Tailcat

Tailcat是Tailscale开源组件的一个重制版,可以像netcat一样工作,使用Tailscale的数据面,但不使用Tailscale的控制面。Tailscale数据面(内部称为magicsock)为两台机器之间提供点对点的WireGuard®加密隧道,DERP则作为NAT穿透的通信备用通道,以及在NAT穿透失败时的最终中继。你可以不通过Tailscale控制面,随意以任何方式交换tailcat连接的元数据。

tailcatCLI基于tailcat Go库(可通过github.com/tailscale/tailcat导入)构建。

无论你是把tailcat当作CLI工具还是库来用,一端都会运行tailcat服务器(监听器)并获取一个临时令牌。另一端则把这个令牌传给tailcat的客户端来建立连接。双方之间的所有流量都会通过WireGuard进行端到端加密。初始连接会通过DERP服务器启动,然后magicsock会执行NAT穿透,在可能的情况下升级为直接的点对点UDP连接(正常情况)。

不需要Tailscale账号,也不需要管理员(root)权限,它不会更改你的路由表、DNS等配置, tailcat只是一个用户空间的库和命令行工具。

而且这一切都是开源的。

你可以使用我们免费的限速DERP中继(https://tailcat.dev/derpmap.json),或者你也可以自己搭建一个DERP。

这里有一个实验性的浏览器网页版演示(tailcat被编译成了WebAssembly),地址为https://tailscale.github.io/tailcat/,它可以发送和接收文件或文本,并与CLI互操作。浏览器的流量仅通过DERP中继,除非支持WebRTC才会直接连接。

安装tailcat

$ go install github.com/tailscale/tailcat/cmd/tailcat@latest

也可以通过Nix flakes方式运行或安装:

$ nix run github:tailscale/tailcat
$ nix profile install github:tailscale/tailcat

使用tailcat

标准输入/输出管道

启动监听服务,显示临时地址:

$ tailcat
# Selected bootstrap relay region 302, San Francisco
# 🐈 Server listening with new address: tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
(hangs, waiting...)

客户端发送内容:

$ echo hello | tailcat tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu

服务端解析并显示内容:

$ tailcat
# Selected bootstrap relay region 302, San Francisco
# 🐈 Server listening with new address: tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
hello

通过隧道暴露本地端口

将本地TCP端口转发到localhost来提供服务:

$ tailcat --serve=8080,8443 # or --serve=all
# 🐈 Server listening with new address: tcXXXXXXXXX

客户端连接:

$ tailcat tcXXXXXXXXX 8080
GET / HTTP/1.1
Host: foo

HTTP/1.1 200 OK
....

免认证SSH服务

在Linux或macOS系统中,可以运行一个免认证的SSH服务。如果想要认证,运行tailcat --serve=22然后代理到你的系统SSH服务器即可。

$ tailcat --serve=no-auth-ssh
# 🐈 Server listening with new address: tcXXXXXXXXX

客户端连接:

$ tailcat ssh tcXXXXXXXXX
$ tailcat ssh tcXXXXXXXXX ls -la

tailcat其他用法

用Ping来测试连接;每个pong会报告它是通过DERP中继连接还是直连。--until-direct 会不断ping(最多到--timeout,默认10秒),直到两者直连,如果没有则以非零状态退出:

$ tailcat ping --until-direct <token>
pong in 42.1ms via DERP(sfo)
pong in 1.2ms via 203.0.113.7:41641

通过隧道走SOCKS5代理运行命令:

$ tailcat socks <token> curl http://server.tailcat:8081/

令牌也可以作为URL主机名使用,SOCKS代理会识别并连接它们,所以token参数是可选的。令牌区分大小写,这在curl和大多数命令行工具中可用,但浏览器不行,因为浏览器会把主机名转换成小写。

$ tailcat socks curl http://<token>:8081/

充当出口节点(exit-node),让客户端可以访问服务器的网络:

$ tailcat --serve=exit-node

解析连接令牌并以JSON输出内容,包含服务器的WireGuard公钥和DERP信息:

$ tailcat parse tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
{
    "ServerPublic": "nodekey:9c8d2e6728da80a1dd37e275a82595b42d9a838610bc53f74a7670d1610f2e34",
    "RegionID": 302
}

将短令牌(通过ID引用DERP域,要求客户端获取DERP地图)解析为一个更长的自包含令牌,其中嵌入了DERP服务器信息,让客户端能够更快连接:

$ tailcat resolve tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFygaFhToGjYWhudGMzMDJhLmlwbi5kZXZhNG0yMDguMTExLjM5LjM4YTZzMjYwNzpmNzQwOjA6M2Y6OjcyMA

解析上面自包含令牌显示嵌入的DERP信息:

$ tailcat parse tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFygaFhToGjYWhudGMzMDJhLmlwbi5kZXZhNG0yMDguMTExLjM5LjM4YTZzMjYwNzpmNzQwOjA6M2Y6OjcyMA
{
    "ServerPublic": "nodekey:9c8d2e6728da80a1dd37e275a82595b42d9a838610bc53f74a7670d1610f2e34",
    "Region": [
        {
            "Nodes": [
                {
                    "HostName": "tc302a.ipn.dev",
                    "IPv4": "208.111.39.38",
                    "IPv6": "2607:f740:0:3f::720"
                }
            ]
        }
    ]
}

服务器可以直接使用 --full-address 参数打印完整的自包含格式。

密钥管理

服务器地址(连接令牌)是由它的WireGuard密钥生成的,所以你使用的密钥决定了谁能连接到你:

CLI启动时会显示它使用的秘钥类型,这样你就能知道自己是启动了一个新的临时服务,还是在重新监听以前使用过的地址。

$ tailcat genkey --region=nyc
# prints the token; key saved to ~/.config/tailcat/keys/default.private.json

# later; the key named "default" is used automatically once it exists:
$ tailcat --serve=8080
# 🐈 Server listening with saved key "default": tcXXXXXXXXX

# ... unless you force a one-off ephemeral key:
$ tailcat --serve=8080 --key=new
# 🐈 Server listening with new address: tcXXXXXXXXX

default是一个魔法密钥名,一旦它存在,tailcat 就会悄悄地使用它,而不是生成临时密钥,上面那行启动信息告诉你发生了什么。

令牌也可以作为DNS TXT记录发布,并通过名称查找;DNS名称可以在CLI使用令牌的任何地方使用:

# If example.com has a TXT record "tailcat=tc..."
$ tailcat example.com 8080
$ tailcat ssh example.com
$ tailcat ping example.com

tailcat用例

通过DNS保护SSH服务

运行一个可通过域名访问的SSH服务,服务端无需开放任何入站端口,WireGuard会在SSH服务接收到数据包之前完成客户端身份验证。

在客户端机器上,生成一个密钥对。它会打印出公钥,而服务端只需要知道这个公钥:

client$ tailcat genkey --client
# wrote file to ~/.config/tailcat/keys/client-default.private.json
nodekey:cfb6bfa77a0654d7450947fd6acef17d2cd848da1d30b2540b13dac272ddfd16

在服务端,生成一个固定到最近DERP域的密钥对(原因见下文),然后仅对该客户端提供SSH服务:

server$ tailcat genkey --fixed-region
# wrote file to ~/.config/tailcat/keys/default.private.json
tcXXXXXXXXX

server$ tailcat --serve=22 --allow=nodekey:cfb6bf...ddfd16
# 🐈 Server listening with saved key "default": tcXXXXXXXXX

修改DNS,将令牌以TXT记录添加。

my-server.example.com. 300 IN TXT "tailcat=tcXXXXXXXXX"

客户端可通过以下方式连接SSH服务。

client$ tailcat ssh my-server.example.com

客户端检测到已保存的client-default密钥时会自动使用它,因此无需额外参数。其他任何人的握手请求都会被静默忽略,他们无法访问SSH服务器,甚至无法得知其存在。

为什么用--fixed-region:它在生成密钥时探测最近的DERP域,并将ID固化到令牌和保存的密钥文件中,这样服务端重启后会绑定到同一DERP域,无需重新探测。

普通的tailcat genkey默认使用--region=auto,即启动时自动选择,适合临时使用。但发布到DNS的令牌应指定固定DERP域,以便客户端和未来的服务端重启都在同一地点汇合。

TODO:如果DERP映射随时间变化,需提升客户端的健壮性:https://github.com/tailscale/tailcat/issues/7

自建DERP中继服务器

你可以不使用Tailscale的官方中继服务,可以自建DERP中继服务器,然后将其主机名添加到--region=,生成使用该中继的服务端密钥:

server$ tailcat genkey --region=derp.example.com
tcomFwWCCAIsKOqPUux6ClG2RM4A_vOq4VBzGgHGGjq9OsJuFKSWFygaFhToGhYWhwZGVycC5leGFtcGxlLmNvbQ

server$ tailcat --serve=22

这个令牌会嵌入你的中继主机名:

$ tailcat parse tcomFwWCCAIsKOqPUux6ClG2RM4A_vOq4VBzGgHGGjq9OsJuFKSWFygaFhToGhYWhwZGVycC5leGFtcGxlLmNvbQ
{
    "ServerPublic": "nodekey:8022c28ea8f52ec7a0a51b644ce00fef3aae150731a01c61a3abd3ac26e14a49",
    "Region": [
        {
            "Nodes": [
                {
                    "HostName": "derp.example.com"
                }
            ]
        }
    ]
}

客户端无需额外参数,也不会连接Tailscale的DERP服务器或中继,唯一的速率限制由你自己决定。另外,如果你运营了一整套中继集群,可以提供自己的DERP映射JSON,并让两端通过--derpmap-url指向它。

Go库使用

一个极简服务器,通过隧道响应所有TCP端口,并打印令牌。零值Server会为未设置的内容选择默认值:一个新的临时密钥、默认DERP最近域,以及 log.Printf 日志记录(设置 Logf 为 logger.Discard 可以安静运行):

package main

import (
	"fmt"
	"log"
	"net"

	"github.com/tailscale/tailcat"
)

func main() {
	s := &tailcat.Server{
		OnTCP: func(port uint16) func(net.Conn) {
			return func(c net.Conn) {
				fmt.Fprintf(c, "hello from port %v\n", port)
				c.Close()
			}
		},
	}
	if err := s.Start(); err != nil {
		log.Fatal(err)
	}
	fmt.Println(s.ConnBlob())
	select {}
}

一个最简客户端,它会拨号,同时将这个令牌作为参数。就像Server一样,Client的零值只要设置了它的Server令牌字段(tailcat.NewClient 就是这种用法的简写),隧道会在第一次拨号时加载建立:

package main

import (
	"context"
	"io"
	"log"
	"os"

	"github.com/tailscale/tailcat"
)

func main() {
	cl := tailcat.NewClient(tailcat.ConnBlob(os.Args[1]))
	defer cl.Close()
	c, err := cl.DialTCPPort(context.Background(), 80)
	if err != nil {
		log.Fatal(err)
	}
	io.Copy(os.Stdout, c)
}
$ ./client tcomFwWCAWf933BLELdzd3RkHiOufJ...
hello from port 80

tailcat是怎么运作的

连接令牌

Tailcat服务器通过连接令牌(内部称为ConnBlob)来区分。格式如tcXYZ...,tc前缀加base64编码的CBOR,其中包含:

一个只包含整数区域ID的典型令牌大约是50字节。加入嵌入的DERP节点细节后会更长,但它是自包含的。

tailcat网络技术栈

Tailcat重用了Tailscale的客户端网络组件,但未使用控制平面。

tailcat连接流程

  1. 服务启动会生成(或加载)一个WireGuard密钥对,连接到DERP中继,并将其连接令牌打印到标准错误。然后等待客户端连接。
  2. 客户端解析令牌以获取服务器的公钥和DERP域。它生成自己的临时密钥对,并连接到同一个DERP中继。
  3. 发现握手,客户端通过DERP中继向服务器发送“喵”ping 消息。该消息携带客户端的节点公钥。服务器收到后,将客户端添加到其WireGuard对等列表和网络地图中,重新配置WireGuard引擎,并回复一个“喵过了”的确认。
  4. WireGuard隧道,双方都配置为WireGuard对等节点后,标准的WireGuard握手就会进行(最初通过DERP路由)。完成后,隧道建立,加密流量即可传输。
  5. NAT穿透,与此同时,每一方都会通过DERP在disco call-me-maybe消息中把自己的UDP端点(通过STUN获取的公共IP:端口,以及本地接口地址)广播给对方,并在信息变化时重新广播。然后双方会运行 Tailscale的disco协议并尝试UDP打洞。如果成功,流量就可以从DERP中继升级为直接的点对点路径。如果打洞失败,DERP会继续作为备用连接,连接仍然可以使用,只是在使用我们公共托管的DERP中继时,吞吐量会受到限制。
  6. 数据传输,客户端通过隧道连接服务器上的TCP端口。双方的gVisor TCP/IP协议栈负责连接建立。在服务器端,传入的连接会根据端口分发到相应的处理器:转发到本地主机、输出到标准输出、运行SSH会话等等。

寻址

每个节点目前都是从它的WireGuard公钥生成一个确定性的IPv6地址,但这是一个实现细节,对最终用户不可见,而且可能会改变。(例如,我们可能会完全从IP头中移除这些字节,从而回收那些多余的MTU)

稳定性

Tailcat是免费的,但它不保证API或CLI的稳定性:Go API、CLI 参数和输出,以及传输格式都可能会变化。公开的限速Tailcat DERP中继没有正常运行时间SLA或吞吐量目标,我们可能随时以任何理由撤销访问权限。所有功能都是尽力提供的,没有任何合同关系(例如专用 DERP 中继和/或支持)另作保证。

tailcat历史

Tailcat的诞生可以追溯到2023年9月,当时它叫做derpcat,是在一次长途飞行中写的,同时还在补看一些烂片:第一个草图是提交9e4d925cc,第一次正常运行是在提交911915fbb(“derpcat: it’s alive!”,提交信息中提到“UA 605 PDX-ORD 飞往爱尔兰。耶,不买机上 Wi-Fi。”)。当时它存在于tailscale.com仓库的一个分支中,随着Tailscale内部代码的变化,它好几次陷入了腐烂。之后,我们又让它重获新生,并重构成了tailscale.com仓库的一个常规Go模块客户端,而不是仓库的一个分支。

tailcat它在2026年8月的TailscaleUp大会上正式发布。

https://github.com/tailscale/tailcat