refactor: move shared portal-sdk DTOs and API paths into types package
Kim committed
Mar 6, 2026 at 14:36 UTC
16170dcd59d6a548126ef0f5f6e2207999605bfc
19 files changed
+362
-288
AGENTS.md
+9
@@ -70,6 +70,15 @@ Source of truth for architecture decisions: `docs/adr/README.md` and linked ADRs
70
1. **All HTTP responses use the `APIEnvelope` wrapper:** `{ ok: bool, data?: any, error?: { code, message } }` (defined in `types/api.go`). Write responses through `writeAPIData()`, `writeAPIOK()`, or `writeAPIError()` helpers — never raw JSON.
71
- Why: cross-cutting contract across all endpoints; inconsistent envelopes break SDK and frontend parsing.
72
73
+## Shared Types Package
74
+
75
+1. **`types/` is reserved for shared wire/public types and protocol constants only.**
76
+ - Allowed: request/response DTOs, shared metadata structs, protocol marker/header/path constants.
77
+ - Not allowed: relay runtime state, broker/session state, server config, SDK lifecycle state, generic helpers.
78
+
79
+2. **Do not import `portal` from `cmd/*` or `sdk` just to reach shared DTOs or protocol constants.**
80
+ - Why: `portal` is relay runtime code; shared public shapes belong in `types/`.
81
+
82
## Operational Truths (CI-Aligned, Minimal)
83
84
1. **CI verification commands:** `make vet`, `make lint`, `make test`, `make vuln`. These are the enforced checks in `.github/workflows/ci.yml`. Note: `make tidy` is a local maintenance/pre-release step, not part of CI.
cmd/demo-app/main.go
+2
-1
@@ -21,6 +21,7 @@ import (
21
"golang.org/x/net/websocket"
22
23
"gosuda.org/portal/sdk"
24
+ "gosuda.org/portal/types"
25
)
26
27
//go:embed static
@@ -74,7 +75,7 @@ func runDemo() error {
75
76
listener, err := sdkClient.Listen(ctx, sdk.ListenRequest{
77
Name: flagName,
77
- Metadata: sdk.LeaseMetadata{
78
+ Metadata: types.LeaseMetadata{
79
Description: flagDesc,
80
Tags: splitCSV(flagTags),
81
Owner: flagOwner,
cmd/portal-tunnel/main.go
+3
-6
@@ -17,8 +17,8 @@ import (
17
"github.com/rs/zerolog"
18
"github.com/rs/zerolog/log"
19
20
- "gosuda.org/portal/portal"
20
"gosuda.org/portal/sdk"
21
+ "gosuda.org/portal/types"
22
)
23
24
var (
@@ -68,10 +68,7 @@ func runTunnel() error {
68
if len(relayURLs) == 0 {
69
return errors.New("no relay URLs provided")
70
}
71
- relayURL, err := portal.NormalizeRelayURL(relayURLs[0])
72
- if err != nil {
73
- return err
74
- }
71
+ relayURL := strings.TrimSpace(relayURLs[0])
72
73
logger.Info().
74
Str("local", flagHost).
@@ -89,7 +86,7 @@ func runTunnel() error {
86
87
listener, err := sdkClient.Listen(ctx, sdk.ListenRequest{
88
Name: flagName,
92
- Metadata: portal.LeaseMetadata{
89
+ Metadata: types.LeaseMetadata{
90
Description: flagDesc,
91
Tags: parseURLs(flagTags),
92
Owner: flagOwner,
cmd/relay-server/admin.go
+4
-3
@@ -9,6 +9,7 @@ import (
9
"strings"
10
11
"gosuda.org/portal/portal"
12
+ "gosuda.org/portal/types"
13
)
14
15
type Admin struct {
@@ -38,9 +39,9 @@ func (a *Admin) HandleAdminRequest(w http.ResponseWriter, r *http.Request) {
39
}
40
41
switch strings.TrimSuffix(r.URL.Path, "/") {
41
- case "/admin":
42
+ case types.PathAdmin:
43
a.handleAdminIndex(w)
43
- case "/admin/leases":
44
+ case types.PathAdminLeases:
45
w.Header().Set("Content-Type", "application/json")
46
w.WriteHeader(http.StatusOK)
47
_ = json.NewEncoder(w).Encode(convertLeaseEntriesToRows(a.server, true, a.frontend.portalURL))
@@ -52,7 +53,7 @@ func (a *Admin) HandleAdminRequest(w http.ResponseWriter, r *http.Request) {
53
func (a *Admin) handleAdminIndex(w http.ResponseWriter) {
54
rows := convertLeaseEntriesToRows(a.server, true, a.frontend.portalURL)
55
w.Header().Set("Content-Type", "text/html; charset=utf-8")
55
- _, _ = fmt.Fprintf(w, `<!doctype html><html><body><h1>Portal Admin</h1><p>%d leases</p><p><a href="/admin/leases">JSON lease list</a></p></body></html>`, len(rows))
56
+ _, _ = fmt.Fprintf(w, `<!doctype html><html><body><h1>Portal Admin</h1><p>%d leases</p><p><a href="%s">JSON lease list</a></p></body></html>`, len(rows), types.PathAdminLeases)
57
}
58
59
func (a *Admin) authorize(r *http.Request) bool {
cmd/relay-server/frontend.go
+95
@@ -3,12 +3,15 @@ package main
3
import (
4
"embed"
5
"encoding/json"
6
+ "fmt"
7
"html"
8
"io/fs"
9
+ "mime"
10
"net/http"
11
"path"
12
"strings"
13
"sync"
14
+ "time"
15
16
"gosuda.org/portal/portal"
17
)
@@ -163,3 +166,95 @@ func (f *Frontend) injectOGMetadata(htmlContent, title, description, imageURL st
166
)
167
return replacer.Replace(htmlContent)
168
}
169
+
170
+func getContentType(ext string) string {
171
+ ext = strings.TrimSpace(ext)
172
+ if ext == "" {
173
+ return ""
174
+ }
175
+ if contentType := mime.TypeByExtension(ext); contentType != "" {
176
+ return contentType
177
+ }
178
+
179
+ switch strings.ToLower(ext) {
180
+ case ".js", ".mjs":
181
+ return "text/javascript; charset=utf-8"
182
+ case ".css":
183
+ return "text/css; charset=utf-8"
184
+ case ".svg":
185
+ return "image/svg+xml"
186
+ case ".ico":
187
+ return "image/x-icon"
188
+ case ".jpg", ".jpeg":
189
+ return "image/jpeg"
190
+ case ".png":
191
+ return "image/png"
192
+ case ".json", ".webmanifest":
193
+ return "application/json; charset=utf-8"
194
+ default:
195
+ return ""
196
+ }
197
+}
198
+
199
+type leaseRow struct {
200
+ TTL string
201
+ Metadata string
202
+ Kind string
203
+ DNS string
204
+ Name string
205
+ Peer string
206
+ Link string
207
+ Hide bool
208
+ Connected bool
209
+}
210
+
211
+func convertLeaseEntriesToRows(serv *portal.Server, includeHidden bool, portalURL string) []leaseRow {
212
+ if serv == nil {
213
+ return nil
214
+ }
215
+ snapshots := serv.ListLeases()
216
+ rows := make([]leaseRow, 0, len(snapshots))
217
+ for _, snapshot := range snapshots {
218
+ if !includeHidden && snapshot.Metadata.Hide {
219
+ continue
220
+ }
221
+ metadataJSON, _ := json.Marshal(snapshot.Metadata)
222
+ host := ""
223
+ if len(snapshot.Hostnames) > 0 {
224
+ host = snapshot.Hostnames[0]
225
+ }
226
+ rows = append(rows, leaseRow{
227
+ TTL: formatDuration(time.Until(snapshot.ExpiresAt)),
228
+ Metadata: string(metadataJSON),
229
+ Kind: "https",
230
+ DNS: host,
231
+ Name: snapshot.Name,
232
+ Peer: snapshot.ID,
233
+ Link: leaseLink(host, portalURL),
234
+ Hide: snapshot.Metadata.Hide,
235
+ Connected: snapshot.Ready > 0,
236
+ })
237
+ }
238
+ return rows
239
+}
240
+
241
+func formatDuration(d time.Duration) string {
242
+ if d <= 0 {
243
+ return ""
244
+ }
245
+ if d > time.Hour {
246
+ return fmt.Sprintf("%.0fh", d.Hours())
247
+ }
248
+ if d > time.Minute {
249
+ return fmt.Sprintf("%.0fm", d.Minutes())
250
+ }
251
+ return fmt.Sprintf("%.0fs", d.Seconds())
252
+}
253
+
254
+func leaseLink(host, portalURL string) string {
255
+ host = strings.TrimSpace(host)
256
+ if host == "" {
257
+ return ""
258
+ }
259
+ return "https://" + host + "/"
260
+}
cmd/relay-server/http_helpers.go
deleted
-35
@@ -1,35 +0,0 @@
1
-package main
2
-
3
-import (
4
- "mime"
5
- "strings"
6
-)
7
-
8
-func getContentType(ext string) string {
9
- ext = strings.TrimSpace(ext)
10
- if ext == "" {
11
- return ""
12
- }
13
- if contentType := mime.TypeByExtension(ext); contentType != "" {
14
- return contentType
15
- }
16
-
17
- switch strings.ToLower(ext) {
18
- case ".js", ".mjs":
19
- return "text/javascript; charset=utf-8"
20
- case ".css":
21
- return "text/css; charset=utf-8"
22
- case ".svg":
23
- return "image/svg+xml"
24
- case ".ico":
25
- return "image/x-icon"
26
- case ".jpg", ".jpeg":
27
- return "image/jpeg"
28
- case ".png":
29
- return "image/png"
30
- case ".json", ".webmanifest":
31
- return "application/json; charset=utf-8"
32
- default:
33
- return ""
34
- }
35
-}
cmd/relay-server/lease_rows.go
deleted
-73
@@ -1,73 +0,0 @@
1
-package main
2
-
3
-import (
4
- "encoding/json"
5
- "fmt"
6
- "strings"
7
- "time"
8
-
9
- "gosuda.org/portal/portal"
10
-)
11
-
12
-type leaseRow struct {
13
- TTL string
14
- Metadata string
15
- Kind string
16
- DNS string
17
- Name string
18
- Peer string
19
- Link string
20
- Hide bool
21
- Connected bool
22
-}
23
-
24
-func convertLeaseEntriesToRows(serv *portal.Server, includeHidden bool, portalURL string) []leaseRow {
25
- if serv == nil {
26
- return nil
27
- }
28
- snapshots := serv.ListLeases()
29
- rows := make([]leaseRow, 0, len(snapshots))
30
- for _, snapshot := range snapshots {
31
- if !includeHidden && snapshot.Metadata.Hide {
32
- continue
33
- }
34
- metadataJSON, _ := json.Marshal(snapshot.Metadata)
35
- host := ""
36
- if len(snapshot.Hostnames) > 0 {
37
- host = snapshot.Hostnames[0]
38
- }
39
- rows = append(rows, leaseRow{
40
- TTL: formatDuration(time.Until(snapshot.ExpiresAt)),
41
- Metadata: string(metadataJSON),
42
- Kind: "https",
43
- DNS: host,
44
- Name: snapshot.Name,
45
- Peer: snapshot.ID,
46
- Link: leaseLink(host, portalURL),
47
- Hide: snapshot.Metadata.Hide,
48
- Connected: snapshot.Ready > 0,
49
- })
50
- }
51
- return rows
52
-}
53
-
54
-func formatDuration(d time.Duration) string {
55
- if d <= 0 {
56
- return ""
57
- }
58
- if d > time.Hour {
59
- return fmt.Sprintf("%.0fh", d.Hours())
60
- }
61
- if d > time.Minute {
62
- return fmt.Sprintf("%.0fm", d.Minutes())
63
- }
64
- return fmt.Sprintf("%.0fs", d.Seconds())
65
-}
66
-
67
-func leaseLink(host, portalURL string) string {
68
- host = strings.TrimSpace(host)
69
- if host == "" {
70
- return ""
71
- }
72
- return "https://" + host + "/"
73
-}
cmd/relay-server/registry.go
deleted
-26
@@ -1,26 +0,0 @@
1
-package main
2
-
3
-import "strings"
4
-
5
-func parseURLs(raw string) []string {
6
- if strings.TrimSpace(raw) == "" {
7
- return nil
8
- }
9
- parts := strings.Split(raw, ",")
10
- out := make([]string, 0, len(parts))
11
- for _, part := range parts {
12
- part = strings.TrimSpace(part)
13
- if part != "" {
14
- out = append(out, part)
15
- }
16
- }
17
- return out
18
-}
19
-
20
-func isRelayControlPlanePath(path string) bool {
21
- switch strings.TrimSpace(path) {
22
- case "/sdk/register", "/sdk/connect", "/sdk/renew", "/sdk/unregister", "/sdk/domain":
23
- return true
24
- }
25
- return strings.HasPrefix(path, "/sdk/")
26
-}
cmd/relay-server/serve.go
+51
-17
@@ -14,6 +14,17 @@ import (
14
"gosuda.org/portal/portal"
15
"gosuda.org/portal/portal/acme"
16
"gosuda.org/portal/portal/keyless"
17
+ "gosuda.org/portal/types"
18
+)
19
+
20
+const (
21
+ pathFaviconICO = "/favicon.ico"
22
+ pathFaviconSVG = "/favicon.svg"
23
+ pathFavicon96PNG = "/favicon-96x96.png"
24
+ pathAppleTouchIconPNG = "/apple-touch-icon.png"
25
+ pathWebAppManifest192PNG = "/web-app-manifest-192x192.png"
26
+ pathWebAppManifest512PNG = "/web-app-manifest-512x512.png"
27
+ pathPortalJPG = "/portal.jpg"
28
)
29
30
func runServer(cfg relayServerConfig) error {
@@ -97,25 +108,25 @@ func serveAPI(frontend *Frontend, admin *Admin, cfg relayServerConfig) func(http
108
switch {
109
case isRelayControlPlanePath(r.URL.Path):
110
base.ServeHTTP(w, r)
100
- case r.URL.Path == "/v1/sign":
111
+ case r.URL.Path == types.PathV1Sign:
112
base.ServeHTTP(w, r)
102
- case r.URL.Path == "/healthz":
113
+ case r.URL.Path == types.PathHealthz:
114
base.ServeHTTP(w, r)
115
case isFrontendRootAssetPath(r.URL.Path):
116
frontend.ServeAsset(w, r, strings.TrimPrefix(r.URL.Path, "/"), "")
106
- case strings.HasPrefix(strings.TrimSpace(r.URL.Path), "/assets/"):
117
+ case strings.HasPrefix(strings.TrimSpace(r.URL.Path), types.PathAssetsPrefix):
118
frontend.ServeAsset(w, r, strings.TrimPrefix(r.URL.Path, "/"), "")
108
- case r.URL.Path == "/" || r.URL.Path == "/app" || r.URL.Path == "/app/":
119
+ case r.URL.Path == types.PathRoot || r.URL.Path == types.PathApp || r.URL.Path == types.PathAppPrefix:
120
frontend.ServeAppStatic(w, r, "")
110
- case strings.HasPrefix(strings.TrimSpace(r.URL.Path), "/app/"):
111
- frontend.ServeAppStatic(w, r, strings.TrimPrefix(strings.TrimSpace(r.URL.Path), "/app/"))
112
- case r.URL.Path == "/admin" || r.URL.Path == "/admin/":
121
+ case strings.HasPrefix(strings.TrimSpace(r.URL.Path), types.PathAppPrefix):
122
+ frontend.ServeAppStatic(w, r, strings.TrimPrefix(strings.TrimSpace(r.URL.Path), types.PathAppPrefix))
123
+ case r.URL.Path == types.PathAdmin || r.URL.Path == types.PathAdminPrefix:
124
admin.HandleAdminRequest(w, r)
114
- case strings.HasPrefix(strings.TrimSpace(r.URL.Path), "/admin/"):
125
+ case strings.HasPrefix(strings.TrimSpace(r.URL.Path), types.PathAdminPrefix):
126
admin.HandleAdminRequest(w, r)
116
- case r.URL.Path == "/tunnel":
127
+ case r.URL.Path == types.PathTunnel:
128
serveTunnelScript(w, r, cfg.PortalURL)
118
- case strings.HasPrefix(strings.TrimSpace(r.URL.Path), "/tunnel/bin/"):
129
+ case strings.HasPrefix(strings.TrimSpace(r.URL.Path), types.PathTunnelBinPrefix):
130
serveTunnelBinary(w, r)
131
default:
132
base.ServeHTTP(w, r)
@@ -126,13 +137,13 @@ func serveAPI(frontend *Frontend, admin *Admin, cfg relayServerConfig) func(http
137
138
func isFrontendRootAssetPath(requestPath string) bool {
139
switch requestPath {
129
- case "/favicon.ico",
130
- "/favicon.svg",
131
- "/favicon-96x96.png",
132
- "/apple-touch-icon.png",
133
- "/web-app-manifest-192x192.png",
134
- "/web-app-manifest-512x512.png",
135
- "/portal.jpg":
140
+ case pathFaviconICO,
141
+ pathFaviconSVG,
142
+ pathFavicon96PNG,
143
+ pathAppleTouchIconPNG,
144
+ pathWebAppManifest192PNG,
145
+ pathWebAppManifest512PNG,
146
+ pathPortalJPG:
147
return true
148
default:
149
return false
@@ -149,3 +160,26 @@ func mustRead(path string) []byte {
160
}
161
return data
162
}
163
+
164
+func parseURLs(raw string) []string {
165
+ if strings.TrimSpace(raw) == "" {
166
+ return nil
167
+ }
168
+ parts := strings.Split(raw, ",")
169
+ out := make([]string, 0, len(parts))
170
+ for _, part := range parts {
171
+ part = strings.TrimSpace(part)
172
+ if part != "" {
173
+ out = append(out, part)
174
+ }
175
+ }
176
+ return out
177
+}
178
+
179
+func isRelayControlPlanePath(path string) bool {
180
+ switch strings.TrimSpace(path) {
181
+ case types.PathSDKRegister, types.PathSDKConnect, types.PathSDKRenew, types.PathSDKUnregister, types.PathSDKDomain:
182
+ return true
183
+ }
184
+ return strings.HasPrefix(path, types.PathSDKPrefix)
185
+}
portal/api.go
+4
-66
@@ -3,76 +3,14 @@ package portal
3
import (
4
"encoding/json"
5
"net/http"
6
- "time"
7
-)
6
9
-const (
10
- HeaderReverseToken = "X-Portal-Token"
11
- MarkerKeepalive = byte(0x00)
12
- MarkerTLSStart = byte(0x02)
7
+ "gosuda.org/portal/types"
8
)
9
15
-type APIEnvelope struct {
16
- Data any `json:"data,omitempty"`
17
- Error *APIError `json:"error,omitempty"`
18
- OK bool `json:"ok"`
19
-}
20
-
21
-type APIError struct {
22
- Code string `json:"code"`
23
- Message string `json:"message"`
24
-}
25
-
26
-type LeaseMetadata struct {
27
- Description string `json:"description,omitempty"`
28
- Owner string `json:"owner,omitempty"`
29
- Thumbnail string `json:"thumbnail,omitempty"`
30
- Tags []string `json:"tags,omitempty"`
31
- Hide bool `json:"hide,omitempty"`
32
-}
33
-
34
-type RegisterRequest struct {
35
- Name string `json:"name"`
36
- ReverseToken string `json:"reverse_token"`
37
- Hostnames []string `json:"hostnames,omitempty"`
38
- Metadata LeaseMetadata `json:"metadata,omitempty"`
39
- TTLSeconds int `json:"ttl_seconds,omitempty"`
40
- TLS bool `json:"tls"`
41
-}
42
-
43
-type RegisterResponse struct {
44
- ExpiresAt time.Time `json:"expires_at"`
45
- LeaseID string `json:"lease_id"`
46
- ConnectURL string `json:"connect_url"`
47
- Hostnames []string `json:"hostnames"`
48
- Metadata LeaseMetadata `json:"metadata,omitempty"`
49
-}
50
-
51
-type RenewRequest struct {
52
- LeaseID string `json:"lease_id"`
53
- ReverseToken string `json:"reverse_token"`
54
- TTLSeconds int `json:"ttl_seconds,omitempty"`
55
-}
56
-
57
-type RenewResponse struct {
58
- ExpiresAt time.Time `json:"expires_at"`
59
- LeaseID string `json:"lease_id"`
60
-}
61
-
62
-type UnregisterRequest struct {
63
- LeaseID string `json:"lease_id"`
64
- ReverseToken string `json:"reverse_token"`
65
-}
66
-
67
-type DomainResponse struct {
68
- RootHost string `json:"root_host"`
69
- SuggestedHostname string `json:"suggested_hostname"`
70
-}
71
-
10
func writeAPIData(w http.ResponseWriter, status int, data any) {
11
w.Header().Set("Content-Type", "application/json")
12
w.WriteHeader(status)
75
- _ = json.NewEncoder(w).Encode(APIEnvelope{OK: true, Data: data})
13
+ _ = json.NewEncoder(w).Encode(types.APIEnvelope{OK: true, Data: data})
14
}
15
16
func writeAPIOK(w http.ResponseWriter, status int) {
@@ -82,8 +20,8 @@ func writeAPIOK(w http.ResponseWriter, status int) {
20
func writeAPIError(w http.ResponseWriter, status int, code, message string) {
21
w.Header().Set("Content-Type", "application/json")
22
w.WriteHeader(status)
85
- _ = json.NewEncoder(w).Encode(APIEnvelope{
23
+ _ = json.NewEncoder(w).Encode(types.APIEnvelope{
24
OK: false,
87
- Error: &APIError{Code: code, Message: message},
25
+ Error: &types.APIError{Code: code, Message: message},
26
})
27
}
portal/broker.go
+19
-2
@@ -7,6 +7,10 @@ import (
7
"net"
8
"sync"
9
"time"
10
+
11
+ "github.com/rs/zerolog/log"
12
+
13
+ "gosuda.org/portal/types"
14
)
15
16
var (
@@ -150,6 +154,12 @@ func (b *leaseBroker) watchSession(session *reverseSession) {
154
break
155
}
156
}
157
+ log.Info().
158
+ Str("component", "relay-server").
159
+ Str("lease_id", b.leaseID).
160
+ Str("remote_addr", session.RemoteAddr()).
161
+ Int("ready", len(b.ready)).
162
+ Msg("sdk reverse disconnected")
163
b.signalLocked()
164
}
165
@@ -197,6 +207,13 @@ func (s *reverseSession) Done() <-chan struct{} {
207
return s.done
208
}
209
210
+func (s *reverseSession) RemoteAddr() string {
211
+ if s == nil || s.conn == nil || s.conn.RemoteAddr() == nil {
212
+ return ""
213
+ }
214
+ return s.conn.RemoteAddr().String()
215
+}
216
+
217
func (s *reverseSession) IsClosed() bool {
218
select {
219
case <-s.done:
@@ -249,7 +266,7 @@ func (s *reverseSession) Activate() error {
266
return net.ErrClosed
267
}
268
_ = s.conn.SetWriteDeadline(time.Now().Add(defaultSessionWriteLimit))
252
- _, err := s.conn.Write([]byte{MarkerTLSStart})
269
+ _, err := s.conn.Write([]byte{types.MarkerTLSStart})
270
_ = s.conn.SetWriteDeadline(time.Time{})
271
if err != nil {
272
_ = s.Close()
@@ -303,7 +320,7 @@ func (s *reverseSession) runKeepalive(stop <-chan struct{}, done chan<- struct{}
320
return
321
}
322
_ = s.conn.SetWriteDeadline(time.Now().Add(defaultSessionWriteLimit))
306
- _, err := s.conn.Write([]byte{MarkerKeepalive})
323
+ _, err := s.conn.Write([]byte{types.MarkerKeepalive})
324
_ = s.conn.SetWriteDeadline(time.Time{})
325
s.mu.Unlock()
326
if err != nil {
portal/broker_test.go
+4
-2
@@ -6,6 +6,8 @@ import (
6
"net"
7
"testing"
8
"time"
9
+
10
+ "gosuda.org/portal/types"
11
)
12
13
func TestLeaseBrokerClaimActivatesTLSMarker(t *testing.T) {
@@ -49,8 +51,8 @@ func TestLeaseBrokerClaimActivatesTLSMarker(t *testing.T) {
51
case err := <-errCh:
52
t.Fatalf("ReadFull() error = %v", err)
53
case marker := <-markerCh:
52
- if marker != MarkerTLSStart {
53
- t.Fatalf("marker = 0x%02x, want 0x%02x", marker, MarkerTLSStart)
54
+ if marker != types.MarkerTLSStart {
55
+ t.Fatalf("marker = 0x%02x, want 0x%02x", marker, types.MarkerTLSStart)
56
}
57
case <-time.After(time.Second):
58
t.Fatal("timed out waiting for activation marker")
portal/helpers.go
+3
-1
@@ -8,6 +8,8 @@ import (
8
"net/url"
9
"strings"
10
"time"
11
+
12
+ "gosuda.org/portal/types"
13
)
14
15
const (
@@ -108,7 +110,7 @@ func intOrDefault(v, fallback int) int {
110
return fallback
111
}
112
111
-func normalizeMetadata(meta LeaseMetadata) LeaseMetadata {
113
+func normalizeMetadata(meta types.LeaseMetadata) types.LeaseMetadata {
114
meta.Description = strings.TrimSpace(meta.Description)
115
meta.Owner = strings.TrimSpace(meta.Owner)
116
meta.Thumbnail = strings.TrimSpace(meta.Thumbnail)
portal/server.go
+45
-29
@@ -14,11 +14,12 @@ import (
14
"sync"
15
"time"
16
17
+ "github.com/rs/zerolog/log"
18
"golang.org/x/sync/errgroup"
19
20
"github.com/gosuda/keyless_tls/relay/l4"
20
-
21
"gosuda.org/portal/portal/keyless"
22
+ "gosuda.org/portal/types"
23
)
24
25
type ServerConfig struct {
@@ -60,7 +61,7 @@ type leaseRecord struct {
61
Name string
62
ReverseToken string
63
Hostnames []string
63
- Metadata LeaseMetadata
64
+ Metadata types.LeaseMetadata
65
}
66
67
type LeaseSnapshot struct {
@@ -68,7 +69,7 @@ type LeaseSnapshot struct {
69
ID string
70
Name string
71
Hostnames []string
71
- Metadata LeaseMetadata
72
+ Metadata types.LeaseMetadata
73
Ready int
74
}
75
@@ -244,14 +245,14 @@ func (s *Server) ListLeases() []LeaseSnapshot {
245
func (s *Server) apiHandler() http.Handler {
246
mux := http.NewServeMux()
247
if s.cfg.KeylessSignerHandler != nil {
247
- mux.Handle("/v1/sign", s.cfg.KeylessSignerHandler)
248
- }
249
- mux.HandleFunc("/healthz", s.handleHealthz)
250
- mux.HandleFunc("/sdk/domain", s.handleDomain)
251
- mux.HandleFunc("/sdk/register", s.handleRegister)
252
- mux.HandleFunc("/sdk/renew", s.handleRenew)
253
- mux.HandleFunc("/sdk/unregister", s.handleUnregister)
254
- mux.HandleFunc("/sdk/connect", s.handleConnect)
248
+ mux.Handle(types.PathV1Sign, s.cfg.KeylessSignerHandler)
249
+ }
250
+ mux.HandleFunc(types.PathHealthz, s.handleHealthz)
251
+ mux.HandleFunc(types.PathSDKDomain, s.handleDomain)
252
+ mux.HandleFunc(types.PathSDKRegister, s.handleRegister)
253
+ mux.HandleFunc(types.PathSDKRenew, s.handleRenew)
254
+ mux.HandleFunc(types.PathSDKUnregister, s.handleUnregister)
255
+ mux.HandleFunc(types.PathSDKConnect, s.handleConnect)
256
mux.HandleFunc("/", s.handleRoot)
257
return mux
258
}
@@ -273,7 +274,7 @@ func (s *Server) handleDomain(w http.ResponseWriter, r *http.Request) {
274
return
275
}
276
name := r.URL.Query().Get("name")
276
- writeAPIData(w, http.StatusOK, DomainResponse{
277
+ writeAPIData(w, http.StatusOK, types.DomainResponse{
278
RootHost: s.cfg.RootHost,
279
SuggestedHostname: suggestHostname(name, s.cfg.RootHost),
280
})
@@ -284,7 +285,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
285
writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
286
return
287
}
287
- var req RegisterRequest
288
+ var req types.RegisterRequest
289
if err := decodeJSONBody(w, r, &req); err != nil {
290
writeAPIError(w, http.StatusBadRequest, "invalid_json", err.Error())
291
return
@@ -306,7 +307,7 @@ func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
307
writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
308
return
309
}
309
- var req RenewRequest
310
+ var req types.RenewRequest
311
if err := decodeJSONBody(w, r, &req); err != nil {
312
writeAPIError(w, http.StatusBadRequest, "invalid_json", err.Error())
313
return
@@ -331,7 +332,7 @@ func (s *Server) handleUnregister(w http.ResponseWriter, r *http.Request) {
332
writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
333
return
334
}
334
- var req UnregisterRequest
335
+ var req types.UnregisterRequest
336
if err := decodeJSONBody(w, r, &req); err != nil {
337
writeAPIError(w, http.StatusBadRequest, "invalid_json", err.Error())
338
return
@@ -361,7 +362,7 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
362
}
363
364
leaseID := strings.TrimSpace(r.URL.Query().Get("lease_id"))
364
- token := strings.TrimSpace(r.Header.Get(HeaderReverseToken))
365
+ token := strings.TrimSpace(r.Header.Get(types.HeaderReverseToken))
366
lease, err := s.lookupLeaseByID(leaseID, token)
367
if err != nil {
368
status, code := http.StatusForbidden, "unauthorized"
@@ -395,19 +396,34 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
396
397
session := newReverseSession(conn, s.cfg.IdleKeepaliveInterval)
398
if err := lease.Broker.Offer(session); err != nil {
399
+ log.Warn().
400
+ Err(err).
401
+ Str("component", "relay-server").
402
+ Str("lease_id", lease.ID).
403
+ Str("lease_name", lease.Name).
404
+ Str("remote_addr", session.RemoteAddr()).
405
+ Msg("sdk reverse rejected")
406
_ = session.Close()
407
+ return
408
}
409
+ log.Info().
410
+ Str("component", "relay-server").
411
+ Str("lease_id", lease.ID).
412
+ Str("lease_name", lease.Name).
413
+ Str("remote_addr", session.RemoteAddr()).
414
+ Int("ready", lease.Broker.ReadyCount()).
415
+ Msg("sdk reverse connected")
416
}
417
402
-func (s *Server) registerLease(req RegisterRequest) (RegisterResponse, error) {
418
+func (s *Server) registerLease(req types.RegisterRequest) (types.RegisterResponse, error) {
419
if strings.TrimSpace(req.Name) == "" {
404
- return RegisterResponse{}, errors.New("name is required")
420
+ return types.RegisterResponse{}, errors.New("name is required")
421
}
422
if strings.TrimSpace(req.ReverseToken) == "" {
407
- return RegisterResponse{}, errors.New("reverse token is required")
423
+ return types.RegisterResponse{}, errors.New("reverse token is required")
424
}
425
if !req.TLS {
410
- return RegisterResponse{}, errors.New("tls must be true")
426
+ return types.RegisterResponse{}, errors.New("tls must be true")
427
}
428
429
hostnames := normalizeHostnames(req.Hostnames)
@@ -420,7 +436,7 @@ func (s *Server) registerLease(req RegisterRequest) (RegisterResponse, error) {
436
437
for _, host := range hostnames {
438
if owner := s.findLeaseByHostnameLocked(host); owner != nil {
423
- return RegisterResponse{}, fmt.Errorf("%w: %s", errHostnameConflict, host)
439
+ return types.RegisterResponse{}, fmt.Errorf("%w: %s", errHostnameConflict, host)
440
}
441
}
442
@@ -446,7 +462,7 @@ func (s *Server) registerLease(req RegisterRequest) (RegisterResponse, error) {
462
s.routes.Set(host, leaseID)
463
}
464
449
- return RegisterResponse{
465
+ return types.RegisterResponse{
466
LeaseID: leaseID,
467
Hostnames: append([]string(nil), hostnames...),
468
Metadata: record.Metadata,
@@ -455,16 +471,16 @@ func (s *Server) registerLease(req RegisterRequest) (RegisterResponse, error) {
471
}, nil
472
}
473
458
-func (s *Server) renewLease(req RenewRequest) (RenewResponse, error) {
474
+func (s *Server) renewLease(req types.RenewRequest) (types.RenewResponse, error) {
475
s.mu.Lock()
476
defer s.mu.Unlock()
477
478
record, ok := s.leases[strings.TrimSpace(req.LeaseID)]
479
if !ok {
464
- return RenewResponse{}, errLeaseNotFound
480
+ return types.RenewResponse{}, errLeaseNotFound
481
}
482
if !tokenMatches(record.ReverseToken, req.ReverseToken) {
467
- return RenewResponse{}, errUnauthorized
483
+ return types.RenewResponse{}, errUnauthorized
484
}
485
486
ttl := s.cfg.LeaseTTL
@@ -473,10 +489,10 @@ func (s *Server) renewLease(req RenewRequest) (RenewResponse, error) {
489
}
490
record.ExpiresAt = time.Now().Add(ttl)
491
record.Broker.Reset()
476
- return RenewResponse{LeaseID: record.ID, ExpiresAt: record.ExpiresAt}, nil
492
+ return types.RenewResponse{LeaseID: record.ID, ExpiresAt: record.ExpiresAt}, nil
493
}
494
479
-func (s *Server) unregisterLease(req UnregisterRequest) error {
495
+func (s *Server) unregisterLease(req types.UnregisterRequest) error {
496
s.mu.Lock()
497
record, ok := s.leases[strings.TrimSpace(req.LeaseID)]
498
if !ok {
@@ -644,9 +660,9 @@ func (s *Server) watchContext() error {
660
func (s *Server) connectURL() string {
661
base := strings.TrimRight(s.cfg.PortalURL, "/")
662
if base == "" && s.apiListener != nil {
647
- return "https://" + HostPortOrLoopback(s.apiListener.Addr().String()) + "/sdk/connect"
663
+ return "https://" + HostPortOrLoopback(s.apiListener.Addr().String()) + types.PathSDKConnect
664
}
649
- return base + "/sdk/connect"
665
+ return base + types.PathSDKConnect
666
}
667
668
func (s *Server) wrapAPIHandler(base http.Handler) http.Handler {
sdk/client.go
+27
-18
@@ -18,8 +18,8 @@ import (
18
"strings"
19
"time"
20
21
- "gosuda.org/portal/portal"
21
"gosuda.org/portal/portal/keyless"
22
+ "gosuda.org/portal/types"
23
)
24
25
type ClientConfig struct {
@@ -46,14 +46,19 @@ type Client struct {
46
}
47
48
func NewClient(cfg ClientConfig) (*Client, error) {
49
- relayURL, err := portal.NormalizeRelayURL(cfg.RelayURL)
50
- if err != nil {
51
- return nil, err
52
- }
53
- baseURL, err := url.Parse(relayURL)
49
+ baseURL, err := url.Parse(strings.TrimSpace(cfg.RelayURL))
50
if err != nil {
51
return nil, fmt.Errorf("parse relay url: %w", err)
52
}
53
+ if !strings.EqualFold(baseURL.Scheme, "https") {
54
+ return nil, fmt.Errorf("relay url must use https: %q", cfg.RelayURL)
55
+ }
56
+ if baseURL.Host == "" {
57
+ return nil, fmt.Errorf("relay url host is empty: %q", cfg.RelayURL)
58
+ }
59
+ baseURL.Path = strings.TrimRight(baseURL.Path, "/")
60
+ baseURL.RawQuery = ""
61
+ baseURL.Fragment = ""
62
63
rootCAs, err := buildRootCAs(cfg.RootCAPEM)
64
if err != nil {
@@ -134,8 +139,12 @@ func (c *Client) Listen(ctx context.Context, req ListenRequest) (*Listener, erro
139
if leaseTTL <= 0 {
140
leaseTTL = c.leaseTTL
141
}
142
+ acceptedCap := readyTarget * 2
143
+ if acceptedCap < 1 {
144
+ acceptedCap = 1
145
+ }
146
138
- registerReq := portal.RegisterRequest{
147
+ registerReq := types.RegisterRequest{
148
Name: req.Name,
149
Hostnames: append([]string(nil), req.Hostnames...),
150
Metadata: req.Metadata,
@@ -144,8 +153,8 @@ func (c *Client) Listen(ctx context.Context, req ListenRequest) (*Listener, erro
153
TTLSeconds: int(leaseTTL / time.Second),
154
}
155
147
- var registerResp portal.RegisterResponse
148
- if err := c.doJSON(ctx, http.MethodPost, "/sdk/register", registerReq, ®isterResp); err != nil {
156
+ var registerResp types.RegisterResponse
157
+ if err := c.doJSON(ctx, http.MethodPost, types.PathSDKRegister, registerReq, ®isterResp); err != nil {
158
return nil, err
159
}
160
@@ -169,7 +178,7 @@ func (c *Client) Listen(ctx context.Context, req ListenRequest) (*Listener, erro
178
readyTarget: readyTarget,
179
tlsConfig: tlsConf,
180
tlsCloser: tlsCloser,
172
- accepted: make(chan net.Conn, max(readyTarget*2, 1)),
181
+ accepted: make(chan net.Conn, acceptedCap),
182
signal: make(chan struct{}, 1),
183
}
184
@@ -218,15 +227,15 @@ func (c *Client) doJSON(ctx context.Context, method, path string, payload any, o
227
}
228
229
func (c *Client) renewLease(ctx context.Context, leaseID, reverseToken string, ttl time.Duration) error {
221
- return c.doJSON(ctx, http.MethodPost, "/sdk/renew", portal.RenewRequest{
230
+ return c.doJSON(ctx, http.MethodPost, types.PathSDKRenew, types.RenewRequest{
231
LeaseID: leaseID,
232
ReverseToken: reverseToken,
233
TTLSeconds: int(ttl / time.Second),
225
- }, &portal.RenewResponse{})
234
+ }, &types.RenewResponse{})
235
}
236
237
func (c *Client) unregisterLease(ctx context.Context, leaseID, reverseToken string) error {
229
- return c.doJSON(ctx, http.MethodPost, "/sdk/unregister", portal.UnregisterRequest{
238
+ return c.doJSON(ctx, http.MethodPost, types.PathSDKUnregister, types.UnregisterRequest{
239
LeaseID: leaseID,
240
ReverseToken: reverseToken,
241
}, nil)
@@ -243,7 +252,7 @@ func (c *Client) openReverseSession(ctx context.Context, leaseID, reverseToken s
252
return nil, err
253
}
254
246
- connectURL, err := url.Parse(c.resolve("/sdk/connect"))
255
+ connectURL, err := url.Parse(c.resolve(types.PathSDKConnect))
256
if err != nil {
257
_ = conn.Close()
258
return nil, err
@@ -258,7 +267,7 @@ func (c *Client) openReverseSession(ctx context.Context, leaseID, reverseToken s
267
Host: c.baseURL.Host,
268
Header: make(http.Header),
269
}
261
- req.Header.Set(portal.HeaderReverseToken, reverseToken)
270
+ req.Header.Set(types.HeaderReverseToken, reverseToken)
271
req.Header.Set("Connection", "keep-alive")
272
273
if writeErr := req.Write(conn); writeErr != nil {
@@ -289,9 +298,9 @@ func (c *Client) resolve(path string) string {
298
}
299
300
type apiEnvelope struct {
292
- Error *portal.APIError `json:"error"`
293
- Data json.RawMessage `json:"data"`
294
- OK bool `json:"ok"`
301
+ Error *types.APIError `json:"error"`
302
+ Data json.RawMessage `json:"data"`
303
+ OK bool `json:"ok"`
304
}
305
306
func buildRootCAs(rootCAPEM []byte) (*x509.CertPool, error) {
sdk/listener.go
+6
-8
@@ -10,16 +10,14 @@ import (
10
"sync"
11
"time"
12
13
- "gosuda.org/portal/portal"
13
+ "gosuda.org/portal/types"
14
)
15
16
-type LeaseMetadata = portal.LeaseMetadata
17
-
16
type ListenRequest struct {
17
Name string
18
ReverseToken string
19
Hostnames []string
22
- Metadata LeaseMetadata
20
+ Metadata types.LeaseMetadata
21
ReadyTarget int
22
LeaseTTL time.Duration
23
}
@@ -36,7 +34,7 @@ type Listener struct {
34
leaseID string
35
reverseToken string
36
hostnames []string
39
- metadata LeaseMetadata
37
+ metadata types.LeaseMetadata
38
readyTarget int
39
leaseTTL time.Duration
40
activeSessions int
@@ -85,7 +83,7 @@ func (l *Listener) Hostnames() []string {
83
return append([]string(nil), l.hostnames...)
84
}
85
88
-func (l *Listener) Metadata() LeaseMetadata {
86
+func (l *Listener) Metadata() types.LeaseMetadata {
87
return l.metadata
88
}
89
@@ -166,9 +164,9 @@ func (l *Listener) awaitActivation(conn net.Conn) error {
164
_ = conn.SetReadDeadline(time.Time{})
165
166
switch marker[0] {
169
- case portal.MarkerKeepalive:
167
+ case types.MarkerKeepalive:
168
continue
171
- case portal.MarkerTLSStart:
169
+ case types.MarkerTLSStart:
170
return l.activate(conn)
171
default:
172
return fmt.Errorf("unexpected reverse marker: 0x%02x", marker[0])
sdk/listener_test.go
+2
-1
@@ -23,6 +23,7 @@ import (
23
24
"gosuda.org/portal/portal"
25
"gosuda.org/portal/portal/keyless"
26
+ "gosuda.org/portal/types"
27
)
28
29
func selfSignedCertPEM(hosts ...string) (certPEM, keyPEM []byte, err error) {
@@ -122,7 +123,7 @@ func TestListenerEndToEndTLSHTTP(t *testing.T) {
123
listener, err := client.Listen(ctx, ListenRequest{
124
Name: "demo",
125
Hostnames: []string{tenantHost},
125
- Metadata: LeaseMetadata{
126
+ Metadata: types.LeaseMetadata{
127
Description: "demo description",
128
Tags: []string{"demo", "test", "demo"},
129
Owner: "portal",
types/api.go
new
+66
@@ -0,0 +1,66 @@
1
+package types
2
+
3
+import "time"
4
+
5
+const (
6
+ HeaderReverseToken = "X-Portal-Token"
7
+ MarkerKeepalive = byte(0x00)
8
+ MarkerTLSStart = byte(0x02)
9
+)
10
+
11
+type APIEnvelope struct {
12
+ Data any `json:"data,omitempty"`
13
+ Error *APIError `json:"error,omitempty"`
14
+ OK bool `json:"ok"`
15
+}
16
+
17
+type APIError struct {
18
+ Code string `json:"code"`
19
+ Message string `json:"message"`
20
+}
21
+
22
+type LeaseMetadata struct {
23
+ Description string `json:"description,omitempty"`
24
+ Owner string `json:"owner,omitempty"`
25
+ Thumbnail string `json:"thumbnail,omitempty"`
26
+ Tags []string `json:"tags,omitempty"`
27
+ Hide bool `json:"hide,omitempty"`
28
+}
29
+
30
+type RegisterRequest struct {
31
+ Name string `json:"name"`
32
+ ReverseToken string `json:"reverse_token"`
33
+ Hostnames []string `json:"hostnames,omitempty"`
34
+ Metadata LeaseMetadata `json:"metadata,omitempty"`
35
+ TTLSeconds int `json:"ttl_seconds,omitempty"`
36
+ TLS bool `json:"tls"`
37
+}
38
+
39
+type RegisterResponse struct {
40
+ ExpiresAt time.Time `json:"expires_at"`
41
+ LeaseID string `json:"lease_id"`
42
+ ConnectURL string `json:"connect_url"`
43
+ Hostnames []string `json:"hostnames"`
44
+ Metadata LeaseMetadata `json:"metadata,omitempty"`
45
+}
46
+
47
+type RenewRequest struct {
48
+ LeaseID string `json:"lease_id"`
49
+ ReverseToken string `json:"reverse_token"`
50
+ TTLSeconds int `json:"ttl_seconds,omitempty"`
51
+}
52
+
53
+type RenewResponse struct {
54
+ ExpiresAt time.Time `json:"expires_at"`
55
+ LeaseID string `json:"lease_id"`
56
+}
57
+
58
+type UnregisterRequest struct {
59
+ LeaseID string `json:"lease_id"`
60
+ ReverseToken string `json:"reverse_token"`
61
+}
62
+
63
+type DomainResponse struct {
64
+ RootHost string `json:"root_host"`
65
+ SuggestedHostname string `json:"suggested_hostname"`
66
+}
types/paths.go
new
+22
@@ -0,0 +1,22 @@
1
+package types
2
+
3
+const (
4
+ PathV1Sign = "/v1/sign"
5
+ PathHealthz = "/healthz"
6
+ PathRoot = "/"
7
+ PathAssetsPrefix = "/assets/"
8
+ PathApp = "/app"
9
+ PathAppPrefix = "/app/"
10
+ PathAdmin = "/admin"
11
+ PathAdminPrefix = "/admin/"
12
+ PathAdminLeases = "/admin/leases"
13
+ PathTunnel = "/tunnel"
14
+ PathTunnelBinPrefix = "/tunnel/bin/"
15
+
16
+ PathSDKPrefix = "/sdk/"
17
+ PathSDKDomain = "/sdk/domain"
18
+ PathSDKRegister = "/sdk/register"
19
+ PathSDKRenew = "/sdk/renew"
20
+ PathSDKUnregister = "/sdk/unregister"
21
+ PathSDKConnect = "/sdk/connect"
22
+)