Skip to content

Commit 6d13ed1

Browse files
author
gitlayzer
committed
feat: add tunnel diagnostics and protocol templates
1 parent 2d0be4b commit 6d13ed1

15 files changed

Lines changed: 921 additions & 25 deletions

README.md

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ Sealtun 是一款功能强大、设计优雅的 CLI 工具,旨在为 **Sealos
1212
- 🌍 **区域切换**:支持查看已内置的 Sealos Cloud region,并通过 `sealtun region use` 重新登录切换区域。
1313
- 👤 **Profile 多账号管理**:可把不同 Sealos 账号、region、workspace 和 kubeconfig 保存为命名 profile,按需切换。
1414
- 🚀 **一键暴露服务**:执行 `sealtun expose 8080`,即可获得一个受信任的 HTTPS URL,将流量安全地路由到本地。
15-
- 🌐 **自定义域名**:新建隧道时可用 `--domain` 生成 CNAME 指引,并通过 `domain status/doctor` 检查 DNS、Ingress 与证书状态。
16-
- 📊 **本地控制台与观测**`dashboard` 提供本地 Web 控制台,`logs``events``metrics` 可查看远端 Pod 日志、Kubernetes 事件、请求计数与运行状态。
15+
- 🌐 **自定义域名自动化**:可用 `domain plan/add/verify/status/doctor` 生成 CNAME 指引、等待 DNS、绑定域名并检查证书状态。
16+
- 📊 **状态与诊断**`doctor <tunnel-id>``inspect --remote``logs``events``metrics` 可定位本地端口、daemon、远端 Pod、Service、Ingress 与证书问题。
17+
- 🧩 **协议模板**`template https|ssh|tcp|mysql|postgres|redis|mqtt` 可生成直接命令和 `sealtun.yaml` 示例。
1718
- 🧾 **声明式配置**`apply -f sealtun.yaml` 可用 YAML 声明隧道,并以稳定名称幂等创建或更新。
1819
- 🌐 **深度适配 Sealos**:原生使用 Sealos Cloud 的 Kubernetes、Service 与 Ingress 能力,当前稳定支持 HTTPS 入口和 WebSocket 隧道。
1920
- 🐳 **全能二进制文件**:客户端和服务器代理共用同一个精简的二进制文件和 Docker 镜像。
@@ -247,7 +248,14 @@ sealtun expose 3000 --domain app.example.com --wait-domain
247248

248249
或者在 DNS 生效后对已有隧道绑定:
249250
```bash
251+
# 先查看需要配置的 DNS
252+
sealtun domain plan <tunnel-id> app.example.com
253+
254+
# DNS 已经生效后绑定
250255
sealtun domain set <tunnel-id> app.example.com
256+
257+
# 或者等待 DNS 生效后自动绑定,并继续等待证书就绪
258+
sealtun domain add <tunnel-id> app.example.com --wait --timeout 5m
251259
```
252260

253261
Sealtun 会保留一个 Sealos 官方子域名作为隧道控制面和 CNAME 目标。只有 CNAME 已经指向该 Sealos host 后,Sealtun 才会把自定义域名写入 Ingress,并创建 cert-manager `Issuer``Certificate`。你需要在自己的 DNS 服务商处配置:
@@ -294,6 +302,16 @@ sealtun events <tunnel-id>
294302
sealtun events <tunnel-id> --json
295303
```
296304

305+
一键诊断本地与远端状态:
306+
```bash
307+
# 全局健康检查
308+
sealtun doctor
309+
310+
# 单条隧道诊断,会给出本地端口、daemon、远端资源和下一步建议
311+
sealtun doctor <tunnel-id>
312+
sealtun doctor <tunnel-id> --json
313+
```
314+
297315
`metrics` 会聚合本地 session 状态、远端 Deployment/Pod/Ingress 状态,并在远端 Pod 支持时读取受 Bearer secret 保护的 `/_sealtun/metrics` 请求计数。TCP/SSH 四层隧道还会暴露 TCP 连接数、活跃连接数、字节数和错误数。
298316

299317
启动本地只读控制台:
@@ -306,7 +324,19 @@ sealtun dashboard --addr 127.0.0.1 --port 19777
306324

307325
Dashboard 仅监听本地地址,数据来自当前 CLI 进程读取到的本地 session、登录状态、远端诊断和自定义域名状态。
308326

309-
### 7. 声明式配置
327+
### 7. 协议模板
328+
不确定该怎么写命令或声明式配置时,可以先生成模板:
329+
330+
```bash
331+
sealtun template https --name web --port 3000 --domain app.example.com
332+
sealtun template ssh
333+
sealtun template postgres
334+
sealtun template redis --name cache
335+
```
336+
337+
模板会同时输出一次性 `sealtun expose` 命令和可提交到项目内的 `sealtun.yaml` 片段。`mysql``postgres``redis``mqtt` 模板默认走通用 TCP 四层入口;HTTPS 模板才支持自定义域名和访问控制。
338+
339+
### 8. 声明式配置
310340
创建 `sealtun.yaml`
311341
```yaml
312342
version: v1

README_EN.md

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ It connects your local development machine straight to the internet by dynamical
1212
- 🌍 **Region Switching**: List built-in Sealos Cloud regions and switch regions by re-running login with `sealtun region use`.
1313
- 👤 **Named Profiles**: Save different Sealos accounts, regions, workspaces, and kubeconfigs as named profiles and switch between them.
1414
- 🚀 **One-Command Expose**: Execute `sealtun expose 8080`, and get a fully trusted HTTPS URL for your localhost securely routed.
15-
- 🌐 **Custom Domains**: Use `--domain` to print the required CNAME target and `domain status/doctor` to diagnose DNS, Ingress, and certificate readiness.
16-
- 📊 **Local Console and Observability**: Use `dashboard` for a local web console, and `logs` / `events` / `metrics` for remote pod logs, Kubernetes events, request counters, and runtime state.
15+
- 🌐 **Custom Domain Automation**: Use `domain plan/add/verify/status/doctor` to generate CNAME guidance, wait for DNS, attach domains, and inspect certificate readiness.
16+
- 📊 **Status and Diagnostics**: Use `doctor <tunnel-id>`, `inspect --remote`, `logs`, `events`, and `metrics` to diagnose local ports, daemon state, remote Pods, Services, Ingresses, and certificates.
17+
- 🧩 **Protocol Templates**: Use `template https|ssh|tcp|mysql|postgres|redis|mqtt` to generate commands and `sealtun.yaml` examples.
1718
- 🧾 **Declarative Config**: Use `apply -f sealtun.yaml` to declare tunnels in YAML and create or update them with stable names.
1819
- 🌐 **Optimized for Sealos**: Native support for Sealos Cloud domains, HTTPS traffic, and WebSocket tunnels.
1920
- 🐳 **All-in-One Binary**: The client and the server agent live comfortably in the exact same compact binary and Docker image.
@@ -247,7 +248,14 @@ sealtun expose 3000 --domain app.example.com --wait-domain
247248

248249
Or attach one to an existing tunnel after DNS is ready:
249250
```bash
251+
# Show the DNS record you need first
252+
sealtun domain plan <tunnel-id> app.example.com
253+
254+
# Attach after DNS is ready
250255
sealtun domain set <tunnel-id> app.example.com
256+
257+
# Or wait for DNS, attach automatically, and keep waiting for certificate readiness
258+
sealtun domain add <tunnel-id> app.example.com --wait --timeout 5m
251259
```
252260

253261
Sealtun keeps a Sealos-managed host as the tunnel control endpoint and CNAME target. It writes the custom host to Ingress and creates cert-manager `Issuer` and `Certificate` resources only after the CNAME points to that Sealos host. Configure DNS at your provider:
@@ -294,6 +302,16 @@ sealtun events <tunnel-id>
294302
sealtun events <tunnel-id> --json
295303
```
296304

305+
Run local and remote diagnostics:
306+
```bash
307+
# Global health check
308+
sealtun doctor
309+
310+
# Single-tunnel diagnosis with local port, daemon, remote resource, and next-step suggestions
311+
sealtun doctor <tunnel-id>
312+
sealtun doctor <tunnel-id> --json
313+
```
314+
297315
`metrics` combines local session state, remote Deployment/Pod/Ingress readiness, and server-side request counters when the remote pod supports the Bearer-secret-protected `/_sealtun/metrics` endpoint. TCP/SSH tunnels also expose TCP connection, active connection, byte, and error counters.
298316

299317
Run the local read-only dashboard:
@@ -306,7 +324,19 @@ sealtun dashboard --addr 127.0.0.1 --port 19777
306324

307325
The dashboard listens locally and reads the same data as the CLI: local sessions, login state, remote diagnostics, and custom domain readiness.
308326

309-
### 7. Declarative config
327+
### 7. Protocol templates
328+
When you are unsure which command or declarative config to use, generate a template first:
329+
330+
```bash
331+
sealtun template https --name web --port 3000 --domain app.example.com
332+
sealtun template ssh
333+
sealtun template postgres
334+
sealtun template redis --name cache
335+
```
336+
337+
Templates print both a one-shot `sealtun expose` command and a `sealtun.yaml` snippet. `mysql`, `postgres`, `redis`, and `mqtt` templates default to generic TCP L4 entries; only HTTPS templates support custom domains and access controls.
338+
339+
### 8. Declarative config
310340
Create `sealtun.yaml`:
311341
```yaml
312342
version: v1

cmd/doctor.go

Lines changed: 234 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ import (
55
"encoding/json"
66
"errors"
77
"fmt"
8+
"strings"
89
"sync"
910
"time"
1011

12+
"github.com/labring/sealtun/pkg/k8s"
13+
"github.com/labring/sealtun/pkg/session"
1114
"github.com/spf13/cobra"
1215
)
1316

@@ -31,12 +34,50 @@ type doctorPayload struct {
3134
Warnings []string `json:"warnings,omitempty"`
3235
}
3336

37+
type tunnelDoctorPayload struct {
38+
TunnelID string `json:"tunnelId"`
39+
Status string `json:"status"`
40+
Protocol string `json:"protocol,omitempty"`
41+
Endpoint string `json:"endpoint,omitempty"`
42+
LocalTarget string `json:"localTarget,omitempty"`
43+
Mode string `json:"mode,omitempty"`
44+
Namespace string `json:"namespace,omitempty"`
45+
ProcessAlive bool `json:"processAlive"`
46+
LocalPortReachable bool `json:"localPortReachable"`
47+
Remote *k8s.TunnelDiagnostics `json:"remote,omitempty"`
48+
Checks []doctorCheck `json:"checks"`
49+
Suggestions []string `json:"suggestions,omitempty"`
50+
Warnings []string `json:"warnings,omitempty"`
51+
}
52+
53+
type doctorCheck struct {
54+
Name string `json:"name"`
55+
Status string `json:"status"`
56+
Detail string `json:"detail,omitempty"`
57+
}
58+
3459
var doctorJSON bool
3560

3661
var doctorCmd = &cobra.Command{
37-
Use: "doctor",
38-
Short: "Run local Sealtun diagnostics",
62+
Use: "doctor [tunnel-id]",
63+
Short: "Run Sealtun diagnostics",
64+
Args: cobra.MaximumNArgs(1),
65+
SilenceUsage: true,
3966
RunE: func(cmd *cobra.Command, args []string) error {
67+
if len(args) > 0 {
68+
payload, err := collectTunnelDoctorPayload(cmd.Context(), args[0])
69+
if err != nil {
70+
return err
71+
}
72+
if doctorJSON {
73+
enc := json.NewEncoder(cmd.OutOrStdout())
74+
enc.SetIndent("", " ")
75+
return enc.Encode(payload)
76+
}
77+
printTunnelDoctor(cmd, payload)
78+
return nil
79+
}
80+
4081
payload, err := collectDoctorPayloadWithContext(cmd.Context())
4182
if err != nil {
4283
return err
@@ -62,6 +103,49 @@ func collectDoctorPayload() (*doctorPayload, error) {
62103
return collectDoctorPayloadWithContext(context.Background())
63104
}
64105

106+
func collectTunnelDoctorPayload(ctx context.Context, tunnelID string) (*tunnelDoctorPayload, error) {
107+
sess, err := findSession(tunnelID)
108+
if err != nil {
109+
return nil, err
110+
}
111+
ensureSessionPublicPort(ctx, sess)
112+
113+
snapshot := classifySession(*sess, true)
114+
endpoint := endpointLabel(sess.Protocol, sess.Host, sess.SealosHost, sess.PublicPort)
115+
payload := &tunnelDoctorPayload{
116+
TunnelID: sess.TunnelID,
117+
Status: snapshot.Status,
118+
Protocol: valueOr(sess.Protocol, "https"),
119+
Endpoint: endpoint,
120+
LocalTarget: "localhost:" + valueOr(sess.LocalPort, "unknown"),
121+
Mode: valueOr(sess.Mode, "foreground"),
122+
Namespace: sess.Namespace,
123+
ProcessAlive: snapshot.ProcessAlive,
124+
LocalPortReachable: snapshot.LocalPortReachable,
125+
}
126+
127+
payload.Checks = append(payload.Checks,
128+
doctorCheck{Name: "session", Status: "ok", Detail: "local session record exists"},
129+
ownerDoctorCheck(*sess, snapshot.ProcessAlive),
130+
localPortDoctorCheck(*sess, snapshot.LocalPortReachable),
131+
)
132+
133+
remoteCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
134+
remote, err := collectRemoteDiagnosticsWithContext(remoteCtx, *sess)
135+
cancel()
136+
if err != nil {
137+
payload.Checks = append(payload.Checks, doctorCheck{Name: "remote", Status: "warn", Detail: err.Error()})
138+
payload.Warnings = append(payload.Warnings, fmt.Sprintf("remote diagnostics unavailable: %v", err))
139+
} else {
140+
payload.Remote = remote
141+
payload.Checks = append(payload.Checks, remoteDoctorChecks(remote)...)
142+
payload.Warnings = append(payload.Warnings, remote.Warnings...)
143+
}
144+
145+
payload.Suggestions = tunnelDoctorSuggestions(*sess, payload)
146+
return payload, nil
147+
}
148+
65149
func collectDoctorPayloadWithContext(ctx context.Context) (*doctorPayload, error) {
66150
status, err := collectStatus()
67151
if err != nil {
@@ -258,3 +342,151 @@ func printDoctor(cmd *cobra.Command, payload *doctorPayload) {
258342
fmt.Fprintln(out, "No issues detected from local diagnostics.")
259343
}
260344
}
345+
346+
func printTunnelDoctor(cmd *cobra.Command, payload *tunnelDoctorPayload) {
347+
out := cmd.OutOrStdout()
348+
349+
fmt.Fprintln(out, "Sealtun Tunnel Doctor")
350+
fmt.Fprintf(out, " Tunnel ID: %s\n", payload.TunnelID)
351+
fmt.Fprintf(out, " Status: %s\n", payload.Status)
352+
fmt.Fprintf(out, " Protocol: %s\n", payload.Protocol)
353+
fmt.Fprintf(out, " Endpoint: %s\n", valueOr(payload.Endpoint, "-"))
354+
fmt.Fprintf(out, " Local target: %s\n", payload.LocalTarget)
355+
fmt.Fprintf(out, " Mode: %s\n", valueOr(payload.Mode, "unknown"))
356+
fmt.Fprintf(out, " Namespace: %s\n", valueOr(payload.Namespace, "unknown"))
357+
358+
if len(payload.Checks) > 0 {
359+
fmt.Fprintln(out, "")
360+
fmt.Fprintln(out, "Checks")
361+
for _, check := range payload.Checks {
362+
fmt.Fprintf(out, " - %s: %s", check.Name, check.Status)
363+
if check.Detail != "" {
364+
fmt.Fprintf(out, " (%s)", check.Detail)
365+
}
366+
fmt.Fprintln(out)
367+
}
368+
}
369+
370+
if len(payload.Suggestions) > 0 {
371+
fmt.Fprintln(out, "")
372+
fmt.Fprintln(out, "Suggestions")
373+
for _, suggestion := range payload.Suggestions {
374+
fmt.Fprintf(out, " - %s\n", suggestion)
375+
}
376+
}
377+
378+
if len(payload.Warnings) > 0 {
379+
fmt.Fprintln(out, "")
380+
fmt.Fprintln(out, "Warnings")
381+
for _, warning := range payload.Warnings {
382+
fmt.Fprintf(out, " - %s\n", warning)
383+
}
384+
}
385+
}
386+
387+
func checkStatus(ok bool) string {
388+
if ok {
389+
return "ok"
390+
}
391+
return "warn"
392+
}
393+
394+
func ownerDoctorCheck(sess session.TunnelSession, alive bool) doctorCheck {
395+
status := checkStatus(alive)
396+
if sess.ConnectionState == session.ConnectionStateStopped {
397+
status = "skip"
398+
}
399+
return doctorCheck{Name: "owner", Status: status, Detail: ownerCheckDetail(sess, alive)}
400+
}
401+
402+
func ownerCheckDetail(sess session.TunnelSession, alive bool) string {
403+
if alive {
404+
return "owner process is alive"
405+
}
406+
if sess.ConnectionState == session.ConnectionStateStopped {
407+
return "tunnel is stopped"
408+
}
409+
if sess.Mode == "daemon" {
410+
return "local daemon is not running"
411+
}
412+
return "recorded process is not running"
413+
}
414+
415+
func localPortDoctorCheck(sess session.TunnelSession, reachable bool) doctorCheck {
416+
status := checkStatus(reachable)
417+
if sess.ConnectionState == session.ConnectionStateStopped {
418+
status = "skip"
419+
}
420+
return doctorCheck{Name: "local-port", Status: status, Detail: localPortCheckDetail(sess, reachable)}
421+
}
422+
423+
func localPortCheckDetail(sess session.TunnelSession, reachable bool) string {
424+
if reachable {
425+
return "local target accepts TCP connections"
426+
}
427+
if strings.TrimSpace(sess.LocalPort) == "" {
428+
return "local port is missing from the session"
429+
}
430+
if sess.ConnectionState == session.ConnectionStateStopped {
431+
return "not checked because the tunnel is stopped"
432+
}
433+
return fmt.Sprintf("localhost:%s is not reachable", sess.LocalPort)
434+
}
435+
436+
func remoteDoctorChecks(remote *k8s.TunnelDiagnostics) []doctorCheck {
437+
if remote == nil {
438+
return nil
439+
}
440+
deploymentStatus := checkStatus(remote.Deployment.Exists && remote.Deployment.ReadyReplicas > 0)
441+
if remote.Deployment.Exists && remote.Deployment.DesiredReplicas == 0 {
442+
deploymentStatus = "skip"
443+
}
444+
checks := []doctorCheck{
445+
{Name: "deployment", Status: deploymentStatus, Detail: fmt.Sprintf("%d/%d ready", remote.Deployment.ReadyReplicas, remote.Deployment.DesiredReplicas)},
446+
{Name: "service", Status: checkStatus(remote.Service.Exists), Detail: valueOr(strings.Join(remote.Service.Ports, ", "), "no ports reported")},
447+
}
448+
if remote.Ingress.Exists {
449+
checks = append(checks, doctorCheck{Name: "ingress", Status: "ok", Detail: strings.Join(remote.Ingress.Hosts, ", ")})
450+
} else {
451+
checks = append(checks, doctorCheck{Name: "ingress", Status: "warn", Detail: "missing"})
452+
}
453+
if remote.Certificate != nil {
454+
status := checkStatus(remote.Certificate.Exists && remote.Certificate.Ready)
455+
checks = append(checks, doctorCheck{Name: "certificate", Status: status, Detail: certificateDoctorDetail(remote.Certificate)})
456+
}
457+
return checks
458+
}
459+
460+
func certificateDoctorDetail(cert *k8s.CertificateDiagnostics) string {
461+
if cert == nil || !cert.Exists {
462+
return "missing"
463+
}
464+
if cert.Ready {
465+
return "ready"
466+
}
467+
return "not ready"
468+
}
469+
470+
func tunnelDoctorSuggestions(sess session.TunnelSession, payload *tunnelDoctorPayload) []string {
471+
suggestions := []string{}
472+
if payload.Status == "stopped" {
473+
suggestions = append(suggestions, fmt.Sprintf("run `sealtun start %s` to resume the tunnel", sess.TunnelID))
474+
return suggestions
475+
}
476+
if !payload.ProcessAlive && sess.Mode == "daemon" {
477+
suggestions = append(suggestions, "run `sealtun status` to check the daemon, then restart the tunnel if needed")
478+
}
479+
if !payload.LocalPortReachable && strings.TrimSpace(sess.LocalPort) != "" && payload.Status != "stopped" {
480+
suggestions = append(suggestions, fmt.Sprintf("start the local service on localhost:%s, then rerun `sealtun doctor %s`", sess.LocalPort, sess.TunnelID))
481+
}
482+
if payload.Remote == nil {
483+
suggestions = append(suggestions, fmt.Sprintf("run `sealtun inspect %s --remote` after login to see Kubernetes resource details", sess.TunnelID))
484+
}
485+
if sess.CustomDomain != "" {
486+
suggestions = append(suggestions, fmt.Sprintf("run `sealtun domain doctor %s` to verify DNS, Ingress, and certificate status", sess.TunnelID))
487+
}
488+
if len(suggestions) == 0 {
489+
suggestions = append(suggestions, "no immediate action suggested")
490+
}
491+
return suggestions
492+
}

0 commit comments

Comments
 (0)