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 +}