tunnel: migrate tunnel codes extract to github.com/gosuda/portal-tunnel
tunnel: migrate tunnel codes extract to github.com/gosuda/portal-tunnel
Kim committed
Nov 24, 2025 at 16:53 UTC
775a1038afd281e7b255492d8b61a3ddde09be6c
9 files changed
+3
-592
.github/workflows/cd.yml
-48
@@ -60,54 +60,6 @@ jobs:
60
cache-from: type=gha,mode=max
61
cache-to: type=gha,mode=max
62
63
- build-and-push-tunnel:
64
- name: Build and Push Tunnel Docker Image
65
- runs-on: ubuntu-latest
66
- permissions:
67
- contents: read
68
- packages: write
69
-
70
- steps:
71
- - name: Checkout code
72
- uses: actions/checkout@v5
73
-
74
- - name: Log in to Container Registry
75
- uses: docker/login-action@v3
76
- with:
77
- registry: ${{ env.REGISTRY }}
78
- username: ${{ github.actor }}
79
- password: ${{ secrets.GITHUB_TOKEN }}
80
-
81
- - name: Set up QEMU
82
- uses: docker/setup-qemu-action@v3
83
-
84
- - name: Set up Docker Buildx
85
- uses: docker/setup-buildx-action@v3
86
-
87
- - name: Extract metadata
88
- id: meta
89
- uses: docker/metadata-action@v5
90
- with:
91
- images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-tunnel
92
- tags: |
93
- type=ref,event=branch
94
- type=ref,event=pr
95
- type=semver,pattern={{version}}
96
- type=semver,pattern={{major}}.{{minor}}
97
- type=semver,pattern={{major}}
98
- type=sha,enable=true,prefix=sha-,suffix=,format=short
99
-
100
- - name: Build and push Docker image
101
- uses: docker/build-push-action@v6
102
- with:
103
- file: Dockerfile.tunnel
104
- platforms: ${{ env.PLATFORMS }}
105
- push: true
106
- tags: ${{ steps.meta.outputs.tags }}
107
- labels: ${{ steps.meta.outputs.labels }}
108
- cache-from: type=gha,mode=max
109
- cache-to: type=gha,mode=max
110
-
63
deploy:
64
name: Deploy to `https://portal.iwanhae.kr`
65
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
Dockerfile.tunnel
deleted
-21
@@ -1,21 +0,0 @@
1
-# syntax=docker/dockerfile:1
2
-
3
-FROM --platform=$BUILDPLATFORM golang:1 AS builder
4
-WORKDIR /src
5
-
6
-COPY go.mod go.sum ./
7
-RUN --mount=type=cache,target=/go/pkg/mod go mod download
8
-
9
-COPY . .
10
-
11
-ARG TARGETOS
12
-ARG TARGETARCH
13
-RUN --mount=type=cache,target=/go/pkg/mod \
14
- --mount=type=cache,target=/root/.cache/go-build \
15
- GOOS=${TARGETOS} GOARCH=${TARGETARCH} make build-tunnel
16
-
17
-FROM gcr.io/distroless/static-debian12:nonroot
18
-
19
-COPY --from=builder /src/bin/portal-tunnel /usr/bin/portal-tunnel
20
-
21
-ENTRYPOINT ["/usr/bin/portal-tunnel"]
Makefile
+2
-8
@@ -6,12 +6,11 @@ SHELL := /bin/sh
6
7
help:
8
@echo "Available targets:"
9
- @echo " make build - Build everything (protoc, wasm, frontend, server, tunnel)"
9
+ @echo " make build - Build everything (protoc, wasm, frontend, server)"
10
@echo " make build-protoc - Generate Go code from protobuf definitions"
11
@echo " make build-wasm - Build and compress WASM client with optimization"
12
@echo " make build-frontend - Build React frontend (Tailwind CSS 4)"
13
@echo " make build-server - Build Go relay server (includes frontend build)"
14
- @echo " make build-tunnel - Build Portal Tunnel CLI"
14
@echo " make run - Run relay server"
15
@echo " make clean - Remove build artifacts"
16
@@ -19,7 +18,7 @@ run:
18
./bin/relay-server
19
20
# Convenience target
22
-build: build-wasm build-frontend build-server build-tunnel
21
+build: build-wasm build-frontend build-server
22
23
build-protoc:
24
protoc -I . \
@@ -90,11 +89,6 @@ build-server:
89
@echo "[server] building Go portal..."
90
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o bin/relay-server ./cmd/relay-server
91
93
-# Build Portal Tunnel CLI (cloudflared-style tunnel)
94
-build-tunnel:
95
- @echo "[tunnel] building Portal Tunnel CLI..."
96
- CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o bin/portal-tunnel ./cmd/portal-tunnel
97
-
92
clean:
93
rm -rf bin
94
rm -rf cmd/relay-server/dist/app
README.md
-16
@@ -61,22 +61,6 @@ See [portal-toys](https://github.com/gosuda/portal-toys)
61
62
For a detailed overview of system components and data flow, see the [architecture documentation](docs/architecture.md).
63
64
-## Portal-tunnel
65
-
66
-portal-tunnel is a tunneling tool that connects a locally running service to a relay server, allowing external access.
67
-
68
-1. **Run with a config file** (Check the [example](cmd/portal-tunnel/config.yaml.example) for configuration details)
69
-
70
-```bash
71
-bin/portal-tunnel expose --config config.yaml
72
-```
73
-
74
-2. **Expose a single service directly**
75
-
76
-```bash
77
-bin/portal-tunnel expose --relay <url> [--relay <url> ...] --host localhost --port 8080 --name <service>
78
-```
79
-
64
## Glossary
65
66
If you need Portal-specific terminology, check the [Portal glossary](docs/glossary.md)
cmd/portal-tunnel/config.go
deleted
-185
@@ -1,185 +0,0 @@
1
-package main
2
-
3
-import (
4
- "fmt"
5
- "os"
6
- "strings"
7
-
8
- "gopkg.in/yaml.v3"
9
-)
10
-
11
-var defaultProtocols = []string{"http/1.1", "h2"}
12
-
13
-// RelayConfig describes a named relay endpoint and its bootstrap URLs.
14
-type RelayConfig struct {
15
- Name string `yaml:"name"`
16
- URLs []string `yaml:"urls"`
17
-}
18
-
19
-// ServiceConfig describes a local service exposed through the tunnel.
20
-type ServiceConfig struct {
21
- Name string `yaml:"name"`
22
- RelayPreference []string `yaml:"relayPreference"`
23
- Target string `yaml:"target"`
24
- Protocols []string `yaml:"protocols"`
25
-}
26
-
27
-// TunnelConfig represents the YAML configuration schema for portal-tunnel.
28
-type TunnelConfig struct {
29
- Relays []RelayConfig `yaml:"relays"`
30
- Services []ServiceConfig `yaml:"services"`
31
-}
32
-
33
-// RelayDirectory provides lookup helpers for relay definitions.
34
-type RelayDirectory struct {
35
- entries map[string]RelayConfig
36
-}
37
-
38
-// LoadConfig reads the YAML file at path, parses it into TunnelConfig, and validates it.
39
-func LoadConfig(path string) (*TunnelConfig, error) {
40
- data, err := os.ReadFile(path)
41
- if err != nil {
42
- return nil, fmt.Errorf("read config: %w", err)
43
- }
44
-
45
- var cfg TunnelConfig
46
- if err := yaml.Unmarshal(data, &cfg); err != nil {
47
- return nil, fmt.Errorf("parse config: %w", err)
48
- }
49
- cfg.applyDefaults()
50
-
51
- if err := cfg.validate(); err != nil {
52
- return nil, err
53
- }
54
-
55
- return &cfg, nil
56
-}
57
-
58
-// NewRelayDirectory builds a lookup structure for relay definitions.
59
-func NewRelayDirectory(relays []RelayConfig) *RelayDirectory {
60
- idx := make(map[string]RelayConfig, len(relays))
61
- for _, relay := range relays {
62
- idx[relay.Name] = relay
63
- }
64
- return &RelayDirectory{entries: idx}
65
-}
66
-
67
-// BootstrapServers aggregates URLs for the given relay preference list.
68
-// Preference order is preserved and duplicate URLs are removed.
69
-func (rd *RelayDirectory) BootstrapServers(preferences []string) ([]string, error) {
70
- if len(preferences) == 0 {
71
- return nil, fmt.Errorf("relayPreference must contain at least one relay name")
72
- }
73
-
74
- seen := map[string]struct{}{}
75
- var servers []string
76
- for _, relayName := range preferences {
77
- relayName = strings.TrimSpace(relayName)
78
- if relayName == "" {
79
- continue
80
- }
81
- relay, ok := rd.entries[relayName]
82
- if !ok {
83
- continue
84
- }
85
- for _, url := range relay.URLs {
86
- url = strings.TrimSpace(url)
87
- if url == "" {
88
- continue
89
- }
90
- if _, exists := seen[url]; exists {
91
- continue
92
- }
93
- seen[url] = struct{}{}
94
- servers = append(servers, url)
95
- }
96
- }
97
-
98
- if len(servers) == 0 {
99
- return nil, fmt.Errorf("no bootstrap servers resolved for relayPreference %v", preferences)
100
- }
101
-
102
- return servers, nil
103
-}
104
-
105
-func (cfg *TunnelConfig) validate() error {
106
- var errs []string
107
-
108
- relayIdx := map[string]RelayConfig{}
109
- if len(cfg.Relays) == 0 {
110
- errs = append(errs, "at least one relay must be defined")
111
- }
112
- for i, relay := range cfg.Relays {
113
- prefix := fmt.Sprintf("relays[%d]", i)
114
- name := strings.TrimSpace(relay.Name)
115
- if name == "" {
116
- errs = append(errs, fmt.Sprintf("%s: name is required", prefix))
117
- } else {
118
- if _, exists := relayIdx[name]; exists {
119
- errs = append(errs, fmt.Sprintf("%s: duplicate relay name %q", prefix, name))
120
- } else {
121
- relayIdx[name] = relay
122
- }
123
- }
124
-
125
- if len(relay.URLs) == 0 {
126
- errs = append(errs, fmt.Sprintf("%s: at least one url is required", prefix))
127
- }
128
- for j, url := range relay.URLs {
129
- if strings.TrimSpace(url) == "" {
130
- errs = append(errs, fmt.Sprintf("%s.urls[%d]: url cannot be empty", prefix, j))
131
- }
132
- }
133
- }
134
-
135
- if len(cfg.Services) == 0 {
136
- errs = append(errs, "at least one service must be defined")
137
- }
138
- for i, service := range cfg.Services {
139
- prefix := fmt.Sprintf("services[%d]", i)
140
- name := strings.TrimSpace(service.Name)
141
- if name == "" {
142
- errs = append(errs, fmt.Sprintf("%s: name is required", prefix))
143
- }
144
- target := strings.TrimSpace(service.Target)
145
- if target == "" {
146
- errs = append(errs, fmt.Sprintf("%s: target is required", prefix))
147
- }
148
- for j, proto := range service.Protocols {
149
- if strings.TrimSpace(proto) == "" {
150
- errs = append(errs, fmt.Sprintf("%s.protocols[%d]: protocol cannot be empty", prefix, j))
151
- }
152
- }
153
- if len(service.RelayPreference) == 0 {
154
- errs = append(errs, fmt.Sprintf("%s: relayPreference must list at least one relay name", prefix))
155
- }
156
- for j, relayName := range service.RelayPreference {
157
- relayName = strings.TrimSpace(relayName)
158
- if relayName == "" {
159
- errs = append(errs, fmt.Sprintf("%s.relayPreference[%d]: relay name cannot be empty", prefix, j))
160
- continue
161
- }
162
- if _, exists := relayIdx[relayName]; !exists {
163
- errs = append(errs, fmt.Sprintf("%s.relayPreference[%d]: relay %q is not defined", prefix, j, relayName))
164
- }
165
- }
166
- }
167
-
168
- if len(errs) > 0 {
169
- return fmt.Errorf("invalid config:\n - %s", strings.Join(errs, "\n - "))
170
- }
171
-
172
- return nil
173
-}
174
-
175
-func (cfg *TunnelConfig) applyDefaults() {
176
- for i := range cfg.Services {
177
- applyServiceDefaults(&cfg.Services[i])
178
- }
179
-}
180
-
181
-func applyServiceDefaults(svc *ServiceConfig) {
182
- if len(svc.Protocols) == 0 {
183
- svc.Protocols = append([]string(nil), defaultProtocols...)
184
- }
185
-}
cmd/portal-tunnel/config.yaml.example
deleted
-25
@@ -1,25 +0,0 @@
1
-relays:
2
- - name: thumbgo
3
- urls:
4
- - wss://portal.thumbgo.kr/relay
5
- - name: gosuda
6
- urls:
7
- - wss://portal.gosuda.org/relay
8
-
9
-services:
10
- - name: test-thumbgo
11
- relayPreference:
12
- - thumbgo
13
- - gosuda
14
- target: localhost:1013
15
- protocols:
16
- - http/1.1
17
- - h2
18
- - name: test-thumbgo2
19
- relayPreference:
20
- - thumbgo
21
- - gosuda
22
- target: localhost:1014
23
- protocols:
24
- - http/1.1
25
- - h2
cmd/portal-tunnel/main.go
deleted
-283
@@ -1,283 +0,0 @@
1
-package main
2
-
3
-import (
4
- "context"
5
- "flag"
6
- "fmt"
7
- "io"
8
- "net"
9
- "os"
10
- "os/signal"
11
- "strings"
12
- "sync"
13
- "syscall"
14
-
15
- "github.com/rs/zerolog/log"
16
- "gosuda.org/portal/sdk"
17
- "gosuda.org/portal/utils"
18
-)
19
-
20
-var (
21
- flagConfigPath string
22
- flagRelayURLs string
23
- flagHost string
24
- flagPort string
25
- flagName string
26
-)
27
-
28
-func main() {
29
- if len(os.Args) < 2 {
30
- printTunnelUsage()
31
- os.Exit(2)
32
- }
33
-
34
- switch os.Args[1] {
35
- case "expose":
36
- fs := flag.NewFlagSet("expose", flag.ExitOnError)
37
- fs.StringVar(&flagConfigPath, "config", "", "Path to portal-tunnel config file")
38
- fs.StringVar(&flagRelayURLs, "relay", "ws://localhost:4017/relay", "Portal relay server URLs when config is not provided (comma-separated)")
39
- fs.StringVar(&flagHost, "host", "localhost", "Local host to proxy to when config is not provided")
40
- fs.StringVar(&flagPort, "port", "4018", "Local port to proxy to when config is not provided")
41
- fs.StringVar(&flagName, "name", "", "Service name when config is not provided (auto-generated if empty)")
42
- _ = fs.Parse(os.Args[2:])
43
-
44
- if err := runExpose(); err != nil {
45
- log.Fatal().Err(err).Msg("Failed to expose")
46
- }
47
- case "-h", "--help", "help":
48
- printTunnelUsage()
49
- default:
50
- fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1])
51
- printTunnelUsage()
52
- os.Exit(2)
53
- }
54
-}
55
-
56
-func printTunnelUsage() {
57
- fmt.Println("portal-tunnel — Expose local services through Portal relay")
58
- fmt.Println()
59
- fmt.Println("Usage:")
60
- fmt.Println(" portal-tunnel expose --config <file>")
61
- fmt.Println(" portal-tunnel expose [--relay URL1,URL2] [--host HOST] [--port PORT] [--name NAME]")
62
-}
63
-
64
-func runExpose() error {
65
- if flagConfigPath == "" {
66
- return runExposeWithFlags()
67
- }
68
- return runExposeWithConfig()
69
-}
70
-
71
-func runExposeWithConfig() error {
72
- cfg, err := LoadConfig(flagConfigPath)
73
- if err != nil {
74
- return fmt.Errorf("load config: %w", err)
75
- }
76
- relayDir := NewRelayDirectory(cfg.Relays)
77
- ctx, cancel := context.WithCancel(context.Background())
78
- defer cancel()
79
-
80
- // Graceful shutdown
81
- sigCh := make(chan os.Signal, 1)
82
- signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
83
- go func() {
84
- <-sigCh
85
- log.Info().Msg("")
86
- log.Info().Msg("Shutting down tunnels...")
87
- cancel()
88
- }()
89
-
90
- errCh := make(chan error, len(cfg.Services))
91
- var wg sync.WaitGroup
92
-
93
- for i := range cfg.Services {
94
- service := &cfg.Services[i]
95
- wg.Add(1)
96
- go func() {
97
- defer wg.Done()
98
- if err := runServiceTunnel(ctx, relayDir, service, fmt.Sprintf("config=%s", flagConfigPath)); err != nil {
99
- errCh <- err
100
- }
101
- }()
102
- }
103
-
104
- doneCh := make(chan struct{})
105
- go func() {
106
- wg.Wait()
107
- close(doneCh)
108
- }()
109
-
110
- select {
111
- case err := <-errCh:
112
- cancel()
113
- <-doneCh
114
- return err
115
- case <-ctx.Done():
116
- <-doneCh
117
- log.Info().Msg("Tunnel stopped")
118
- return nil
119
- case <-doneCh:
120
- return nil
121
- }
122
-}
123
-
124
-func runExposeWithFlags() error {
125
- relayURLs := utils.ParseURLs(flagRelayURLs)
126
- if len(relayURLs) == 0 {
127
- return fmt.Errorf("--relay must include at least one non-empty URL when --config is not provided")
128
- }
129
-
130
- target := net.JoinHostPort(flagHost, flagPort)
131
- service := &ServiceConfig{
132
- Name: strings.TrimSpace(flagName),
133
- Target: target,
134
- RelayPreference: []string{"flags"},
135
- }
136
- applyServiceDefaults(service)
137
-
138
- relayDir := NewRelayDirectory([]RelayConfig{
139
- {
140
- Name: "flags",
141
- URLs: relayURLs,
142
- },
143
- })
144
-
145
- ctx, cancel := context.WithCancel(context.Background())
146
- defer cancel()
147
-
148
- sigCh := make(chan os.Signal, 1)
149
- signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
150
- go func() {
151
- <-sigCh
152
- log.Info().Msg("")
153
- log.Info().Msg("Shutting down tunnel...")
154
- cancel()
155
- }()
156
-
157
- if err := runServiceTunnel(ctx, relayDir, service, "flags"); err != nil {
158
- return err
159
- }
160
-
161
- log.Info().Msg("Tunnel stopped")
162
- return nil
163
-}
164
-
165
-func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn) error {
166
- defer relayConn.Close()
167
-
168
- localConn, err := net.Dial("tcp", localAddr)
169
- if err != nil {
170
- return fmt.Errorf("failed to connect to local service %s: %w", localAddr, err)
171
- }
172
- defer localConn.Close()
173
-
174
- errCh := make(chan error, 2)
175
- stopCh := make(chan struct{})
176
- go func() {
177
- select {
178
- case <-ctx.Done():
179
- relayConn.Close()
180
- localConn.Close()
181
- case <-stopCh:
182
- }
183
- }()
184
-
185
- go func() {
186
- _, err := io.Copy(localConn, relayConn)
187
- errCh <- err
188
- }()
189
-
190
- go func() {
191
- _, err := io.Copy(relayConn, localConn)
192
- errCh <- err
193
- }()
194
-
195
- err = <-errCh
196
- close(stopCh)
197
- relayConn.Close()
198
- <-errCh
199
-
200
- return err
201
-}
202
-
203
-func runServiceTunnel(ctx context.Context, relayDir *RelayDirectory, service *ServiceConfig, origin string) error {
204
- localAddr := service.Target
205
- serviceName := strings.TrimSpace(service.Name)
206
- bootstrapServers, err := relayDir.BootstrapServers(service.RelayPreference)
207
- if err != nil {
208
- return fmt.Errorf("service %s: resolve relay servers: %w", serviceName, err)
209
- }
210
-
211
- cred := sdk.NewCredential()
212
- leaseID := cred.ID()
213
- if serviceName == "" {
214
- serviceName = fmt.Sprintf("tunnel-%s", leaseID[:8])
215
- log.Info().Str("service", serviceName).Msg("No service name provided; generated automatically")
216
- }
217
- log.Info().Str("service", serviceName).Msgf("Local service is reachable at %s", localAddr)
218
- log.Info().Str("service", serviceName).Msgf("Starting Portal Tunnel (%s)...", origin)
219
- log.Info().Str("service", serviceName).Msgf(" Local: %s", localAddr)
220
- log.Info().Str("service", serviceName).Msgf(" Relays: %s", strings.Join(bootstrapServers, ", "))
221
- log.Info().Str("service", serviceName).Msgf(" Lease ID: %s", leaseID)
222
-
223
- client, err := sdk.NewClient(func(c *sdk.ClientConfig) {
224
- c.BootstrapServers = bootstrapServers
225
- })
226
- if err != nil {
227
- return fmt.Errorf("service %s: failed to connect to relay: %w", serviceName, err)
228
- }
229
- defer client.Close()
230
-
231
- listener, err := client.Listen(cred, serviceName, service.Protocols)
232
- if err != nil {
233
- return fmt.Errorf("service %s: failed to register service: %w", serviceName, err)
234
- }
235
- defer listener.Close()
236
-
237
- go func() {
238
- <-ctx.Done()
239
- _ = listener.Close()
240
- }()
241
-
242
- log.Info().Str("service", serviceName).Msg("")
243
- log.Info().Str("service", serviceName).Msg("Access via:")
244
- log.Info().Str("service", serviceName).Msgf("- Name: /peer/%s", serviceName)
245
- log.Info().Str("service", serviceName).Msgf("- Lease ID: /peer/%s", leaseID)
246
- log.Info().Str("service", serviceName).Msgf("- Example: http://%s/peer/%s", bootstrapServers[0], serviceName)
247
-
248
- log.Info().Str("service", serviceName).Msg("")
249
-
250
- connCount := 0
251
- var connWG sync.WaitGroup
252
- defer connWG.Wait()
253
- for {
254
- select {
255
- case <-ctx.Done():
256
- return nil
257
- default:
258
- }
259
-
260
- relayConn, err := listener.Accept()
261
- if err != nil {
262
- select {
263
- case <-ctx.Done():
264
- return nil
265
- default:
266
- log.Error().Str("service", serviceName).Err(err).Msg("Failed to accept connection")
267
- continue
268
- }
269
- }
270
-
271
- connCount++
272
- log.Info().Str("service", serviceName).Msgf("→ [#%d] New connection from %s", connCount, relayConn.RemoteAddr())
273
-
274
- connWG.Add(1)
275
- go func(relayConn net.Conn) {
276
- defer connWG.Done()
277
- if err := proxyConnection(ctx, localAddr, relayConn); err != nil {
278
- log.Error().Str("service", serviceName).Err(err).Msg("Proxy error")
279
- }
280
- log.Info().Str("service", serviceName).Msg("Connection closed")
281
- }(relayConn)
282
- }
283
-}
docs/glossary.md
-5
@@ -26,11 +26,6 @@ A Client is the end-user entity that attempts to access services published by an
26
* A Client requests a connection to the Portal by specifying a Lease ID or name. The Portal matches this request to the App owning that Lease, then performs an RDSEC handshake to establish a SecureConnection and exchange data.
27
* A Client must also possess a credential, which grants connection-request permission and enables mutual authentication with the App (E2EE).
28
29
-
30
-### Tunnel (portal-tunnel CLI)
31
-
32
-Tunnel is a deployment tool used to publish services to the Portal without modifying App code.
33
-
29
### Lease (Advertising Slot)
30
31
A Lease is the advertisement unit an App publishes to the Portal.
go.mod
+1
-1
@@ -12,7 +12,6 @@ require (
12
golang.org/x/crypto v0.44.0
13
golang.org/x/net v0.47.0
14
google.golang.org/protobuf v1.36.10
15
- gopkg.in/yaml.v3 v3.0.1
15
)
16
17
require (
@@ -22,4 +21,5 @@ require (
21
github.com/pmezard/go-difflib v1.0.0 // indirect
22
golang.org/x/sys v0.38.0 // indirect
23
golang.org/x/text v0.31.0 // indirect
24
+ gopkg.in/yaml.v3 v3.0.1 // indirect
25
)