portal tunnel
fatheradvisor committed
Nov 3, 2025 at 21:12 UTC
a1a8e2d3b03e597e1ca52e26ea075a57d5fe25bb
3 files changed
+558
Makefile
+8
@@ -34,6 +34,14 @@ build-server:
34
@echo "[server] building Go portal..."
35
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o bin/relay-server ./cmd/relay-server
36
37
+# Build Portal Tunnel CLI (cloudflared-style tunnel)
38
+build-tunnel:
39
+ @echo "[tunnel] building Portal Tunnel CLI..."
40
+ CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o bin/portal-tunnel ./cmd/portal-tunnel
41
+
42
+# Build all binaries
43
+build-all: build-protoc build-wasm build-server build-tunnel
44
+
45
clean:
46
rm -rf bin
47
rm -rf cmd/relay-server/wasm
cmd/portal-tunnel/main.go
new
+235
@@ -0,0 +1,235 @@
1
+package main
2
+
3
+import (
4
+ "context"
5
+ "fmt"
6
+ "io"
7
+ "net"
8
+ "os"
9
+ "os/signal"
10
+ "syscall"
11
+
12
+ "github.com/rs/zerolog/log"
13
+ "github.com/spf13/cobra"
14
+ "gosuda.org/portal/sdk"
15
+)
16
+
17
+var (
18
+ flagRelayURL string
19
+ flagLocalPort int
20
+ flagName string
21
+ flagLocalHost string
22
+)
23
+
24
+var rootCmd = &cobra.Command{
25
+ Use: "portal-tunnel",
26
+ Short: "Expose local services through Portal relay (like cloudflared tunnel)",
27
+ Long: `Portal Tunnel exposes your local services to the internet through a secure Portal relay.
28
+
29
+Example:
30
+ portal-tunnel expose 8080 --name my-service
31
+ portal-tunnel expose 3000 --name api --relay ws://my-relay.com/relay
32
+`,
33
+}
34
+
35
+var exposeCmd = &cobra.Command{
36
+ Use: "expose [local-port]",
37
+ Short: "Expose a local port through the relay",
38
+ Args: cobra.ExactArgs(1),
39
+ RunE: runExpose,
40
+}
41
+
42
+func init() {
43
+ exposeCmd.Flags().StringVar(&flagRelayURL, "relay", "ws://localhost:4017/relay", "Portal relay server URL")
44
+ exposeCmd.Flags().StringVar(&flagName, "name", "", "Service name (will be generated if not provided)")
45
+ exposeCmd.Flags().StringVar(&flagLocalHost, "local-host", "localhost", "Local host to proxy to")
46
+
47
+ rootCmd.AddCommand(exposeCmd)
48
+}
49
+
50
+func main() {
51
+ if err := rootCmd.Execute(); err != nil {
52
+ log.Fatal().Err(err).Msg("Failed to execute command")
53
+ }
54
+}
55
+
56
+func runExpose(cmd *cobra.Command, args []string) error {
57
+ // Parse local port
58
+ var port string = args[0]
59
+ localAddr := fmt.Sprintf("%s:%s", flagLocalHost, port)
60
+
61
+ // Test local service connectivity
62
+ log.Info().Msgf("Testing connection to local service at %s...", localAddr)
63
+ testConn, err := net.Dial("tcp", localAddr)
64
+ if err != nil {
65
+ return fmt.Errorf("cannot connect to local service at %s: %w", localAddr, err)
66
+ }
67
+ testConn.Close()
68
+ log.Info().Msgf("✓ Local service is reachable at %s", localAddr)
69
+
70
+ // Create credential
71
+ cred := sdk.NewCredential()
72
+ leaseID := cred.ID()
73
+
74
+ // Use provided name or generate from lease ID
75
+ if flagName == "" {
76
+ flagName = fmt.Sprintf("tunnel-%s", leaseID[:8])
77
+ }
78
+
79
+ log.Info().Msgf("Starting Portal Tunnel...")
80
+ log.Info().Msgf(" Local: %s", localAddr)
81
+ log.Info().Msgf(" Relay: %s", flagRelayURL)
82
+ log.Info().Msgf(" Name: %s", flagName)
83
+ log.Info().Msgf(" Lease ID: %s", leaseID)
84
+
85
+ // Create SDK client
86
+ client, err := sdk.NewClient(func(c *sdk.RDClientConfig) {
87
+ c.BootstrapServers = []string{flagRelayURL}
88
+ })
89
+ if err != nil {
90
+ return fmt.Errorf("failed to connect to relay: %w", err)
91
+ }
92
+ defer client.Close()
93
+
94
+ // Register listener
95
+ listener, err := client.Listen(cred, flagName, []string{"http/1.1", "h2"})
96
+ if err != nil {
97
+ return fmt.Errorf("failed to register service: %w", err)
98
+ }
99
+ defer listener.Close()
100
+
101
+ log.Info().Msg("")
102
+ log.Info().Msg("┌─────────────────────────────────────────────────────────────┐")
103
+ log.Info().Msgf("│ 🌐 Service is now publicly accessible! │")
104
+ log.Info().Msg("├─────────────────────────────────────────────────────────────┤")
105
+ log.Info().Msgf("│ Access via: │")
106
+ log.Info().Msgf("│ - Name: /peer/%s │", padRight(flagName, 30))
107
+ log.Info().Msgf("│ - Lease ID: /peer/%s │", padRight(leaseID[:26], 30))
108
+ log.Info().Msg("│ │")
109
+ log.Info().Msgf("│ Example: │")
110
+ relayHost := extractHost(flagRelayURL)
111
+ log.Info().Msgf("│ http://%s/peer/%s │", padRight(relayHost, 20), padRight(flagName, 20))
112
+ log.Info().Msg("└─────────────────────────────────────────────────────────────┘")
113
+ log.Info().Msg("")
114
+ log.Info().Msg("Press Ctrl+C to stop...")
115
+ log.Info().Msg("")
116
+
117
+ // Handle connections
118
+ ctx, cancel := context.WithCancel(context.Background())
119
+ defer cancel()
120
+
121
+ // Graceful shutdown
122
+ sigCh := make(chan os.Signal, 1)
123
+ signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
124
+
125
+ go func() {
126
+ <-sigCh
127
+ log.Info().Msg("")
128
+ log.Info().Msg("Shutting down tunnel...")
129
+ cancel()
130
+ }()
131
+
132
+ // Accept connections and proxy them
133
+ connCount := 0
134
+ for {
135
+ select {
136
+ case <-ctx.Done():
137
+ log.Info().Msg("Tunnel stopped")
138
+ return nil
139
+ default:
140
+ }
141
+
142
+ relayConn, err := listener.Accept()
143
+ if err != nil {
144
+ // Check if context was cancelled
145
+ select {
146
+ case <-ctx.Done():
147
+ return nil
148
+ default:
149
+ log.Error().Err(err).Msg("Failed to accept connection")
150
+ continue
151
+ }
152
+ }
153
+
154
+ connCount++
155
+ currentConnCount := connCount
156
+ log.Info().Msgf("→ [#%d] New connection from %s", currentConnCount, relayConn.RemoteAddr())
157
+
158
+ // Handle connection in goroutine
159
+ go func(relayConn net.Conn, connNum int) {
160
+ if err := proxyConnection(relayConn, localAddr, connNum); err != nil {
161
+ log.Error().Err(err).Int("conn", connNum).Msg("Proxy error")
162
+ }
163
+ log.Info().Msgf("← [#%d] Connection closed", connNum)
164
+ }(relayConn, currentConnCount)
165
+ }
166
+}
167
+
168
+func proxyConnection(relayConn net.Conn, localAddr string, connNum int) error {
169
+ defer relayConn.Close()
170
+
171
+ // Connect to local service
172
+ localConn, err := net.Dial("tcp", localAddr)
173
+ if err != nil {
174
+ return fmt.Errorf("failed to connect to local service: %w", err)
175
+ }
176
+ defer localConn.Close()
177
+
178
+ // Bidirectional copy
179
+ errCh := make(chan error, 2)
180
+
181
+ // Relay -> Local
182
+ go func() {
183
+ _, err := io.Copy(localConn, relayConn)
184
+ errCh <- err
185
+ }()
186
+
187
+ // Local -> Relay
188
+ go func() {
189
+ _, err := io.Copy(relayConn, localConn)
190
+ errCh <- err
191
+ }()
192
+
193
+ // Wait for one direction to finish
194
+ err = <-errCh
195
+
196
+ // Close both connections to stop the other goroutine
197
+ relayConn.Close()
198
+ localConn.Close()
199
+
200
+ // Wait for other goroutine
201
+ <-errCh
202
+
203
+ return err
204
+}
205
+
206
+func padRight(s string, length int) string {
207
+ if len(s) >= length {
208
+ return s
209
+ }
210
+ return s + string(make([]byte, length-len(s)))
211
+}
212
+
213
+func extractHost(wsURL string) string {
214
+ // Simple extraction: ws://host:port/path -> host:port
215
+ // Remove ws:// or wss://
216
+ host := wsURL
217
+ if len(host) > 5 && host[:5] == "ws://" {
218
+ host = host[5:]
219
+ } else if len(host) > 6 && host[:6] == "wss://" {
220
+ host = host[6:]
221
+ }
222
+
223
+ // Remove path
224
+ if idx := len(host); idx > 0 {
225
+ for i, c := range host {
226
+ if c == '/' {
227
+ idx = i
228
+ break
229
+ }
230
+ }
231
+ host = host[:idx]
232
+ }
233
+
234
+ return host
235
+}
cmd/portal-tunnel/tunnel_test.go
new
+315
@@ -0,0 +1,315 @@
1
+package main
2
+
3
+import (
4
+ "context"
5
+ "fmt"
6
+ "io"
7
+ "net"
8
+ "net/http"
9
+ "strings"
10
+ "testing"
11
+ "time"
12
+
13
+ "github.com/stretchr/testify/assert"
14
+ "github.com/stretchr/testify/require"
15
+ "gosuda.org/portal/sdk"
16
+)
17
+
18
+// TestTunnelEndToEnd tests the complete tunnel functionality
19
+func TestTunnelEndToEnd(t *testing.T) {
20
+ // Start a local HTTP server
21
+ localPort := findFreePort(t)
22
+ localAddr := fmt.Sprintf("localhost:%d", localPort)
23
+
24
+ localServer := &http.Server{
25
+ Addr: localAddr,
26
+ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27
+ w.WriteHeader(http.StatusOK)
28
+ w.Write([]byte("Hello from local server!"))
29
+ }),
30
+ }
31
+
32
+ go func() {
33
+ localServer.ListenAndServe()
34
+ }()
35
+ defer localServer.Close()
36
+
37
+ // Wait for local server to start
38
+ time.Sleep(100 * time.Millisecond)
39
+
40
+ // Verify local server is reachable
41
+ resp, err := http.Get(fmt.Sprintf("http://%s", localAddr))
42
+ require.NoError(t, err)
43
+ require.Equal(t, http.StatusOK, resp.StatusCode)
44
+ resp.Body.Close()
45
+
46
+ t.Logf("✓ Local server started on %s", localAddr)
47
+
48
+ // Create relay server (mock or use actual relay server)
49
+ // For this test, we'll assume a relay server is running on localhost:4017
50
+ relayURL := "ws://localhost:4017/relay"
51
+
52
+ // Create credential for tunnel
53
+ cred := sdk.NewCredential()
54
+ leaseID := cred.ID()
55
+ serviceName := fmt.Sprintf("test-tunnel-%d", time.Now().Unix())
56
+
57
+ t.Logf("Creating tunnel with:")
58
+ t.Logf(" Service name: %s", serviceName)
59
+ t.Logf(" Lease ID: %s", leaseID)
60
+
61
+ // Create SDK client
62
+ client, err := sdk.NewClient(func(c *sdk.RDClientConfig) {
63
+ c.BootstrapServers = []string{relayURL}
64
+ })
65
+ if err != nil {
66
+ t.Skipf("Relay server not available: %v", err)
67
+ return
68
+ }
69
+ defer client.Close()
70
+
71
+ // Register listener (server side of tunnel)
72
+ listener, err := client.Listen(cred, serviceName, []string{"http/1.1"})
73
+ require.NoError(t, err)
74
+ defer listener.Close()
75
+
76
+ t.Logf("✓ Tunnel registered successfully")
77
+
78
+ // Start proxy goroutine (simulate portal-tunnel behavior)
79
+ ctx, cancel := context.WithCancel(context.Background())
80
+ defer cancel()
81
+
82
+ proxyErrors := make(chan error, 10)
83
+ go func() {
84
+ for {
85
+ select {
86
+ case <-ctx.Done():
87
+ return
88
+ default:
89
+ }
90
+
91
+ relayConn, err := listener.Accept()
92
+ if err != nil {
93
+ select {
94
+ case <-ctx.Done():
95
+ return
96
+ default:
97
+ proxyErrors <- fmt.Errorf("accept error: %w", err)
98
+ continue
99
+ }
100
+ }
101
+
102
+ // Proxy connection to local server
103
+ go func(relayConn net.Conn) {
104
+ defer relayConn.Close()
105
+
106
+ localConn, err := net.Dial("tcp", localAddr)
107
+ if err != nil {
108
+ proxyErrors <- fmt.Errorf("local dial error: %w", err)
109
+ return
110
+ }
111
+ defer localConn.Close()
112
+
113
+ // Bidirectional copy
114
+ errCh := make(chan error, 2)
115
+ go func() {
116
+ _, err := io.Copy(localConn, relayConn)
117
+ errCh <- err
118
+ }()
119
+ go func() {
120
+ _, err := io.Copy(relayConn, localConn)
121
+ errCh <- err
122
+ }()
123
+
124
+ <-errCh
125
+ localConn.Close()
126
+ relayConn.Close()
127
+ <-errCh
128
+ }(relayConn)
129
+ }
130
+ }()
131
+
132
+ // Wait for tunnel to be ready
133
+ time.Sleep(500 * time.Millisecond)
134
+
135
+ t.Logf("✓ Tunnel proxy started")
136
+
137
+ // Create a client that will connect through the tunnel
138
+ clientCred := sdk.NewCredential()
139
+ clientSDK, err := sdk.NewClient(func(c *sdk.RDClientConfig) {
140
+ c.BootstrapServers = []string{relayURL}
141
+ })
142
+ require.NoError(t, err)
143
+ defer clientSDK.Close()
144
+
145
+ // Connect through the tunnel
146
+ tunnelConn, err := clientSDK.Dial(clientCred, leaseID, "http/1.1")
147
+ require.NoError(t, err)
148
+ defer tunnelConn.Close()
149
+
150
+ t.Logf("✓ Connected through tunnel")
151
+
152
+ // Send HTTP request through tunnel
153
+ req := "GET / HTTP/1.1\r\nHost: test\r\nConnection: close\r\n\r\n"
154
+ _, err = tunnelConn.Write([]byte(req))
155
+ require.NoError(t, err)
156
+
157
+ // Read response
158
+ response := make([]byte, 4096)
159
+ n, err := tunnelConn.Read(response)
160
+ require.NoError(t, err)
161
+
162
+ responseStr := string(response[:n])
163
+ t.Logf("Received response (%d bytes):\n%s", n, responseStr)
164
+
165
+ // Verify response
166
+ assert.Contains(t, responseStr, "HTTP/1.1 200 OK")
167
+ assert.Contains(t, responseStr, "Hello from local server!")
168
+
169
+ t.Logf("✓ Tunnel test completed successfully!")
170
+
171
+ // Check for proxy errors
172
+ select {
173
+ case err := <-proxyErrors:
174
+ t.Logf("Proxy error (non-fatal): %v", err)
175
+ default:
176
+ // No errors
177
+ }
178
+}
179
+
180
+// TestProxyConnection tests the proxy connection logic
181
+func TestProxyConnection(t *testing.T) {
182
+ // Create a mock local server
183
+ localPort := findFreePort(t)
184
+ localAddr := fmt.Sprintf("localhost:%d", localPort)
185
+
186
+ localListener, err := net.Listen("tcp", localAddr)
187
+ require.NoError(t, err)
188
+ defer localListener.Close()
189
+
190
+ // Local server that echoes back
191
+ go func() {
192
+ for {
193
+ conn, err := localListener.Accept()
194
+ if err != nil {
195
+ return
196
+ }
197
+ go func(c net.Conn) {
198
+ defer c.Close()
199
+ io.Copy(c, c) // Echo back
200
+ }(conn)
201
+ }
202
+ }()
203
+
204
+ // Create mock relay connection (using pipes)
205
+ relayConn, clientConn := net.Pipe()
206
+ defer relayConn.Close()
207
+ defer clientConn.Close()
208
+
209
+ // Start proxy
210
+ go func() {
211
+ err := proxyConnection(relayConn, localAddr, 1)
212
+ if err != nil && !strings.Contains(err.Error(), "closed") {
213
+ t.Logf("Proxy error: %v", err)
214
+ }
215
+ }()
216
+
217
+ // Send data through client side
218
+ testData := "Hello, tunnel!"
219
+ _, err = clientConn.Write([]byte(testData))
220
+ require.NoError(t, err)
221
+
222
+ // Read echoed data
223
+ buf := make([]byte, len(testData))
224
+ n, err := clientConn.Read(buf)
225
+ require.NoError(t, err)
226
+ assert.Equal(t, testData, string(buf[:n]))
227
+
228
+ t.Logf("✓ Proxy connection test passed")
229
+}
230
+
231
+// TestLocalServiceConnectivity tests the local service connectivity check
232
+func TestLocalServiceConnectivity(t *testing.T) {
233
+ // Start a local server
234
+ localPort := findFreePort(t)
235
+ localAddr := fmt.Sprintf("localhost:%d", localPort)
236
+
237
+ listener, err := net.Listen("tcp", localAddr)
238
+ require.NoError(t, err)
239
+ defer listener.Close()
240
+
241
+ go func() {
242
+ for {
243
+ conn, err := listener.Accept()
244
+ if err != nil {
245
+ return
246
+ }
247
+ conn.Close()
248
+ }
249
+ }()
250
+
251
+ // Test connectivity (simulating the check in runExpose)
252
+ testConn, err := net.Dial("tcp", localAddr)
253
+ require.NoError(t, err)
254
+ testConn.Close()
255
+
256
+ t.Logf("✓ Local service connectivity test passed")
257
+}
258
+
259
+// TestLocalServiceNotReachable tests error handling when local service is not available
260
+func TestLocalServiceNotReachable(t *testing.T) {
261
+ // Try to connect to a port that's not listening
262
+ localAddr := "localhost:19999"
263
+
264
+ _, err := net.Dial("tcp", localAddr)
265
+ assert.Error(t, err)
266
+ assert.Contains(t, err.Error(), "connection refused")
267
+
268
+ t.Logf("✓ Error handling test passed")
269
+}
270
+
271
+// TestPadRight tests the padding utility function
272
+func TestPadRight(t *testing.T) {
273
+ tests := []struct {
274
+ input string
275
+ length int
276
+ expected int
277
+ }{
278
+ {"test", 10, 10},
279
+ {"hello", 5, 5},
280
+ {"toolong", 3, 7}, // Should not truncate
281
+ }
282
+
283
+ for _, tt := range tests {
284
+ result := padRight(tt.input, tt.length)
285
+ assert.Equal(t, tt.expected, len(result))
286
+ assert.True(t, strings.HasPrefix(result, tt.input))
287
+ }
288
+}
289
+
290
+// TestExtractHost tests the host extraction from WebSocket URL
291
+func TestExtractHost(t *testing.T) {
292
+ tests := []struct {
293
+ input string
294
+ expected string
295
+ }{
296
+ {"ws://localhost:4017/relay", "localhost:4017"},
297
+ {"wss://example.com:443/path", "example.com:443"},
298
+ {"ws://192.168.1.1:8080/test", "192.168.1.1:8080"},
299
+ {"ws://example.com/", "example.com"},
300
+ }
301
+
302
+ for _, tt := range tests {
303
+ result := extractHost(tt.input)
304
+ assert.Equal(t, tt.expected, result, "Failed for input: %s", tt.input)
305
+ }
306
+}
307
+
308
+// Helper function to find a free port for testing
309
+func findFreePort(t *testing.T) int {
310
+ listener, err := net.Listen("tcp", "localhost:0")
311
+ require.NoError(t, err)
312
+ port := listener.Addr().(*net.TCPAddr).Port
313
+ listener.Close()
314
+ return port
315
+}