一台 Linux 服务器,一个管理员,多个互相独立的 TeamSpeak 3。这篇文章记录 ts3panel 从规格书到 1.0 的开发过程:怎么切边界、怎么做减法、哪九条假设被真机打回,以及为什么最后只有一个二进制。

TeamSpeak 3 的部署,看起来只是“起个容器”,真正做起来却全是碎活:
- 每个实例要占四个端口:语音
9987+i/udp、文件传输30033+i/tcp、ServerQuery10011+i/tcp、SSH Query10022+i/tcp; - 客户端靠 SRV 记录找端口,只写 A 记录不够;
- 管理员 token 只在首次启动日志里出现一次,错过就得进客户端重新生成;
- 数据目录里是
ts3server.sqlitedb和身份密钥,备份、迁移、还原都有讲究。
手工做一次十分钟,做十次就开始出错。现成的工具各有取舍:一键脚本通常只管单实例;Portainer 很通用,但每一步都要手工点;ts3-manager 偏只读查询。于是有了 ts3panel —— 一个只做“多实例 TS3 编排”的 Web 面板。
先把截图放上来。界面是深色的,因为这类面板多半是在深夜的 SSH 隧道里打开的。


截图中的域名、IP、token 都是演示数据。
一、先把“不做什么”写下来
这个小项目的功能边界,比功能列表更早确定。README 里有一节叫“明确不做”:
- 不做在线人数与频道管理;
- 不做公网 HTTPS、反向代理和证书;
- 不做实例克隆;
- 不代管云厂商安全组;
- 不做多租户注册;
- 不支持 Windows 宿主机。
这些不是“以后再说”,而是从设计上就不进入代码。理由很实际:每多一个功能,就多一组权限、状态和失败路径;而这是一个自用与小圈子场景的面板,一个管理员、一台机器、默认上限 20 个实例,把少数路径做扎实,比把功能铺满更有价值。
功能上只保留五件事:
- 三步建实例:填名字和子域名,自动分配端口、建目录、起容器、抓 token;
- 域名自动化:Cloudflare、阿里云 DNS、DNSPod 一键写 A + SRV,失败给可照抄的手动清单;
- 端口看板:把要放行的端口合并成区间,一键复制成工单;
- 状态与详情:容器状态、脱敏日志、CPU / 内存快照、数据目录大小;
- 备份恢复:手动 / 自动备份、跨机器还原。
对应的取舍写在对比表里,也写在代码注释里。比如“不做在线人数”这一条,省掉的不只是一个页面,还有 ServerQuery 长连接、凭据管理、轮询节流和一整套新的失败模式。
二、先写规格,再写代码
这个项目的开发顺序有点反直觉:先有一份规格书,才有第一行代码。
规格书不是一份 PRD 摆设。它固定了六件最容易在实现中途扯皮的事:
- 端口布局(§10.1):一个索引决定四个端口;
- 创建流程的状态机(§11.3):先落盘、再动磁盘、失败怎么回滚;
- DNS Provider 契约(§12.1):四个方法,错误码统一;
- 验证标准(§13):以系统解析器为准,轮询 5 秒、最多 180 秒;
- 错误码与响应结构(§15.4):页面能直接照着渲染;
- 配置与状态结构(§9.2):哪些进
config.toml,哪些进state.json。
这些章节号后来直接留在了代码注释里。比如端口分配的开头是:
// Port layout from the specification (§10.1). Every instance owns four
// consecutive-ish ports derived from one index; query ports stay on loopback.
const (
VoicePortBase = 9987
FileTransferBase = 30033
QueryPortBase = 10011
QuerySSHPortBase = 10022
)
实例创建里也能看到“规格第几步”的标记:
// Step 2 of §11.3: persist the pending instance before touching the disk.
if err := m.store.Update(func(cur *store.State) error {
cur.Instances = append(cur.Instances, inst)
...
}); err != nil {
return InstanceView{}, err
}
这不是学术洁癖。半年后回头看代码,最容易忘的不是“怎么写的”,而是“当时为什么只能这么写”。把 § 号留在注释里,等于把决策现场一起留了下来。
实现按六个里程碑推进,代码注释里还能找到 M1-d、M2-7、M4、M5、M6 这样的标记:

六个里程碑,以及发布之后的第一次修复。
工程约束同样在写第一行代码前就定了:
- 单二进制:前端用
embed.FS打进 Go 二进制,没有 npm、没有 CDN、没有构建步骤; - 三个直接依赖:
docker/docker、golang.org/x/crypto、BurntSushi/toml,仅此而已; - 没有数据库:配置在
config.toml,运行状态在state.json; - 默认只监听回环:
127.0.0.1:8899,推荐 SSH 隧道,不内置 HTTPS。
最后这个项目长这样:62 个 Go 文件、约 1.7 万行代码,其中 5,860 行是测试(157 个测试函数);前端约 7,200 行原生 HTML / CSS / JS;文档约 800 行。没有前端构建产物,也没有 node_modules。
三、架构:一个二进制,四层边界

四层结构;三条外部边界都收窄成了接口。
分层不是为了画图好看,而是为了回答一个问题:什么东西必须被隔离,才能在没装 Docker 的开发机上跑测试?
- 接入层:
net/http+ Go 1.22 的ServeMux方法路由,37 条 API 路由。没有 Gin,没有中间件框架;会话、CSRF、限流都是自己写的几十行。 - 业务层:
orchestrator管实例生命周期,dns管三家解析平台,backup管归档和调度,store管配置与状态。 - 适配层:Docker Engine API、三家 DNS 的 HTTP API、文件系统。每一个都被收窄成接口。
- 资源层:
docker.sock、/srv/ts3/<id>、state.json、config.toml,以及厂商的 DNS API。
三个关键接口都很小:
type Runtime interface {
Ping(ctx context.Context) error
EnsureImage(ctx context.Context, ref string) error
Create(ctx context.Context, spec ContainerSpec) (string, error)
Start(ctx context.Context, name string) error
Stop(ctx context.Context, name string, timeout time.Duration) error
Restart(ctx context.Context, name string, timeout time.Duration) error
Remove(ctx context.Context, name string, force bool) error
Inspect(ctx context.Context, name string) (*ContainerInfo, error)
Logs(ctx context.Context, name string, tail int) (string, error)
Stats(ctx context.Context, name string) (StatsSample, error)
Close() error
}
DNS 的契约更小,只有四个方法:
type Provider interface {
Validate(ctx context.Context) ([]Zone, error)
UpsertA(ctx context.Context, zone, fqdn, ip string, ttl int) (RecordID, error)
UpsertSRV(ctx context.Context, zone, fqdn string, port, ttl int) (RecordID, error)
DeleteRecord(ctx context.Context, zone string, id RecordID) error
}
备份只依赖三个方法:Running、Stop、Start。
接口小的好处,在测试章节会兑现:不连 Docker、不发 DNS 请求、不碰真实磁盘,也能把失败路径跑完。
持久化只有两个文件,没有数据库进程:
/etc/ts3panel/config.toml # 0600,密钥与 Provider 凭据密文
/var/lib/ts3panel/state.json # 0600,实例、备份记录、恢复码哈希
/srv/ts3/<id>/ # 实例数据(bind mount)
state.json 的写入是原子的:临时文件 → fsync → rename,并保留原文件属主。这是因为 reset-password 必须以 root 执行,而服务进程可能以其他账号运行;不保留属主,下一次写状态就会失败。
四、建一个实例:把不可靠的世界拆成八步

八步创建流水线;失败统一回滚,token 抓取失败除外。
创建流程是整个项目里最容易出错的一段。它要同时处理端口、文件、Docker、日志和状态,任何一步都可能失败。最后拆成了八步,每一步都尽量可重入:
1. 校验。 实例 ID 只允许 [a-z0-9-]{1,20},子域名、显示名、实例上限都要过。同一 ID、同一子域名直接拒绝。
2. 选端口。 这是整个项目最早定下来的规则:
func PortsFor(index int) model.Ports {
return model.Ports{
Voice: VoicePortBase + index, // 9987
FileTransfer: FileTransferBase + index, // 30033
Query: QueryPortBase + index, // 10011
QuerySSH: QuerySSHPortBase + index, // 10022
}
}
一个索引对应四个端口,从 next_index 开始找。候选组必须四个端口在 TCP 和 UDP 上都能 bind,才算空闲;最多试 50 组,再不行就返回 E_PORT_EXHAUSTED。绑定成功后立刻释放监听,它证明的是“此刻空闲”,不是永久占用。

端口公式与区间合并:索引只增不减。
这里有一个容易被忽略的决定:next_index 只增不减,删除实例不回收端口。原因在 DNS 缓存和客户端重连:一个被删掉的实例,它的域名可能还在别人机器的缓存里,客户端也可能还记着旧地址;如果马上把端口组交给新实例,就可能连到“别人家”的服务器上。
3. 先落盘 pending。 在碰磁盘和 Docker 之前,先把实例写进 state.json。这样即使创建过程崩溃,面板里也能看见一个 pending 状态的半成品,而不是凭空多出一个容器。创建本身是串行的(createMu),两个并发请求不会抢到同一组端口。
4. 建目录和 ini。 /srv/ts3/<id>,写 ts3server.ini,原子落盘。已有数据绝不覆盖。
5. 起容器。 拉主镜像,失败时换 fallback 镜像;网络用 host;资源限制默认 512MB / 1 CPU;日志 10MB × 3 滚动;标签只打两个:
Labels: map[string]string{
"ts3panel.managed": "true",
"ts3panel.instance": inst.ID,
},
NetworkMode: "host",
标签不是装饰,是安全边界:面板只操作带 ts3panel.managed=true 的容器,读、写、删之前都校验。机器上可能有别人的容器,名字也可能撞上,不能靠命名约定判断归属。
6. 等就绪。 容器“running”不等于服务可用,崩循环的 TS3 也会短暂 running。所以要求连续两次观察到 running,间隔 2 秒,60 秒超时:
case "running":
// A crash-looping TS3 container is "running" for a moment.
// Require two consecutive observations two seconds apart.
if runningSince.IsZero() {
runningSince = time.Now()
} else if time.Since(runningSince) >= m.runningGrace {
return info, nil
}
7. 抓 token。 首次启动日志里有管理员 token,两种格式(token=... 和 Privilege Key: ...)都要认。轮询 6 次、每次间隔 5 秒,tail 300 行。
关键决定是:抓不到 token 不算失败。容器已经跑起来了,实例照常能连;token 可以稍后进客户端重新生成。如果因为抓不到日志就把一个健康的实例回滚掉,那才是真正的糟糕体验。
8. 完成。 状态置为 ready。DNS 绑定不在这条链路里,它是创建完成后的独立操作。
任何一步失败,进入回滚:停容器 → 校验标签后删容器 → 删实例目录 → 状态置为 failed。回滚只删自己创建的东西,标签对不上就不动。
一个细节:创建接口的整体超时是 5 分钟,但单次请求的写超时被单独放宽到 6 分钟——因为拉镜像和等 TS3 启动,很容易超过服务器默认的写超时。
被镜像打脸的 E1
规格里原本想让面板生成 ts3server.ini,用 inifile= 参数传给容器。真机一跑就发现:官方镜像的 entrypoint 会自己重建 ini,再追加自己的 inifile 参数,面板写的端口被覆盖了。
最后的解法是把端口从 ini 挪进环境变量:
Env: []string{
"TS3SERVER_LICENSE=accept",
fmt.Sprintf("TS3SERVER_DEFAULT_VOICE_PORT=%d", inst.Ports.Voice),
fmt.Sprintf("TS3SERVER_FILETRANSFER_PORT=%d", inst.Ports.FileTransfer),
fmt.Sprintf("TS3SERVER_QUERY_PORT=%d", inst.Ports.Query),
fmt.Sprintf("TS3SERVER_QUERY_SSH_PORT=%d", inst.Ports.QuerySSH),
"TS3SERVER_QUERY_IP=127.0.0.1",
"TS3SERVER_QUERY_PROTOCOLS=raw,ssh",
},
代码注释里留了一行实测记录:
// Measured on teamspeak:latest (3.13.8), 2026-10-21.
这行注释的价值,比它占的空间大得多。
五、域名:把“写成功”和“生效”分开

先写后验;失败不回滚实例,手动模式只出清单。
域名这一块,最容易犯的错误是相信厂商 API 的 200。记录写成功了,不代表解析已经生效:可能有 TTL 缓存、可能权威 DNS 配错、可能公网 IP 填错。
所以流程被拆成两段:先写,再验。
- 选平台:按“平台 + 主域名”唯一;
- 冲突预检:按完整记录名查 A / SRV,发现不是自己创建的记录,要求确认覆盖;
- 写 A:Cloudflare 强制
proxied=false(灰云),TTL 300; - 写 SRV:
_ts3._udp.<fqdn>指向9987+i; - 验证:用系统解析器真实查询,5 秒一次,最多 180 秒。
验证的核心逻辑很简单,也很固执:
for attempt := 1; ; attempt++ {
last = v.Check(ctx, fqdn, port, publicIP)
if last.Verified {
return last
}
if !time.Now().Add(interval).Before(deadline) {
last.Message += " 记录可能仍在传播,请稍后重试。"
return last
}
select {
case <-ctx.Done():
...
case <-time.After(interval):
}
}
Check 分别查 A 和 SRV,返回结构化结果:解析到了哪些 A 记录、SRV target 和端口是什么、哪一半失败、错误是什么。页面因此能说清楚“是 A 没生效,还是 SRV 写错了”,而不是笼统地报“验证失败”。
三家实现都是标准库手写的 HTTP 客户端,没有引入云厂商 SDK:
- Cloudflare:Bearer Token,A 记录强制灰云;TTL 被拒(错误码 9003)时回退到 1(automatic);记录已存在(81057)时改为更新。
- 阿里云 DNS:RPC 风格,HMAC-SHA1 签名、RFC 3986 百分号编码,全部手写。
- DNSPod:腾讯云 TC3-HMAC-SHA256 签名,同样零 SDK。
这样做的好处很直接:依赖只有三个,交叉编译毫无悬念,错误码也能统一翻译成 E_DNS_AUTH / E_DNS_PERM / E_DNS_ZONE / E_DNS_RATE。
三条硬约束
第一,DNS 失败绝不回滚或删除实例。 域名没生效,不等于服务器没了。实例仍然可以按 IP:端口 连接,页面会返回失败原因、可照抄的手动记录清单和重试按钮。
第二,删除只按记录 ID,不按名字。 记录名可能被手工改过,按名字删可能误删别人的记录。删除顺序也有讲究:先 SRV,后 A。
第三,手动模式不写也不删。 面板只渲染清单,记录由你自己在控制台添加;面板不知道这些记录的 ID,所以删除实例时不会碰它们。从自动切到手动时,面板会按旧 ID 删除自己创建过的记录——这是勘误 E9 的产物。
被 Cloudflare 打脸的 E7
规格假设 SRV 记录读回来字段是完整的。实际读回时,data.service、data.proto、data.name 可能是 null,只有记录名、data.port、data.target 是可靠的。
于是校验逻辑改成只看这三个字段,不拿 GET 结果做全字段断言。这类差异不会在文档里写清楚,只能在真机上撞出来。
六、安全:默认值要能解释

网络、容器、文件与凭据:三组默认值,三种边界。
这个面板做的事情权限很大:它管 Docker,能删容器、删目录、读日志。所以“安全”不能只是一句口号,得落在默认值上;而每个默认值,最好都能用一句话解释清楚为什么存在。
网络边界
- 默认只监听
127.0.0.1:8899,推荐 SSH 隧道。直接暴露 8899 到公网不是默认行为。 - 非回环监听时,初始化必须携带一次性
setup_token(5 分钟有效、仅一次)。否则任何人打开页面都能抢注管理员。 - 所有写接口要求
X-Requested-With: ts3panel;配合SameSite=Strict的会话 Cookie,挡掉跨站表单和fetch写入。 - 每个响应都带 CSP、
X-Frame-Options: DENY、X-Content-Type-Options: nosniff、Referrer-Policy: no-referrer,不允许被 iframe 嵌入。 - 登录失败 15 分钟内 5 次,锁定 15 分钟;登录时对不存在的用户也跑一次 bcrypt(dummy hash),避免用响应时间枚举用户名。
- 密码用 bcrypt cost 12;8 个一次性恢复码只存哈希;会话只存在内存里,重启即全部失效。
容器边界
- 只操作
ts3panel.managed=true的容器,删除前再校验一次。 host网络是 TS3 的硬需求(UDP),端口直接落宿主;Query 端口固定绑127.0.0.1,端口看板也不会提示开放。- TS3 以非 root 运行;数据目录的属主由镜像 entrypoint 自己
chown,面板不插手(E3)。 - 资源默认上限 512MB / 1 CPU,日志 10MB × 3 滚动。
- 容器状态包含
missing:容器被手工删掉,面板不会假装一切正常(E5)。
文件与凭据边界
config.toml必须是0600,目录0700;不满足就拒绝启动。- Provider 凭据用 AES-256-GCM 加密,AAD 绑定应用标识;界面上只显示掩码。
- 日志统一打码:token、密码、密钥不会以明文进入 journald;抓 token 成功时也只记录首尾各 4 位。
state.json原子写;config.toml和状态文件都是0600。
为什么面板以 root 运行(E4)
这是被问得最多的问题,也是最诚实的勘误之一。
Docker socket 本身就是 root 等价权限;即使面板降权,能连 socket 的进程照样能起特权容器。更现实的是:TS3 容器的 entrypoint 会把数据目录 chown 给容器内用户,非 root 的面板删不掉这些目录。
所以最终选择是:面板以 root 运行,但用 systemd 沙箱把可写范围收窄到三个目录,而不是做一个看起来更安全、实际上到处留后门的降权方案。
[Service]
User=root
UMask=0077
ExecStart=/usr/local/bin/ts3panel serve --config /etc/ts3panel/config.toml
Restart=always
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/srv/ts3 /var/lib/ts3panel /etc/ts3panel
NoNewPrivileges=true
这条边界也写进了部署文档和 README:改 instance_root 时,必须同步改 ReadWritePaths;不要把可写路径放宽到 / 或 /var。
七、真机实测:九条勘误
v1.0.0 的 CHANGELOG 里有一节叫“勘误吸收”,列了 E1–E9。它们不是九个 bug,而是九个规格假设被现实推翻的瞬间。
| 编号 | 规格以为 | 真机是 | 最后怎么处理 |
|---|---|---|---|
| E1 | 用 inifile= 注入端口 | 镜像 entrypoint 会重建 ini,覆盖端口 | 改用 TS3SERVER_* 环境变量注入 |
| E2 | Query / SSH Query 默认开启 | 只开了 raw,SSH 要显式声明 | TS3SERVER_QUERY_PROTOCOLS=raw,ssh |
| E3 | 面板负责 chown 数据目录 | 镜像启动时自己 chown | 面板不做 chown;/srv/ts3 设为 0755 |
| E4 | 面板可以降权运行 | Docker socket 等价 root,非 root 删不掉目录 | 以 root 运行 + systemd 沙箱 |
| E5 | 容器状态只有 running / exited | docker rm 后 inspect 失败 | 增加 missing 状态,页面可删除或重建 |
| E6 | 同一平台可以配多个账号 | 平台 + 主域名唯一,重复保存覆盖凭据 | 按“平台 + 主域名”做唯一键,界面写明覆盖 |
| E7 | Cloudflare SRV 读回字段完整 | service / proto / name 可能为 null | 只校验记录名、port、target |
| E8 | 自动探测公网 IP 总是对的 | NAT 机器探测到的是内网 IP | 手填优先;探测失败不阻塞流程 |
| E9 | 手动模式的记录面板也能删 | 面板没有记录 ID,删不了也不该删 | 不写不删;自动切手动时按旧 ID 删自动记录 |
九条里,最难接受的是 E4,因为它推翻的不是实现细节,而是一个“应该这样”的价值判断;最有代表性的是 E1,因为它说明了一件事:规格能保证一致,不能保证正确。
真机不在乎你的注释写得有多自信。所以每条勘误最后都变成了三样东西:代码里的注释、文档里的说明、以及一条能复现的测试或检查清单。
八、测试:把 Docker 和 DNS 关在接口后面
157 个测试函数、5,860 行测试代码,约占 Go 代码的 34%。这个比例不是刷出来的,是因为所有外部依赖都被做成了可注入的接缝:
Runtime换成记录调用的 fake,测试实例创建、启动、回滚、标签校验;Probe换成 fake,模拟端口占用,测试 50 组耗尽和NextIndex推进;Provider换成 fake,测试 DNS 写入、冲突、失败后的手动清单;Verifier注入假的解析器函数,测试 180 秒内的轮询与超时分支;Lifecycle注入 fake,测试备份时“停容器 → 打包 → 无论成败都重启”;DiskFree、Now注入,测试磁盘不足和跨天调度。
失败路径比成功路径测得多:
- 容器 60 秒没起来,回滚后状态必须是 failed,目录和容器都要清掉;
- token 抓不到,实例必须保持 ready,不能误删;
- 备份解压时遇到
../路径,必须拒绝,不能写出实例目录; - 备份磁盘余量不足 2 倍目录大小时,不能留下半个包;
- 删除一个标签被改过的容器,必须拒绝。
这也解释了一个设计:没有 Docker 的开发机也能启动面板。Docker 连不上时,管理器换成 unavailableRuntime,面板照常启动、照常显示界面,只是实例操作会报“Docker 不可用”。这既是开发体验,也是测试策略的一部分。
真机验证则放在另一条线上:Debian 12 上跑安装、初始化、建实例、备份恢复、整机重启恢复;Debian 11 上跑离线安装、跨机器还原。README 里也诚实标注了:Ubuntu 22.04 是目标平台,但没有在本次发布环境复测。
CI 做四件事:gofmt 检查、go build、go vet、go test -count=1 ./...。发布用 GoReleaser 交叉编译 linux/amd64 与 linux/arm64,CGO_ENABLED=0、-trimpath、-s -w,版本信息通过 ldflags 注入。构建出来的二进制大约 10.5 MB。
九、发布:v1.0.0 与它的第一次修复
安装被压缩成一条命令:
curl -fsSL https://raw.githubusercontent.com/yaodooo/ts3panel/main/scripts/install.sh | sudo bash
install.sh 做的事不少:检查 root、systemd、Docker 和 CPU 架构;下载对应 Release 包并校验 SHA256;创建 /etc/ts3panel(0700)和 /var/lib/ts3panel;生成随机 session_secret 与 master_key(config.toml 为 root:root 0600);写 systemd unit;启动服务;健康检查;最后打印初始化入口。重复执行是安全的:会升级二进制、刷新 unit、重启服务,但不会覆盖配置和状态。GitHub 不通时,可以用 --from-file 离线安装。
v1.0.0 提交后十五分钟,仓库里出现了它的第一次修复。问题很具体:
在线安装的 SHA256 校验永远失败。
原因不复杂:安装脚本把下载的包保存成了 pkg.tar.gz,而 checksums 文件引用的是发布资产名 ts3panel_<version>_linux_<arch>.tar.gz。sha256sum -c 在当前目录找不到对应文件,于是每次都失败。
# checksums 里写的是这个
ts3panel_1.0.1_linux_amd64.tar.gz
# 脚本却存成了
pkg.tar.gz
# 结果
sha256sum -c ts3panel_1.0.1_checksums.txt # 找不到文件
修复也很小:按发布资产原名保存,再校验;校验失败时提示改用 --from-file 或镜像。两个提交的时间戳分别是 02:21(v1.0.0)和 02:36(修复),相隔十五分钟。
这件事留下的教训比修复本身重要:CI 覆盖了 Go 代码,但没有覆盖 shell 安装链路。单元测试再绿,也测不到“文件名和校验文件对不上”这种问题。如果重来一次,我会给安装脚本加一个在容器里跑的冒烟测试:下载 → 校验 → 解包 → --no-start 安装 → 检查 unit 文件。
十、做完之后:减法比加法难
回头看,这个项目里最难的不是 Docker API,也不是三家 DNS 的签名,而是不断回答“这个功能要不要做”。
一些具体的取舍:
- 不做在线人数:省掉 ServerQuery 长连接和一套新的凭据体系;
- 不做公网 HTTPS:把证书、备案、爆破风险留给真正需要它的人;
- 不做实例克隆:克隆意味着复制 SQLite 和身份密钥,还要处理端口、域名、token 的一整套语义,收益不值;
- 不做安全组自动化:云厂商的 AK/SK 权限太大,面板不该碰;
- 不做多租户:一个管理员、一台机器,是这类自用面板最舒服的形态。
如果重来一次,我会改三件事:
- 把“规格假设”更早拿到真机验证。E1、E2、E4 这类问题,写多少单测都发现不了,必须在真实镜像和真实 systemd 上跑一遍。
- 安装链路进 CI。v1.0.1 那个 bug 本来可以用一个十行的冒烟测试挡住。
- DNS 验证的可观测性再强一点。现在能告诉你是 A 还是 SRV 失败,但还缺“当前解析到什么、期望是什么”的一屏对比。
至于结果:面板能在一台 Debian 12 上完成安装、初始化、建实例、写解析、备份、恢复、重启恢复;也能在 Debian 11 上离线安装、跨机器还原。对一个自用项目来说,这已经够了。
十一、自己跑一下
环境要求:Debian 11+ / Ubuntu 22.04,root,Docker Engine 24+,systemd。
# 在线安装
curl -fsSL https://raw.githubusercontent.com/yaodooo/ts3panel/main/scripts/install.sh | sudo bash
# 本机建 SSH 隧道(面板默认只监听回环)
ssh -L 8899:127.0.0.1:8899 root@<服务器公网 IP>
# 浏览器打开
# http://127.0.0.1:8899/setup
首次打开是四步向导:管理员账号 → 解析平台(可跳过)→ 公网 IP(NAT 机器务必手填)→ 保存 8 个一次性恢复码。

建好实例后,去端口看板复制要放行的端口:

只放行语音 UDP 和文件传输 TCP,不要开放 Query 端口——它们只绑在 127.0.0.1 上,是面板内部用的。
项目地址:github.com/yaodooo/ts3panel,MIT 协议。文档在仓库里:
本文的界面截图使用演示数据;架构图、流程图与端口图按代码实际行为绘制。如果你也在维护小圈子的 TS3,或者对“单二进制 + 无数据库”的自部署面板感兴趣,欢迎来仓库提 Issue。