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