Skip to content

Commit ed287f9

Browse files
author
gitlayzer
committed
feat: add ssh tunnels and preserved stop flow
1 parent ed3d24d commit ed287f9

40 files changed

Lines changed: 1631 additions & 97 deletions

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
FROM golang:1.25.10-alpine AS builder
22

33
WORKDIR /app
4+
ENV GOPROXY=https://proxy.golang.org|direct
5+
RUN apk add --no-cache ca-certificates git
46
COPY go.mod go.sum* ./
57
RUN go mod download
68

@@ -10,5 +12,6 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s -X github.com/labring/seal
1012

1113
FROM scratch
1214

15+
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
1316
COPY --from=builder /app/sealtun /sealtun
1417
ENTRYPOINT ["/sealtun"]

README.md

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -182,14 +182,41 @@ export SEALTUN_TEMP_TOKEN='review-link-secret'
182182
sealtun expose 3000 --temporary-access-token-env SEALTUN_TEMP_TOKEN --temporary-access-ttl 1h
183183
```
184184

185-
Bearer Token 和临时链接 token 至少需要 8 个字符,只保存 SHA-256 hash,不会写入 Deployment 参数;临时链接使用 `?_sealtun_token=...` 访问,Sealtun 会在转发到本地服务前移除该查询参数。IP 规则优先使用 Ingress/代理传入的 `X-Real-IP`再回退到最近一跳 `X-Forwarded-For`。Basic Auth 与 Bearer/临时链接同时配置时,任一认证方式通过即可访问。
185+
Bearer Token 和临时链接 token 至少需要 8 个字符,只保存 SHA-256 hash,不会写入 Deployment 参数;临时链接使用 `?_sealtun_token=...` 访问,Sealtun 会在转发到本地服务前移除该查询参数。IP 规则优先使用 Ingress/代理传入的 `X-Real-IP`再回退到 `X-Forwarded-For` 中最后一个有效的代理确认客户端 IP。Basic Auth 与 Bearer/临时链接同时配置时,任一认证方式通过即可访问。
186186

187187
Sealtun 会自动执行以下操作:
188188
1. 在你的 Sealos Namespace 中启动一个隧道代理 Pod。
189189
2. 配置 Ingress 路由规则。
190190
3. 建立加密 WebSocket 隧道,并将所有流量转发至 `localhost:3000`
191191

192-
### 3. 使用自定义域名
192+
### 3. SSH 公网访问
193+
如果 Sealos Region 支持公网 TCP NodePort,可以用四层 SSH 模式直接连接公网域名和端口:
194+
195+
```bash
196+
# macOS/Linux 常见 SSH 端口是 22;也可以换成本机 sshd 监听的其他端口
197+
sealtun expose 22 --protocol ssh
198+
```
199+
200+
命令会输出公网 SSH 入口:
201+
```bash
202+
ssh <user>@<public-host> -p <node-port>
203+
```
204+
205+
也可以写进 `~/.ssh/config`,之后直接 `ssh sealtun-dev`
206+
```sshconfig
207+
Host sealtun-dev
208+
HostName <public-host>
209+
User <user>
210+
Port <node-port>
211+
```
212+
213+
`--protocol ssh` 的公网业务入口只有 TCP NodePort,不会提供默认 HTTPS 业务 URL。Sealtun 仍会保留内部控制通道供本地 daemon 连接远端 Pod,但它不作为 SSH 隧道的用户访问入口。Basic Auth、Bearer Token、临时链接、IP 规则和自定义域名只适用于 HTTPS 隧道,不适用于 SSH 四层入口。旧的 WebSocket ProxyCommand 备用方式仍可用:
214+
215+
```bash
216+
ssh -o ProxyCommand='sealtun ssh connect <tunnel-id>' <user>@sealtun
217+
```
218+
219+
### 4. 使用自定义域名
193220
新建隧道时先生成官方 Sealos 域名和 CNAME 目标:
194221
```bash
195222
sealtun expose 3000 --domain app.example.com
@@ -311,7 +338,8 @@ basicAuth:
311338

312339
## 🛠️ 架构详情
313340

314-
- **底层协议**:基于 WebSocket 的 Yamux 多路复用。
341+
- **HTTPS 隧道协议**:基于 WebSocket 的 Yamux 多路复用。
342+
- **SSH 四层入口**:`--protocol ssh` 只提供公网 TCP NodePort 直连本地 SSH;HTTPS 只作为内部控制通道,不提供默认业务 URL。
315343
- **Sealos 资源**:触发 `sealtun expose` 时,会在集群中创建以 `sealtun-*` 命名的 `Deployment`、`Service` 和 `Ingress`。
316344
- **镜像来源**:依赖于 `ghcr.io/gitlayzer/sealtun` 的原生镜像。
317345

@@ -331,12 +359,13 @@ basicAuth:
331359
- 声明式配置支持 `sealtun diff -f`、多 tunnel 批量 apply 和 `ttl` 自动过期清理。
332360
- `logs` 读取远端 tunnel Pod 日志;`metrics` 聚合本地、远端和 server counters,其中 server counters 需要新版本远端镜像支持。
333361
- `dashboard` 是本地只读 Web 控制台,不需要额外服务端组件。
334-
- `apply -f sealtun.yaml` 是声明式配置 MVP,当前覆盖 HTTPS 隧道、稳定 tunnel name、自定义域名指引和 daemon 托管。
335-
- 提供 `status`、`list`、`inspect`、`doctor`、`stop`、`cleanup`、`logout` 等本地控制命令。
362+
- `apply -f sealtun.yaml` 支持 HTTPS 隧道、SSH 四层隧道、稳定 tunnel name、自定义域名指引和 daemon 托管。
363+
- 提供 `status`、`list`、`inspect`、`doctor`、`stop`、`start/resume`、`cleanup`、`logout` 等本地控制命令。
364+
- `stop` 只会把远端 tunnel Pod 副本缩容为 0,保留域名、Service、Ingress、Secret 与本地 session;可用 `sealtun start <tunnel-id>` 恢复。`cleanup` 默认删除 stopped/expired/stale 隧道,`cleanup --all` 才会强制删除所有本地跟踪的隧道资源。
336365
- `list` 默认只读取本地 session;需要探测本地端口健康时可使用 `list --check`。
337366
- `inspect` 默认展示本地状态;需要远端 Kubernetes 诊断时可使用 `inspect --remote`。
338367
- `logout` 会先回收本地记录中的隧道资源再删除凭据;如果只想强制清除本地凭据,可使用 `logout --force`。
339-
- 当前 `--protocol` 只接受 `https`TCP、UDP 和 gRPC 泛化暂不支持,后续如果需要会以单独能力设计,而不是继续复用当前 HTTP Ingress 路径。
368+
- 当前 `--protocol` 支持 `https` 和专用 `ssh` 模式。`ssh` 不支持 Basic Auth、Bearer Token、临时链接、IP 规则和自定义域名。通用 TCP、UDP 和 gRPC 泛化暂不支持,后续如果需要会以单独能力设计,而不是继续复用当前 HTTP Ingress 路径。
340369
- `doctor` 会汇总本地 daemon、登录、session、端口健康和远端 Deployment、Service、Ingress、Pod 与 Event 状态,用于定位镜像拉取、Pod 未就绪、Ingress 缺失等问题。
341370

342371
## 📄 许可证

README_EN.md

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,14 +182,41 @@ export SEALTUN_TEMP_TOKEN='review-link-secret'
182182
sealtun expose 3000 --temporary-access-token-env SEALTUN_TEMP_TOKEN --temporary-access-ttl 1h
183183
```
184184

185-
Bearer and temporary-link tokens must be at least 8 characters. They are stored only as SHA-256 hashes and are not written into Deployment args. Temporary links use `?_sealtun_token=...`; Sealtun strips that query parameter before forwarding the request to your local service. IP rules prefer the `X-Real-IP` value set by the Ingress/proxy and fall back to the nearest `X-Forwarded-For` hop. When Basic Auth and Bearer/temporary tokens are both configured, either authentication method can grant access.
185+
Bearer and temporary-link tokens must be at least 8 characters. They are stored only as SHA-256 hashes and are not written into Deployment args. Temporary links use `?_sealtun_token=...`; Sealtun strips that query parameter before forwarding the request to your local service. IP rules prefer the `X-Real-IP` value set by the Ingress/proxy and fall back to the last valid proxy-confirmed client IP in `X-Forwarded-For`. When Basic Auth and Bearer/temporary tokens are both configured, either authentication method can grant access.
186186

187187
Sealtun will:
188188
1. Spin up a tunnel proxy Pod in your Sealos namespace.
189189
2. Establish the Ingress routes.
190190
3. Automatically connect via WebSockets and proxy all L7 connections back to `localhost:3000`.
191191

192-
### 3. Use a custom domain
192+
### 3. Public SSH access
193+
If the Sealos region supports public TCP NodePort, use the L4 SSH mode to connect directly to the public host and port:
194+
195+
```bash
196+
# macOS/Linux commonly use port 22; replace it if your local sshd listens elsewhere
197+
sealtun expose 22 --protocol ssh
198+
```
199+
200+
The command prints a public SSH endpoint:
201+
```bash
202+
ssh <user>@<public-host> -p <node-port>
203+
```
204+
205+
Or add an SSH config entry and then run `ssh sealtun-dev`:
206+
```sshconfig
207+
Host sealtun-dev
208+
HostName <public-host>
209+
User <user>
210+
Port <node-port>
211+
```
212+
213+
`--protocol ssh` exposes only a public TCP NodePort for user traffic and does not provide a default HTTPS application URL. Sealtun still keeps an internal control channel so the local daemon can connect to the remote pod, but that channel is not a user-facing SSH entry. Basic Auth, Bearer tokens, temporary links, IP policies, and custom domains apply only to HTTPS tunnels, not to the L4 SSH entry. The older WebSocket ProxyCommand fallback remains available:
214+
215+
```bash
216+
ssh -o ProxyCommand='sealtun ssh connect <tunnel-id>' <user>@sealtun
217+
```
218+
219+
### 4. Use a custom domain
193220
Create the tunnel first and print the Sealos-managed CNAME target:
194221
```bash
195222
sealtun expose 3000 --domain app.example.com
@@ -311,14 +338,15 @@ basicAuth:
311338

312339
## Architecture Details
313340

314-
- **Protocol**: Yamux over Websocket.
341+
- **HTTPS tunnel protocol**: Yamux over WebSocket.
342+
- **SSH L4 entry**: `--protocol ssh` exposes only a public TCP NodePort that connects directly to local SSH; HTTPS is kept only as an internal control channel, not as a default application URL.
315343
- **Sealos Resources**: When you trigger `sealtun expose`, it creates `sealtun-*` variants of `Deployment`, `Service`, and `Ingress` in the active cluster context.
316344
- **Images**: Relies on a single Docker image built natively targeting `ghcr.io/gitlayzer/sealtun`.
317345

318346
## Hardening Notes
319347

320348
- `expose` now validates port and protocol inputs before provisioning remote resources.
321-
- `--protocol` currently supports only `https`. TCP, UDP, and gRPC are intentionally out of scope until there is a dedicated transport design for them.
349+
- `--protocol` currently supports `https` and the dedicated `ssh` mode. `ssh` does not support Basic Auth, Bearer tokens, temporary links, IP policies, or custom domains. Generic TCP, UDP, and gRPC are intentionally out of scope until there is a dedicated transport design for them.
322350
- `profile` supports named login bundles for multiple accounts, regions, and workspaces; `profile use` switches the active kubeconfig used by later `expose`, `status`, and `region current` commands.
323351
- Ingress host generation prefers the `SEALOS_DOMAIN` returned by Sealos Launchpad instead of guessing from the region host.
324352
- Custom domains must pass CNAME ownership verification before Sealtun writes the custom host to Ingress, preventing unverified host preemption on shared Ingress controllers.
@@ -329,7 +357,9 @@ basicAuth:
329357
- Declarative config supports `sealtun diff -f`, multi-tunnel batch apply, and `ttl` automatic expiry cleanup.
330358
- `logs` reads remote tunnel pod logs; `metrics` aggregates local state, remote readiness, and server counters when the remote image supports them.
331359
- `dashboard` is a local read-only web console and does not require any additional hosted backend.
332-
- `apply -f sealtun.yaml` is the declarative config MVP for HTTPS tunnels, stable tunnel names, custom domain guidance, and daemon-managed sessions.
360+
- `apply -f sealtun.yaml` supports HTTPS tunnels, SSH L4 tunnels, stable tunnel names, custom domain guidance, and daemon-managed sessions.
361+
- Local controls include `status`, `list`, `inspect`, `doctor`, `stop`, `start/resume`, `cleanup`, and `logout`.
362+
- `stop` only scales the remote tunnel pod Deployment to zero, preserving the domain, Service, Ingress, secrets, and local session. Use `sealtun start <tunnel-id>` to reopen it. `cleanup` deletes stopped, expired, or stale tunnels by default; `cleanup --all` is the force path for deleting every locally tracked tunnel.
333363
- `list` reads local session records by default; use `list --check` to probe local target ports and report degraded sessions.
334364
- `inspect` shows local session state by default; use `inspect --remote` to include best-effort Kubernetes diagnostics.
335365
- `doctor` summarizes daemon, login, session, local port, and remote Deployment, Service, Ingress, Pod, and Event diagnostics.

cmd/apply.go

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,11 @@ type diffResult struct {
6767
type applyResult struct {
6868
Name string `json:"name"`
6969
TunnelID string `json:"tunnelId"`
70+
Protocol string `json:"protocol"`
7071
Host string `json:"host"`
7172
SealosHost string `json:"sealosHost,omitempty"`
7273
CustomDomain string `json:"customDomain,omitempty"`
74+
PublicPort int32 `json:"publicPort,omitempty"`
7375
LocalPort string `json:"localPort"`
7476
BasicAuth bool `json:"basicAuth"`
7577
BasicAuthUser string `json:"basicAuthUser,omitempty"`
@@ -183,6 +185,7 @@ func runApply(ctx context.Context, path string, dryRun bool) ([]applyResult, err
183185
results = append(results, applyResult{
184186
Name: normalized.Name,
185187
TunnelID: normalized.TunnelID,
188+
Protocol: normalized.Protocol,
186189
LocalPort: normalized.LocalPort,
187190
BasicAuth: normalized.BasicAuth != nil && normalized.BasicAuth.Enabled,
188191
BasicAuthUser: basicAuthUsername(normalized.BasicAuth),
@@ -296,6 +299,7 @@ func applyOneTunnel(ctx context.Context, item applyTunnel, authData *auth.AuthDa
296299
result = applyResult{
297300
Name: normalized.Name,
298301
TunnelID: normalized.TunnelID,
302+
Protocol: normalized.Protocol,
299303
LocalPort: normalized.LocalPort,
300304
BasicAuth: normalized.BasicAuth != nil && normalized.BasicAuth.Enabled,
301305
BasicAuthUser: basicAuthUsername(normalized.BasicAuth),
@@ -439,6 +443,7 @@ func applyOneTunnel(ctx context.Context, item applyTunnel, authData *auth.AuthDa
439443
result.Host = hosts.PublicHost
440444
result.SealosHost = hosts.SealosHost
441445
result.CustomDomain = hosts.CustomDomain
446+
result.PublicPort = hosts.PublicPort
442447
result.BasicAuth = normalized.BasicAuth != nil && normalized.BasicAuth.Enabled
443448
result.BasicAuthUser = basicAuthUsername(normalized.BasicAuth)
444449
result.AccessPolicy = normalized.AccessPolicy != nil
@@ -463,6 +468,7 @@ func buildApplySessionRecord(normalized normalizedApplyTunnel, authData *auth.Au
463468
Host: hosts.PublicHost,
464469
SealosHost: hosts.SealosHost,
465470
CustomDomain: hosts.CustomDomain,
471+
PublicPort: hosts.PublicPort,
466472
LocalPort: normalized.LocalPort,
467473
Secret: secret,
468474
BasicAuth: normalized.BasicAuth,
@@ -576,6 +582,7 @@ func normalizeApplyTunnel(item applyTunnel) (normalizedApplyTunnel, error) {
576582
if err := validateProtocol(protocol); err != nil {
577583
return normalizedApplyTunnel{}, fmt.Errorf("tunnel %s: %w", tunnelID, err)
578584
}
585+
protocol = tunnelprotocol.Normalize(protocol)
579586
customDomain, err := validateCustomDomain(item.Domain)
580587
if err != nil {
581588
return normalizedApplyTunnel{}, fmt.Errorf("tunnel %s: %w", tunnelID, err)
@@ -597,6 +604,17 @@ func normalizeApplyTunnel(item applyTunnel) (normalizedApplyTunnel, error) {
597604
if err != nil {
598605
return normalizedApplyTunnel{}, fmt.Errorf("tunnel %s accessPolicy: %w", tunnelID, err)
599606
}
607+
if protocol == tunnelprotocol.SSH {
608+
if customDomain != "" || item.WaitDomain {
609+
return normalizedApplyTunnel{}, fmt.Errorf("tunnel %s: domain and waitDomain are only supported for https tunnels", tunnelID)
610+
}
611+
if basicAuth != nil {
612+
return normalizedApplyTunnel{}, fmt.Errorf("tunnel %s: basicAuth is only supported for https tunnels", tunnelID)
613+
}
614+
if accessPolicy != nil {
615+
return normalizedApplyTunnel{}, fmt.Errorf("tunnel %s: accessPolicy is only supported for https tunnels", tunnelID)
616+
}
617+
}
600618
ttl := strings.TrimSpace(item.TTL)
601619
expiresAt, err := resolveApplyTunnelExpiresAt(ttl, now)
602620
if err != nil {
@@ -606,7 +624,7 @@ func normalizeApplyTunnel(item applyTunnel) (normalizedApplyTunnel, error) {
606624
Name: item.Name,
607625
TunnelID: tunnelID,
608626
LocalPort: localPort,
609-
Protocol: tunnelprotocol.Normalize(protocol),
627+
Protocol: protocol,
610628
CustomDomain: customDomain,
611629
BasicAuth: basicAuth,
612630
BasicAuthPass: basicAuthPass,
@@ -752,17 +770,36 @@ func printApplyResults(cmd *cobra.Command, results []applyResult, dryRun bool) {
752770
fmt.Fprintln(out, "Sealtun Apply Results")
753771
}
754772
for _, result := range results {
773+
endpoint := endpointDisplay(result.Protocol, result.Host, result.SealosHost, result.PublicPort)
755774
fmt.Fprintf(out, " - %s (%s): %s localhost:%s", result.Name, result.TunnelID, result.Status, result.LocalPort)
756-
if result.Host != "" {
757-
fmt.Fprintf(out, " -> https://%s", result.Host)
775+
if result.Protocol == tunnelprotocol.SSH && endpoint.Command != "" {
776+
fmt.Fprintf(out, " -> %s", endpoint.Command)
777+
} else if endpoint.URL != "" {
778+
fmt.Fprintf(out, " -> %s", endpoint.URL)
758779
}
759780
fmt.Fprintln(out)
781+
if result.Protocol != "" {
782+
fmt.Fprintf(out, " Protocol: %s\n", result.Protocol)
783+
}
760784
if result.SealosHost != "" {
761785
fmt.Fprintf(out, " Sealos host: %s\n", result.SealosHost)
762786
}
763787
if result.CustomDomain != "" {
764788
fmt.Fprintf(out, " Custom domain: %s\n", result.CustomDomain)
765789
}
790+
if result.Protocol == tunnelprotocol.SSH {
791+
if endpoint.Host != "" {
792+
fmt.Fprintf(out, " Public SSH host: %s\n", endpoint.Host)
793+
}
794+
if endpoint.Port != 0 {
795+
fmt.Fprintf(out, " Public SSH port: %d\n", endpoint.Port)
796+
}
797+
if endpoint.Command != "" {
798+
fmt.Fprintf(out, " SSH command: %s\n", endpoint.Command)
799+
}
800+
} else if endpoint.URL != "" {
801+
fmt.Fprintf(out, " Public URL: %s\n", endpoint.URL)
802+
}
766803
if result.BasicAuth {
767804
fmt.Fprintf(out, " Basic Auth: enabled")
768805
if result.BasicAuthUser != "" {

cmd/apply_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,35 @@ tunnels:
3737
if results[0].TunnelID != "web" || results[0].LocalPort != "3000" || results[0].Status != "planned" {
3838
t.Fatalf("unexpected dry-run result: %+v", results[0])
3939
}
40+
if results[0].Protocol != "https" {
41+
t.Fatalf("expected dry-run protocol to be reported, got %q", results[0].Protocol)
42+
}
43+
}
44+
45+
func TestRunApplyDryRunReportsSSHProtocol(t *testing.T) {
46+
t.Parallel()
47+
48+
path := filepath.Join(t.TempDir(), "sealtun.yaml")
49+
data := []byte(`version: v1
50+
tunnels:
51+
- name: ssh-dev
52+
localPort: 22
53+
protocol: ssh
54+
`)
55+
if err := os.WriteFile(path, data, 0o600); err != nil {
56+
t.Fatal(err)
57+
}
58+
59+
results, err := runApply(context.Background(), path, true)
60+
if err != nil {
61+
t.Fatalf("dry-run apply should accept ssh tunnels: %v", err)
62+
}
63+
if len(results) != 1 {
64+
t.Fatalf("expected one result, got %d", len(results))
65+
}
66+
if results[0].Protocol != "ssh" {
67+
t.Fatalf("expected ssh protocol to be reported, got %+v", results[0])
68+
}
4069
}
4170

4271
func TestBuildApplySessionRecordPersistsCustomDomain(t *testing.T) {
@@ -95,6 +124,39 @@ func TestNormalizeApplyTunnelDefaultsProtocol(t *testing.T) {
95124
}
96125
}
97126

127+
func TestNormalizeApplyTunnelRejectsHTTPOnlyOptionsForSSH(t *testing.T) {
128+
t.Setenv("SEALTUN_TEST_BEARER", "secret-token")
129+
130+
tests := []struct {
131+
name string
132+
item applyTunnel
133+
}{
134+
{
135+
name: "domain",
136+
item: applyTunnel{Name: "ssh", LocalPort: 22, Protocol: "ssh", Domain: "dev.example.com"},
137+
},
138+
{
139+
name: "wait domain",
140+
item: applyTunnel{Name: "ssh", LocalPort: 22, Protocol: "ssh", WaitDomain: true},
141+
},
142+
{
143+
name: "basic auth",
144+
item: applyTunnel{Name: "ssh", LocalPort: 22, Protocol: "ssh", BasicAuth: &applyBasicAuth{Credential: "admin:secret"}},
145+
},
146+
{
147+
name: "access policy",
148+
item: applyTunnel{Name: "ssh", LocalPort: 22, Protocol: "ssh", AccessPolicy: &applyAccessPolicy{BearerTokenEnv: "SEALTUN_TEST_BEARER"}},
149+
},
150+
}
151+
for _, tt := range tests {
152+
t.Run(tt.name, func(t *testing.T) {
153+
if _, err := normalizeApplyTunnel(tt.item); err == nil {
154+
t.Fatal("expected ssh tunnel with HTTP-only option to fail")
155+
}
156+
})
157+
}
158+
}
159+
98160
func TestNormalizeApplyTunnelResolvesBasicAuthPasswordEnv(t *testing.T) {
99161
t.Setenv("SEALTUN_TEST_BASIC_AUTH_PASSWORD", "secret")
100162

0 commit comments

Comments
 (0)