Skip to content

Commit 4baace1

Browse files
author
gitlayzer
committed
feat: add tui console and release hardening
Add the interactive terminal console, strengthen connect cleanup and port-forward handling, increase tunnel ID entropy, harden server image resolution and listener shutdown, and expand release gates with race/gosec coverage. Sync README and Sealtun skills for the new console flow.
1 parent fb77268 commit 4baace1

30 files changed

Lines changed: 2825 additions & 122 deletions

.github/workflows/ci.yml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,29 @@ jobs:
2929
cache: true
3030

3131
- name: Run tests
32-
run: go test ./...
32+
run: go test -coverprofile=coverage.out ./...
3333

3434
- name: Run vet
3535
run: go vet ./...
3636

37+
- name: Run race tests
38+
run: go test -race ./cmd ./pkg/session ./pkg/accesspolicy ./pkg/publicauth ./pkg/k8s ./pkg/clusterconnect ./pkg/tunnel
39+
40+
- name: Install gosec
41+
run: go install github.com/securego/gosec/v2/cmd/gosec@v2.22.8
42+
43+
- name: Run gosec
44+
run: $(go env GOPATH)/bin/gosec ./cmd ./pkg/... ./assets ./
45+
3746
- name: Check npm package builder syntax
3847
run: node --check scripts/build-npm-packages.mjs
3948

49+
- name: Upload coverage profile
50+
uses: actions/upload-artifact@v4
51+
with:
52+
name: go-coverage
53+
path: coverage.out
54+
4055
goreleaser-snapshot:
4156
runs-on: ubuntu-latest
4257
needs: test

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ NPM ?= npm
88
# Binary name
99
BINARY_NAME=sealtun
1010

11-
# Get version from git (Pure Git Hash mode)
12-
VERSION ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "dev")
11+
# Development builds use the explicit dev channel; releases inject semver via GoReleaser.
12+
VERSION ?= dev
1313
NPM_LATEST_RELEASE_TAG ?= $(shell git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)
1414
NPM_VERSION ?= $(shell echo $(NPM_LATEST_RELEASE_TAG) | sed 's/^v//')
1515
NPM_RELEASE_TAG ?= v$(NPM_VERSION)

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Sealtun 是一款面向 **Sealos Cloud** 和 **Kubernetes** 用户的本地隧
1919
- 🛡️ **安全运营**:HTTPS 隧道支持 Basic Auth、Bearer Token、临时链接、IP 规则、rate limit、访问审计和 server secret 轮换。
2020
- 📊 **状态与诊断**`doctor <tunnel-id>``inspect --remote``logs``events``metrics``resources``watch` 可定位本地端口、daemon、远端 Pod、Service、Ingress 与证书问题。
2121
- 🧭 **引导与自动修复**`init` 可根据登录状态和本地监听端口推荐命令/YAML;`resources``watch``doctor --fix --dry-run` 可帮助理解和保守修复隧道状态。
22+
- 🖥️ **终端控制台**`tui` / `console` 可在交互式终端里发现端口、创建隧道,并管理日志、事件、资源、域名、策略、分享链接和生命周期操作。
2223
- 🔌 **集群内服务访问**:Linux 下 `sudo sealtun connect` 可直接访问 Service FQDN、Service ClusterIP 和 Pod IP 的 TCP 流量,无需 SOCKS 或客户端代理配置。
2324
- 🧩 **协议模板**`template https|ssh|tcp|mysql|postgres|redis|mongodb|mqtt` 可生成直接命令和 `sealtun.yaml` 示例。
2425
- 🧾 **声明式配置**`apply -f sealtun.yaml` 可用 YAML 声明隧道,并以稳定名称幂等创建或更新;`export` 可把本地 session 导出回 YAML。
@@ -137,6 +138,16 @@ sealtun start <tunnel-id>
137138
sealtun cleanup <tunnel-id>
138139
```
139140

141+
偏交互式的日常管理可以直接打开终端控制台:
142+
143+
```bash
144+
sealtun tui
145+
# 等价别名
146+
sealtun console
147+
```
148+
149+
TUI 会复用现有 CLI 逻辑。默认焦点在左侧菜单,上下键切换 `Tunnels``Create``Tools``Status`;按 `enter``right``tab` 进入右侧内容,按 `left``esc``tab` 回到菜单。`Tunnels` 用来查看和选择隧道,选中后按 `o` 进入 `Tunnel Actions`,可以执行 inspect/doctor/logs/metrics/events/resources/export、domain/policy/share/resources/rotate/repair/start/stop/cleanup 等对象级操作;`Create` 用来从本地监听端口创建基础 HTTPS/SSH/TCP 隧道;`Tools` 只放全局操作,例如 status/discover/init/template/connect check/domain status/doctor dry-run/export all,以及 YAML apply dry-run/diff/apply。写操作都会先展示等价 CLI 命令并要求确认。`connect` 真实接管网络规则仍建议使用明确的 CLI 命令。
150+
140151
Linux 下访问集群内 Service/Pod:
141152

142153
```bash

README_EN.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Sealtun is a local tunnel CLI for **Sealos Cloud** and **Kubernetes** users. It
1919
- 🛡️ **Security Operations**: HTTPS tunnels support Basic Auth, Bearer tokens, temporary links, IP rules, rate limits, access audit, and server secret rotation.
2020
- 📊 **Status and Diagnostics**: Use `doctor <tunnel-id>`, `inspect --remote`, `logs`, `events`, `metrics`, `resources`, and `watch` to diagnose local ports, daemon state, remote Pods, Services, Ingresses, and certificates.
2121
- 🧭 **Guided UX and Safe Fixes**: Use `init` for first-run command/YAML recommendations, and `resources`, `watch`, or `doctor --fix --dry-run` to understand and conservatively repair tunnel state.
22+
- 🖥️ **Terminal Console**: Use `tui` / `console` in an interactive terminal to discover ports, create tunnels, and manage logs, events, resources, domains, policies, share links, and lifecycle actions.
2223
- 🔌 **Cluster Service Access**: On Linux, `sudo sealtun connect` lets TCP clients directly reach Service FQDNs, Service ClusterIPs, and Pod IPs without SOCKS or client-side proxy config.
2324
- 🧩 **Protocol Templates**: Use `template https|ssh|tcp|mysql|postgres|redis|mongodb|mqtt` to generate commands and `sealtun.yaml` examples.
2425
- 🧾 **Declarative Config**: Use `apply -f sealtun.yaml` to declare tunnels in YAML and create or update them with stable names; use `export` to turn local sessions back into YAML.
@@ -137,6 +138,16 @@ sealtun start <tunnel-id>
137138
sealtun cleanup <tunnel-id>
138139
```
139140

141+
For interactive day-to-day management, open the terminal console:
142+
143+
```bash
144+
sealtun tui
145+
# alias
146+
sealtun console
147+
```
148+
149+
The TUI reuses the existing CLI logic. Focus starts on the left menu, where up/down switches between `Tunnels`, `Create`, `Tools`, and `Status`; press `enter`, `right`, or `tab` to enter the content area, and `left`, `esc`, or `tab` to return to the menu. `Tunnels` lists and selects sessions; press `o` on a selected tunnel to open `Tunnel Actions` for inspect/doctor/logs/metrics/events/resources/export plus domain/policy/share/resources/rotate/repair/start/stop/cleanup operations. `Create` builds a basic HTTPS/SSH/TCP tunnel from a discovered local port. `Tools` is global-only and runs status, discover, init, template, connect check, domain status, doctor dry-run, export all, and YAML apply dry-run/diff/apply. Write operations show the equivalent CLI command and require confirmation. Real `connect` network takeover remains an explicit CLI flow.
150+
140151
Access in-cluster Services/Pods from Linux:
141152

142153
```bash

cmd/connect.go

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"os"
88
"os/signal"
9+
"syscall"
910

1011
"github.com/labring/sealtun/pkg/clusterconnect"
1112
"github.com/spf13/cobra"
@@ -23,6 +24,20 @@ var connectOpts = connectOptions{
2324
Mode: clusterconnect.ModeAuto,
2425
}
2526

27+
type connectPreflighter interface {
28+
Preflight(context.Context, clusterconnect.Options) (*clusterconnect.Preflight, error)
29+
}
30+
31+
type connectPlanRunner interface {
32+
Plan(context.Context) (*clusterconnect.TransparentPlan, error)
33+
RunPlan(context.Context, *clusterconnect.TransparentPlan) error
34+
}
35+
36+
var (
37+
connectSaveState = clusterconnect.SaveState
38+
connectRemoveState = clusterconnect.RemoveState
39+
)
40+
2641
var connectCmd = &cobra.Command{
2742
Use: "connect",
2843
Short: "Connect local TCP clients to Services or Pods in the active namespace",
@@ -74,7 +89,8 @@ func runConnectCheck(cmd *cobra.Command, opts connectOptions) error {
7489
if err != nil {
7590
return err
7691
}
77-
preflight, err := env.Preflight(cmd.Context(), clusterconnect.Options{
92+
ctx := connectCommandContext(cmd)
93+
preflight, err := env.Preflight(ctx, clusterconnect.Options{
7894
Mode: opts.Mode,
7995
Namespace: opts.Namespace,
8096
})
@@ -99,7 +115,14 @@ func runConnect(cmd *cobra.Command, opts connectOptions) error {
99115
if err != nil {
100116
return err
101117
}
102-
preflight, err := env.Preflight(cmd.Context(), clusterconnect.Options{
118+
return runConnectWithEnvironment(cmd, opts, env, func(options clusterconnect.TransparentOptions) connectPlanRunner {
119+
return clusterconnect.NewTransparentServer(env, options)
120+
})
121+
}
122+
123+
func runConnectWithEnvironment(cmd *cobra.Command, opts connectOptions, env connectPreflighter, newServer func(clusterconnect.TransparentOptions) connectPlanRunner) error {
124+
ctx := connectCommandContext(cmd)
125+
preflight, err := env.Preflight(ctx, clusterconnect.Options{
103126
Mode: opts.Mode,
104127
Namespace: opts.Namespace,
105128
})
@@ -119,11 +142,11 @@ func runConnect(cmd *cobra.Command, opts connectOptions) error {
119142
printConnectPreflight(cmd, preflight)
120143
}
121144

122-
server := clusterconnect.NewTransparentServer(env, clusterconnect.TransparentOptions{
145+
server := newServer(clusterconnect.TransparentOptions{
123146
Namespace: opts.Namespace,
124147
Listen: opts.Listen,
125148
})
126-
plan, err := server.Plan(cmd.Context())
149+
plan, err := server.Plan(ctx)
127150
if err != nil {
128151
return err
129152
}
@@ -139,12 +162,12 @@ func runConnect(cmd *cobra.Command, opts connectOptions) error {
139162
Hosts: plan.Hosts,
140163
PID: os.Getpid(),
141164
}
142-
if err := clusterconnect.SaveState(state); err != nil {
165+
if err := connectSaveState(state); err != nil {
143166
return err
144167
}
145-
defer clusterconnect.RemoveState()
168+
defer connectRemoveState()
146169

147-
connectCtx, stopSignals := signal.NotifyContext(cmd.Context(), os.Interrupt)
170+
connectCtx, stopSignals := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
148171
defer stopSignals()
149172
err = server.RunPlan(connectCtx, plan)
150173
if err == nil || err == context.Canceled {
@@ -153,6 +176,16 @@ func runConnect(cmd *cobra.Command, opts connectOptions) error {
153176
return err
154177
}
155178

179+
func connectCommandContext(cmd *cobra.Command) context.Context {
180+
if cmd == nil {
181+
return context.Background()
182+
}
183+
if ctx := cmd.Context(); ctx != nil {
184+
return ctx
185+
}
186+
return context.Background()
187+
}
188+
156189
func runConnectStatus(cmd *cobra.Command, asJSON bool) error {
157190
state, path, err := clusterconnect.LoadState()
158191
if err != nil {

cmd/connect_test.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"errors"
6+
"io"
7+
"reflect"
8+
"testing"
9+
10+
"github.com/labring/sealtun/pkg/clusterconnect"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
func TestRunConnectSavesStateBeforeRunAndRemovesAfterRun(t *testing.T) {
15+
previousSave := connectSaveState
16+
previousRemove := connectRemoveState
17+
t.Cleanup(func() {
18+
connectSaveState = previousSave
19+
connectRemoveState = previousRemove
20+
})
21+
22+
events := []string{}
23+
var saved clusterconnect.State
24+
connectSaveState = func(state clusterconnect.State) error {
25+
events = append(events, "save")
26+
saved = state
27+
return nil
28+
}
29+
connectRemoveState = func() error {
30+
events = append(events, "remove")
31+
return nil
32+
}
33+
34+
plan := &clusterconnect.TransparentPlan{
35+
Namespace: "ns-test",
36+
Listen: "127.0.0.1:15443",
37+
Rules: []clusterconnect.RedirectRule{{Destination: "10.96.0.12", Port: 80}},
38+
Hosts: []clusterconnect.HostEntry{{IP: "10.96.0.12", Host: "web.ns-test.svc.cluster.local"}},
39+
}
40+
server := &fakeConnectServer{plan: plan, events: &events}
41+
env := &fakeConnectEnv{preflight: &clusterconnect.Preflight{
42+
SelectedMode: clusterconnect.ModeTun,
43+
Namespace: "ns-test",
44+
Region: "https://gzg.sealos.run",
45+
ActiveProfile: "gzg",
46+
}}
47+
cmd := newTestConnectCommand()
48+
49+
err := runConnectWithEnvironment(cmd, connectOptions{
50+
Mode: clusterconnect.ModeTun,
51+
Namespace: "ns-test",
52+
Listen: "127.0.0.1:15443",
53+
}, env, func(options clusterconnect.TransparentOptions) connectPlanRunner {
54+
if options.Namespace != "ns-test" || options.Listen != "127.0.0.1:15443" {
55+
t.Fatalf("unexpected transparent options: %#v", options)
56+
}
57+
return server
58+
})
59+
if err != nil {
60+
t.Fatalf("runConnectWithEnvironment returned error: %v", err)
61+
}
62+
if want := []string{"plan", "save", "run", "remove"}; !reflect.DeepEqual(events, want) {
63+
t.Fatalf("unexpected event order: got %v want %v", events, want)
64+
}
65+
if server.runPlan != plan {
66+
t.Fatalf("RunPlan received wrong plan: %#v", server.runPlan)
67+
}
68+
if saved.Mode != clusterconnect.ModeTun || saved.Namespace != "ns-test" || saved.Region != "https://gzg.sealos.run" || saved.Profile != "gzg" {
69+
t.Fatalf("unexpected saved state: %#v", saved)
70+
}
71+
if saved.Listen != plan.Listen || saved.RouteCount != 1 || saved.HostCount != 1 {
72+
t.Fatalf("saved state does not reflect plan: %#v", saved)
73+
}
74+
if saved.PID == 0 {
75+
t.Fatalf("expected saved state PID to be set")
76+
}
77+
}
78+
79+
func TestRunConnectDoesNotSaveStateWhenPlanFails(t *testing.T) {
80+
previousSave := connectSaveState
81+
previousRemove := connectRemoveState
82+
t.Cleanup(func() {
83+
connectSaveState = previousSave
84+
connectRemoveState = previousRemove
85+
})
86+
87+
events := []string{}
88+
connectSaveState = func(clusterconnect.State) error {
89+
events = append(events, "save")
90+
return nil
91+
}
92+
connectRemoveState = func() error {
93+
events = append(events, "remove")
94+
return nil
95+
}
96+
planErr := errors.New("plan failed")
97+
server := &fakeConnectServer{planErr: planErr, events: &events}
98+
env := &fakeConnectEnv{preflight: &clusterconnect.Preflight{
99+
SelectedMode: clusterconnect.ModeTun,
100+
Namespace: "ns-test",
101+
Region: "https://gzg.sealos.run",
102+
ActiveProfile: "gzg",
103+
}}
104+
105+
err := runConnectWithEnvironment(newTestConnectCommand(), connectOptions{}, env, func(clusterconnect.TransparentOptions) connectPlanRunner {
106+
return server
107+
})
108+
if !errors.Is(err, planErr) {
109+
t.Fatalf("expected plan error, got %v", err)
110+
}
111+
if want := []string{"plan"}; !reflect.DeepEqual(events, want) {
112+
t.Fatalf("unexpected event order: got %v want %v", events, want)
113+
}
114+
}
115+
116+
type fakeConnectEnv struct {
117+
preflight *clusterconnect.Preflight
118+
err error
119+
options clusterconnect.Options
120+
}
121+
122+
func (f *fakeConnectEnv) Preflight(ctx context.Context, opts clusterconnect.Options) (*clusterconnect.Preflight, error) {
123+
f.options = opts
124+
return f.preflight, f.err
125+
}
126+
127+
type fakeConnectServer struct {
128+
plan *clusterconnect.TransparentPlan
129+
planErr error
130+
runErr error
131+
runPlan *clusterconnect.TransparentPlan
132+
events *[]string
133+
}
134+
135+
func (s *fakeConnectServer) Plan(context.Context) (*clusterconnect.TransparentPlan, error) {
136+
*s.events = append(*s.events, "plan")
137+
return s.plan, s.planErr
138+
}
139+
140+
func (s *fakeConnectServer) RunPlan(ctx context.Context, plan *clusterconnect.TransparentPlan) error {
141+
*s.events = append(*s.events, "run")
142+
s.runPlan = plan
143+
return s.runErr
144+
}
145+
146+
func newTestConnectCommand() *cobra.Command {
147+
cmd := &cobra.Command{}
148+
cmd.SetOut(io.Discard)
149+
cmd.SetErr(io.Discard)
150+
return cmd
151+
}

cmd/discover.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ func readProcListeningPorts() ([]discoverItem, error) {
193193
}
194194

195195
func readProcNetTCP(path string) ([]discoverItem, error) {
196-
file, err := os.Open(path)
196+
file, err := os.Open(path) // #nosec G304 -- callers pass fixed /proc/net/tcp paths for local port discovery.
197197
if err != nil {
198198
return nil, err
199199
}

0 commit comments

Comments
 (0)