fix test, readme, cli

Kim committed Mar 4, 2026 at 11:14 UTC a2b72c898ff50678e35ddafdf3e55d5d428cfa68
6 files changed +46 -78
README.md
+3 -20
@@ -4,7 +4,7 @@
4 <img src="/portal.jpg" alt="Portal logo" width="540" />
5 </p>
6
7 -Expose your local application on the public internet without opening inbound ports or managing NAT and DNS.
7 +Expose your local application to the public internet — no ports, no NAT, no DNS setup.
8
9 Portal is a self-hosted relay network. You can run your own relay or connect to one that is already running.
10
@@ -78,7 +78,7 @@ For deployment to a public domain, see [docs/deployment.md](docs/deployment.md).
78 3. Click `Add your server` button.
79 4. Use the generated command to connect your local service.
80
81 -### Use the Go SDK
81 +### Use the Go SDK (Advanced)
82
83 See [portal-toys](https://github.com/gosuda/portal-toys) for more examples.
84
@@ -89,24 +89,7 @@ For architecture decisions, see [docs/adr/README.md](docs/adr/README.md).
89
90 ## Contributing
91
92 -Contributions are welcome.
93 -
94 -### Verification (CI-Aligned)
95 -
96 -Run the same checks enforced in CI (`.github/workflows/ci.yml`) in this order:
97 -
98 -```bash
99 -make vet
100 -make lint
101 -make test
102 -make vuln
103 -```
104 -
105 -For local pre-PR cleanup (not enforced in CI), run:
106 -
107 -```bash
108 -make tidy
109 -```
92 +We welcome contributions from the community!
93
94 ### Steps to Contribute
95 1. Fork the repository
cmd/demo-app/main.go
+21 -6
@@ -4,7 +4,6 @@ import (
4 "embed"
5 "encoding/base64"
6 "encoding/json"
7 - "errors"
7 "flag"
8 "fmt"
9 "io/fs"
@@ -16,6 +15,7 @@ import (
15 "time"
16
17 "github.com/rs/zerolog/log"
18 + "golang.org/x/net/websocket"
19
20 "gosuda.org/portal/sdk"
21 "gosuda.org/portal/types"
@@ -38,7 +38,7 @@ var (
38 )
39
40 func main() {
41 - flag.StringVar(&flagServerURL, "server-url", "https://localhost:4017", "relay API URL (https)")
41 + flag.StringVar(&flagServerURL, "server-url", "http://localhost:4017", "relay API URL (http/https)")
42 flag.IntVar(&flagPort, "port", 8092, "local demo HTTP port")
43 flag.StringVar(&flagName, "name", "demo-app", "backend display name")
44 flag.StringVar(&flagDesc, "description", "Portal demo connectivity app", "lease description")
@@ -53,10 +53,6 @@ func main() {
53 }
54
55 func runDemo() error {
56 - if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(flagServerURL)), "https://") {
57 - return errors.New("server-url must use https://")
58 - }
59 -
56 // 1) Create SDK client and connect to relay(s)
57 opts := []sdk.ClientOption{sdk.WithBootstrapServers([]string{flagServerURL})}
58 sdkClient, err := sdk.NewClient(opts...)
@@ -104,6 +100,25 @@ func runDemo() error {
100 }
101 })
102
103 + // WebSocket echo endpoint
104 + mux.Handle("/ws", websocket.Handler(func(conn *websocket.Conn) {
105 + defer conn.Close()
106 + for {
107 + var msg string
108 + if err := websocket.Message.Receive(conn, &msg); err != nil {
109 + if err.Error() != "EOF" {
110 + log.Error().Err(err).Msg("websocket read error")
111 + }
112 + break
113 + }
114 + log.Debug().Str("msg", msg).Msg("websocket received")
115 + if err := websocket.Message.Send(conn, "echo: "+msg); err != nil {
116 + log.Error().Err(err).Msg("websocket write error")
117 + break
118 + }
119 + }
120 + }))
121 +
122 // Test endpoint for multiple Set-Cookie headers
123 // Note: HttpOnly cookies cannot be set via Service Worker (browser security limitation)
124 mux.HandleFunc("/api/test-cookies", func(w http.ResponseWriter, _ *http.Request) {
cmd/portal-tunnel/main.go
+20 -1
@@ -222,7 +222,7 @@ func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn)
222 Str("addr", targetAddr).
223 Err(err).
224 Msg("Local service unavailable")
225 - return fmt.Errorf("local service unavailable: %w", err)
225 + return writeEmptyHTTPResponse(relayConn)
226 }
227 defer localConn.Close()
228
@@ -279,3 +279,22 @@ func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn)
279 close(stopCh)
280 return firstErr
281 }
282 +
283 +func writeEmptyHTTPResponse(conn net.Conn) error {
284 + htmlBody := `<!DOCTYPE html>
285 +<html>
286 +<head><title>Service Unavailable</title></head>
287 +<body style="font-family:sans-serif;text-align:center;padding:50px;">
288 +<h1>🔌 Service Unavailable</h1>
289 +<p>The local service is not currently running.</p>
290 +<p>Please start your local application and refresh this page.</p>
291 +</body>
292 +</html>`
293 + response := fmt.Sprintf("HTTP/1.1 503 Service Unavailable\r\n"+
294 + "Content-Type: text/html; charset=utf-8\r\n"+
295 + "Content-Length: %d\r\n"+
296 + "Connection: close\r\n"+
297 + "\r\n%s", len(htmlBody), htmlBody)
298 + _, err := conn.Write([]byte(response))
299 + return err
300 +}
cmd/portal-tunnel/main_test.go deleted
-49
@@ -1,49 +0,0 @@
1 -package main
2 -
3 -import "testing"
4 -
5 -func TestValidateRelayURLsForReverseConnect(t *testing.T) {
6 - tests := []struct {
7 - name string
8 - relayURLs []string
9 - wantErr bool
10 - }{
11 - {
12 - name: "single https relay",
13 - relayURLs: []string{"https://relay.example.com"},
14 - wantErr: false,
15 - },
16 - {
17 - name: "multiple https relays",
18 - relayURLs: []string{"https://relay-a.example.com", "https://relay-b.example.com"},
19 - wantErr: false,
20 - },
21 - {
22 - name: "reject http relay",
23 - relayURLs: []string{"http://relay.example.com"},
24 - wantErr: true,
25 - },
26 - {
27 - name: "reject websocket relay",
28 - relayURLs: []string{"wss://relay.example.com"},
29 - wantErr: true,
30 - },
31 - {
32 - name: "reject malformed relay URL",
33 - relayURLs: []string{"://not-a-valid-url"},
34 - wantErr: true,
35 - },
36 - }
37 -
38 - for _, tt := range tests {
39 - t.Run(tt.name, func(t *testing.T) {
40 - err := validateRelayURLsForReverseConnect(tt.relayURLs)
41 - if tt.wantErr && err == nil {
42 - t.Fatalf("validateRelayURLsForReverseConnect(%v) expected error, got nil", tt.relayURLs)
43 - }
44 - if !tt.wantErr && err != nil {
45 - t.Fatalf("validateRelayURLsForReverseConnect(%v) unexpected error: %v", tt.relayURLs, err)
46 - }
47 - })
48 - }
49 -}
cmd/relay-server/tunnel_test.go
+1 -1
@@ -54,7 +54,7 @@ func TestServeTunnelScriptIncludesPowerShellChecksumVerification(t *testing.T) {
54 func TestServeTunnelBinaryServesChecksumSidecarAndHeader(t *testing.T) {
55 originalAssetMap := tunnelBinaryAssetBySlug
56 tunnelBinaryAssetBySlug = map[string]string{
57 - "linux-amd64": "dist/app/portal.html",
57 + "linux-amd64": "dist/.gitkeep",
58 }
59 t.Cleanup(func() {
60 tunnelBinaryAssetBySlug = originalAssetMap
go.mod
+1 -1
@@ -6,6 +6,7 @@ require (
6 github.com/go-acme/lego/v4 v4.32.0
7 github.com/gosuda/keyless_tls v0.0.1-0.20260227054723-d699441f3834
8 github.com/rs/zerolog v1.34.0
9 + golang.org/x/net v0.51.0
10 )
11
12 require (
@@ -16,7 +17,6 @@ require (
17 github.com/miekg/dns v1.1.72 // indirect
18 golang.org/x/crypto v0.48.0 // indirect
19 golang.org/x/mod v0.33.0 // indirect
19 - golang.org/x/net v0.51.0 // indirect
20 golang.org/x/sync v0.19.0 // indirect
21 golang.org/x/sys v0.41.0 // indirect
22 golang.org/x/text v0.34.0 // indirect