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, &registerResp); err != nil {
156 + var registerResp types.RegisterResponse
157 + if err := c.doJSON(ctx, http.MethodPost, types.PathSDKRegister, registerReq, &registerResp); 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 +)