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 )