feat: refactor lease entry handling, improve SNI redirection logic, and streamline related components
gosunuts committed
Feb 25, 2026 at 11:33 UTC
d772d26358d2f23b22d5423cf0d8cb21fc8643e5
5 files changed
+273
-485
cmd/portal-tunnel/main.go
+27
-27
@@ -17,33 +17,6 @@ import (
17
"gosuda.org/portal/sdk"
18
)
19
20
-// parseURLs splits a comma-separated string into a list of trimmed, non-empty URLs.
21
-func parseURLs(raw string) []string {
22
- raw = strings.TrimSpace(raw)
23
- if raw == "" {
24
- return nil
25
- }
26
- parts := strings.Split(raw, ",")
27
- out := make([]string, 0, len(parts))
28
- for _, p := range parts {
29
- p = strings.TrimSpace(p)
30
- if p != "" {
31
- out = append(out, p)
32
- }
33
- }
34
- return out
35
-}
36
-
37
-// bufferPool provides reusable 64KB buffers for io.CopyBuffer to eliminate
38
-// per-copy allocations and reduce GC pressure under high concurrency.
39
-// Using *[]byte to avoid interface boxing allocation in sync.Pool.
40
-var bufferPool = sync.Pool{
41
- New: func() any {
42
- b := make([]byte, 64*1024)
43
- return &b
44
- },
45
-}
46
-
20
type Config struct {
21
_ struct{} `version:"0.0.1" command:"portal-tunnel" about:"Expose local services through Portal relay"`
22
@@ -212,6 +185,23 @@ func runServiceTunnel(ctx context.Context, relayURLs []string, cfg Config, origi
185
}
186
}
187
188
+// parseURLs splits a comma-separated string into a list of trimmed, non-empty URLs.
189
+func parseURLs(raw string) []string {
190
+ raw = strings.TrimSpace(raw)
191
+ if raw == "" {
192
+ return nil
193
+ }
194
+ parts := strings.Split(raw, ",")
195
+ out := make([]string, 0, len(parts))
196
+ for _, p := range parts {
197
+ p = strings.TrimSpace(p)
198
+ if p != "" {
199
+ out = append(out, p)
200
+ }
201
+ }
202
+ return out
203
+}
204
+
205
func splitCSV(raw string) []string {
206
parts := strings.Split(raw, ",")
207
out := make([]string, 0, len(parts))
@@ -224,6 +214,16 @@ func splitCSV(raw string) []string {
214
return out
215
}
216
217
+// bufferPool provides reusable 64KB buffers for io.CopyBuffer to eliminate
218
+// per-copy allocations and reduce GC pressure under high concurrency.
219
+// Using *[]byte to avoid interface boxing allocation in sync.Pool.
220
+var bufferPool = sync.Pool{
221
+ New: func() any {
222
+ b := make([]byte, 64*1024)
223
+ return &b
224
+ },
225
+}
226
+
227
// proxyConnection proxies data between relay and local service using raw TCP.
228
// It ensures complete data transfer before closing connections.
229
func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn) error {
cmd/relay-server/admin.go
+1
-141
@@ -1,15 +1,12 @@
1
package main
2
3
import (
4
- "encoding/base64"
4
"encoding/json"
6
- "fmt"
5
"net/http"
6
"os"
7
"path/filepath"
8
"strings"
9
"sync"
12
- "time"
10
11
"github.com/rs/zerolog/log"
12
@@ -241,7 +238,7 @@ func (a *Admin) HandleAdminRequest(w http.ResponseWriter, r *http.Request, serv
238
case route == "":
239
a.frontend.ServeAppStatic(w, r, "", serv)
240
case route == "leases" && r.Method == http.MethodGet:
244
- writeJSON(w, a.convertLeaseEntriesToAdminRows(serv))
241
+ writeJSON(w, convertLeaseEntriesToRows(serv, a, true))
242
case route == "leases/banned" && r.Method == http.MethodGet:
243
writeJSON(w, serv.GetLeaseManager().GetBannedLeases())
244
case route == "stats" && r.Method == http.MethodGet:
@@ -576,140 +573,3 @@ func (a *Admin) handleIPBanRequest(w http.ResponseWriter, r *http.Request, serv
573
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
574
}
575
}
579
-
580
-// convertLeaseEntriesToAdminRows converts LeaseEntry data to leaseRow format for admin API
581
-func (a *Admin) convertLeaseEntriesToAdminRows(serv *portal.RelayServer) []leaseRow {
582
- leaseEntries := serv.GetAllLeaseEntries()
583
- rows := []leaseRow{}
584
- now := time.Now()
585
-
586
- for _, leaseEntry := range leaseEntries {
587
- if now.After(leaseEntry.Expires) {
588
- continue
589
- }
590
-
591
- lease := leaseEntry.Lease
592
- identityID := lease.ID
593
-
594
- ttl := time.Until(leaseEntry.Expires)
595
- ttlStr := ""
596
- if ttl > 0 {
597
- if ttl > time.Hour {
598
- ttlStr = fmt.Sprintf("%.0fh", ttl.Hours())
599
- } else if ttl > time.Minute {
600
- ttlStr = fmt.Sprintf("%.0fm", ttl.Minutes())
601
- } else {
602
- ttlStr = fmt.Sprintf("%.0fs", ttl.Seconds())
603
- }
604
- }
605
-
606
- since := max(now.Sub(leaseEntry.LastSeen), 0)
607
- lastSeenStr := func(d time.Duration) string {
608
- if d >= time.Hour {
609
- h := int(d / time.Hour)
610
- m := int((d % time.Hour) / time.Minute)
611
- if m > 0 {
612
- return fmt.Sprintf("%dh %dm", h, m)
613
- }
614
- return fmt.Sprintf("%dh", h)
615
- }
616
- if d >= time.Minute {
617
- m := int(d / time.Minute)
618
- s := int((d % time.Minute) / time.Second)
619
- if s > 0 {
620
- return fmt.Sprintf("%dm %ds", m, s)
621
- }
622
- return fmt.Sprintf("%dm", m)
623
- }
624
- return fmt.Sprintf("%ds", int(d/time.Second))
625
- }(since)
626
- lastSeenISO := leaseEntry.LastSeen.UTC().Format(time.RFC3339)
627
- firstSeenISO := leaseEntry.FirstSeen.UTC().Format(time.RFC3339)
628
-
629
- connected := since < 15*time.Second
630
-
631
- name := lease.Name
632
- if name == "" {
633
- name = "(unnamed)"
634
- }
635
-
636
- // Determine protocol based on TLS setting
637
- kind := "http"
638
- if lease.TLSEnabled {
639
- kind = "https"
640
- }
641
-
642
- dnsLabel := identityID
643
- if len(dnsLabel) > 8 {
644
- dnsLabel = dnsLabel[:8] + "..."
645
- }
646
-
647
- link := fmt.Sprintf("//%s.%s/", lease.Name, portalHostPort(flagPortalURL))
648
-
649
- var bps int64
650
- if a.bpsManager != nil {
651
- bps = a.bpsManager.GetBPSLimit(identityID)
652
- }
653
-
654
- // Get IP info for this lease
655
- var ip string
656
- var isIPBanned bool
657
- if a.ipManager != nil {
658
- ip = a.ipManager.GetLeaseIP(identityID)
659
- if ip != "" {
660
- isIPBanned = a.ipManager.IsIPBanned(ip)
661
- }
662
- }
663
-
664
- metadata := lease.Metadata
665
- metadataStr := ""
666
- if metadata.Description != "" || len(metadata.Tags) > 0 || metadata.Thumbnail != "" || metadata.Owner != "" || metadata.Hide {
667
- if b, err := json.Marshal(metadata); err == nil {
668
- metadataStr = string(b)
669
- } else {
670
- log.Warn().Err(err).Str("lease_id", identityID).Msg("[Admin] Failed to marshal lease metadata")
671
- }
672
- }
673
-
674
- rows = append(rows, leaseRow{
675
- Peer: identityID,
676
- Name: name,
677
- Kind: kind,
678
- Connected: connected,
679
- DNS: dnsLabel,
680
- LastSeen: lastSeenStr,
681
- LastSeenISO: lastSeenISO,
682
- FirstSeenISO: firstSeenISO,
683
- TTL: ttlStr,
684
- Link: link,
685
- StaleRed: !connected && since >= 15*time.Second,
686
- Hide: leaseEntry.ParsedMetadata != nil && leaseEntry.ParsedMetadata.Hide,
687
- Metadata: metadataStr,
688
- BPS: bps,
689
- IsApproved: a.approveManager.GetApprovalMode() == manager.ApprovalModeAuto || a.approveManager.IsLeaseApproved(identityID),
690
- IsDenied: a.approveManager.IsLeaseDenied(identityID),
691
- IP: ip,
692
- IsIPBanned: isIPBanned,
693
- })
694
- }
695
-
696
- return rows
697
-}
698
-
699
-func writeJSON(w http.ResponseWriter, v any) {
700
- w.Header().Set("Content-Type", "application/json")
701
- if err := json.NewEncoder(w).Encode(v); err != nil {
702
- log.Error().Err(err).Msg("[HTTP] Failed to encode response")
703
- }
704
-}
705
-
706
-func decodeLeaseID(encoded string) (string, bool) {
707
- idBytes, err := base64.URLEncoding.DecodeString(encoded)
708
- if err != nil {
709
- idBytes, err = base64.RawURLEncoding.DecodeString(encoded)
710
- if err != nil {
711
- return "", false
712
- }
713
- }
714
- return string(idBytes), true
715
-}
cmd/relay-server/frontend.go
+1
-140
@@ -2,17 +2,14 @@ package main
2
3
import (
4
"encoding/json"
5
- "fmt"
5
"html"
6
"io/fs"
7
"net/http"
8
"path"
9
"strings"
10
"sync"
12
- "time"
11
12
"github.com/rs/zerolog/log"
15
- "gosuda.org/portal/cmd/relay-server/manager"
13
"gosuda.org/portal/portal"
14
)
15
@@ -127,7 +124,7 @@ func (f *Frontend) injectServerData(htmlContent string, serv *portal.RelayServer
124
// Get server data from lease manager
125
rows := []leaseRow{}
126
if f.admin != nil {
130
- rows = convertLeaseEntriesToRows(serv, f.admin)
127
+ rows = convertLeaseEntriesToRows(serv, f.admin, false)
128
}
129
130
// Marshal to JSON
@@ -151,142 +148,6 @@ func (f *Frontend) injectServerData(htmlContent string, serv *portal.RelayServer
148
return injected
149
}
150
154
-// convertLeaseEntriesToRows converts LeaseEntry data from LeaseManager to leaseRow format for the app page.
155
-func convertLeaseEntriesToRows(serv *portal.RelayServer, admin *Admin) []leaseRow {
156
- leaseEntries := serv.GetAllLeaseEntries()
157
- rows := []leaseRow{}
158
- now := time.Now()
159
-
160
- bannedList := serv.GetLeaseManager().GetBannedLeases()
161
- bannedMap := make(map[string]struct{}, len(bannedList))
162
- for _, b := range bannedList {
163
- bannedMap[string(b)] = struct{}{}
164
- }
165
-
166
- for _, leaseEntry := range leaseEntries {
167
- if now.After(leaseEntry.Expires) {
168
- continue
169
- }
170
-
171
- lease := leaseEntry.Lease
172
- identityID := lease.ID
173
-
174
- metadata := lease.Metadata
175
-
176
- if _, banned := bannedMap[identityID]; banned {
177
- continue
178
- }
179
-
180
- if admin != nil {
181
- approveManager := admin.GetApproveManager()
182
- if approveManager.GetApprovalMode() == manager.ApprovalModeManual && !approveManager.IsLeaseApproved(identityID) {
183
- continue
184
- }
185
- }
186
-
187
- if metadata.Hide {
188
- continue
189
- }
190
-
191
- ttl := time.Until(leaseEntry.Expires)
192
- ttlStr := ""
193
- if ttl > 0 {
194
- if ttl > time.Hour {
195
- ttlStr = fmt.Sprintf("%.0fh", ttl.Hours())
196
- } else if ttl > time.Minute {
197
- ttlStr = fmt.Sprintf("%.0fm", ttl.Minutes())
198
- } else {
199
- ttlStr = fmt.Sprintf("%.0fs", ttl.Seconds())
200
- }
201
- }
202
-
203
- since := now.Sub(leaseEntry.LastSeen)
204
- if since < 0 {
205
- since = 0
206
- }
207
- lastSeenStr := func(d time.Duration) string {
208
- if d >= time.Hour {
209
- h := int(d / time.Hour)
210
- m := int((d % time.Hour) / time.Minute)
211
- if m > 0 {
212
- return fmt.Sprintf("%dh %dm", h, m)
213
- }
214
- return fmt.Sprintf("%dh", h)
215
- }
216
- if d >= time.Minute {
217
- m := int(d / time.Minute)
218
- s := int((d % time.Minute) / time.Second)
219
- if s > 0 {
220
- return fmt.Sprintf("%dm %ds", m, s)
221
- }
222
- return fmt.Sprintf("%dm", m)
223
- }
224
- return fmt.Sprintf("%ds", int(d/time.Second))
225
- }(since)
226
- lastSeenISO := leaseEntry.LastSeen.UTC().Format(time.RFC3339)
227
- firstSeenISO := leaseEntry.FirstSeen.UTC().Format(time.RFC3339)
228
-
229
- connected := since < 15*time.Second
230
-
231
- if !connected && since >= 3*time.Minute {
232
- continue
233
- }
234
-
235
- name := lease.Name
236
- if name == "" {
237
- name = "(unnamed)"
238
- }
239
-
240
- // Determine protocol based on TLS setting
241
- kind := "http"
242
- if lease.TLSEnabled {
243
- kind = "https"
244
- }
245
-
246
- dnsLabel := identityID
247
- if len(dnsLabel) > 8 {
248
- dnsLabel = dnsLabel[:8] + "..."
249
- }
250
-
251
- link := fmt.Sprintf("//%s.%s/", lease.Name, portalHostPort(flagPortalURL))
252
-
253
- var bps int64
254
- if bpsMgr := admin.GetBPSManager(); bpsMgr != nil {
255
- bps = bpsMgr.GetBPSLimit(identityID)
256
- }
257
-
258
- metadataStr := ""
259
- if b, err := json.Marshal(metadata); err == nil {
260
- metadataStr = string(b)
261
- } else {
262
- log.Warn().Err(err).Str("lease_id", identityID).Msg("[Frontend] Failed to marshal lease metadata")
263
- }
264
-
265
- row := leaseRow{
266
- Peer: identityID,
267
- Name: name,
268
- Kind: kind,
269
- Connected: connected,
270
- DNS: dnsLabel,
271
- LastSeen: lastSeenStr,
272
- LastSeenISO: lastSeenISO,
273
- FirstSeenISO: firstSeenISO,
274
- TTL: ttlStr,
275
- Link: link,
276
- StaleRed: !connected && since >= 15*time.Second,
277
- Hide: leaseEntry.ParsedMetadata != nil && leaseEntry.ParsedMetadata.Hide,
278
- Metadata: metadataStr,
279
- BPS: bps,
280
- }
281
-
282
- if !metadata.Hide {
283
- rows = append(rows, row)
284
- }
285
- }
286
-
287
- return rows
288
-}
289
-
151
// ServeAppStatic serves static files for app UI (React app) from embedded FS.
152
// Falls back to portal.html with SSR when path is root or file not found.
153
func (f *Frontend) ServeAppStatic(w http.ResponseWriter, r *http.Request, appPath string, serv *portal.RelayServer) {
cmd/relay-server/serve.go
+36
-19
@@ -5,7 +5,9 @@ import (
5
"context"
6
"embed"
7
"io"
8
+ "net"
9
"net/http"
10
+ "strconv"
11
"strings"
12
13
"github.com/rs/zerolog/log"
@@ -213,23 +215,38 @@ func proxyToHTTP(w http.ResponseWriter, r *http.Request, serv *portal.RelayServe
215
}
216
}
217
216
-type leaseRow struct {
217
- Peer string
218
- Name string
219
- Kind string
220
- Connected bool
221
- DNS string
222
- LastSeen string
223
- LastSeenISO string
224
- FirstSeenISO string
225
- TTL string
226
- Link string
227
- StaleRed bool
228
- Hide bool
229
- Metadata string
230
- BPS int64 // bytes-per-second limit (0 = unlimited)
231
- IsApproved bool // whether lease is approved (for manual mode)
232
- IsDenied bool // whether lease is denied (for manual mode)
233
- IP string // client IP address (for IP-based ban)
234
- IsIPBanned bool // whether the IP is banned
218
+// redirectToHTTPS redirects the request to HTTPS using the configured SNI port.
219
+func redirectToHTTPS(w http.ResponseWriter, r *http.Request, sniListenAddr string) {
220
+ host := strings.TrimSpace(r.Host)
221
+ if h, _, err := net.SplitHostPort(host); err == nil {
222
+ host = h
223
+ }
224
+
225
+ // Extract port from sniListenAddr (e.g., ":443", "443", "example.com:443")
226
+ port := "443"
227
+ if raw := strings.TrimSpace(sniListenAddr); raw != "" {
228
+ switch {
229
+ case strings.HasPrefix(raw, ":"):
230
+ port = strings.TrimPrefix(raw, ":")
231
+ case strings.Count(raw, ":") == 0:
232
+ port = raw
233
+ default:
234
+ if _, p, err := net.SplitHostPort(raw); err == nil {
235
+ port = p
236
+ }
237
+ }
238
+ if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
239
+ port = "443"
240
+ }
241
+ }
242
+
243
+ if port != "443" {
244
+ host = net.JoinHostPort(host, port)
245
+ }
246
+
247
+ target := "https://" + host + r.URL.Path
248
+ if r.URL.RawQuery != "" {
249
+ target += "?" + r.URL.RawQuery
250
+ }
251
+ http.Redirect(w, r, target, http.StatusMovedPermanently)
252
}
cmd/relay-server/utils.go
+208
-158
@@ -1,65 +1,21 @@
1
package main
2
3
import (
4
+ "encoding/base64"
5
+ "encoding/json"
6
"fmt"
5
- "mime"
7
"net"
8
"net/http"
9
"net/url"
9
- "regexp"
10
- "strconv"
10
"strings"
11
+ "time"
12
13
+ "github.com/rs/zerolog/log"
14
+
15
+ "gosuda.org/portal/cmd/relay-server/manager"
16
"gosuda.org/portal/portal"
17
)
18
16
-// URL-safe name validation regex
17
-var urlSafeNameRegex = regexp.MustCompile(`^[\p{L}\p{N}_-]+$`)
18
-
19
-// isURLSafeName checks if a name contains only URL-safe characters.
20
-func isURLSafeName(name string) bool {
21
- if name == "" {
22
- return true
23
- }
24
- return urlSafeNameRegex.MatchString(name)
25
-}
26
-
27
-// normalizePortalURL takes various user-friendly server inputs and
28
-// converts them into a relay API base URL.
29
-func normalizePortalURL(raw string) (string, error) {
30
- server := strings.TrimSpace(raw)
31
- if server == "" {
32
- return "", fmt.Errorf("bootstrap server is empty")
33
- }
34
-
35
- if !strings.Contains(server, "://") {
36
- server = "http://" + server
37
- }
38
-
39
- u, err := url.Parse(server)
40
- if err != nil {
41
- return "", fmt.Errorf("invalid bootstrap server %q: %w", raw, err)
42
- }
43
- if u.Host == "" {
44
- return "", fmt.Errorf("invalid bootstrap server %q: missing host", raw)
45
- }
46
-
47
- switch u.Scheme {
48
- case "http", "https":
49
- default:
50
- return "", fmt.Errorf("invalid bootstrap server %q: unsupported scheme %q (use http/https)", raw, u.Scheme)
51
- }
52
-
53
- if p := strings.TrimSpace(u.Path); p != "" && p != "/" {
54
- return "", fmt.Errorf("invalid bootstrap server %q: path is not allowed", raw)
55
- }
56
-
57
- u.Path = ""
58
- u.RawQuery = ""
59
- u.Fragment = ""
60
- return strings.TrimSuffix(u.String(), "/"), nil
61
-}
62
-
19
// parseURLs splits a comma-separated string into a list of trimmed, non-empty URLs.
20
func parseURLs(raw string) []string {
21
raw = strings.TrimSpace(raw)
@@ -77,16 +33,6 @@ func parseURLs(raw string) []string {
33
return out
34
}
35
80
-// isHexString reports whether s contains only hexadecimal characters
81
-func isHexString(s string) bool {
82
- for _, c := range s {
83
- if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
84
- return false
85
- }
86
- }
87
- return true
88
-}
89
-
36
// isSubdomain reports whether host matches the given domain pattern.
37
func isSubdomain(domain, host string) bool {
38
if host == "" || domain == "" {
@@ -164,17 +110,22 @@ func defaultBootstrapFrom(base string) string {
110
if base == "" {
111
return "http://localhost:4017"
112
}
167
- if u, err := normalizePortalURL(base); err == nil && u != "" {
168
- return u
113
+
114
+ if !strings.Contains(base, "://") {
115
+ base = "http://" + base
116
}
117
171
- if strings.Contains(base, "://") {
118
+ u, err := url.Parse(strings.TrimSuffix(base, "/"))
119
+ if err != nil || u.Host == "" {
120
return "http://localhost:4017"
121
}
174
- u, err := url.Parse("http://" + strings.TrimSuffix(base, "/"))
175
- if err != nil || u.Host == "" {
122
+ if u.Scheme != "http" && u.Scheme != "https" {
123
return "http://localhost:4017"
124
}
125
+ if p := strings.TrimSpace(u.Path); p != "" && p != "/" {
126
+ return "http://localhost:4017"
127
+ }
128
+
129
u.Path = ""
130
u.RawQuery = ""
131
u.Fragment = ""
@@ -226,18 +177,6 @@ func servicePublicURL(portalURL, serviceName string) string {
177
return fmt.Sprintf("%s://%s.%s", scheme, serviceName, host)
178
}
179
229
-// isHTMLContentType checks if the Content-Type header indicates HTML content
230
-func isHTMLContentType(contentType string) bool {
231
- if contentType == "" {
232
- return false
233
- }
234
- mediaType, _, err := mime.ParseMediaType(contentType)
235
- if err != nil {
236
- return strings.HasPrefix(strings.ToLower(contentType), "text/html")
237
- }
238
- return mediaType == "text/html"
239
-}
240
-
180
// getContentType returns the MIME type for a file extension
181
func getContentType(ext string) string {
182
switch ext {
@@ -271,31 +210,6 @@ func setCORSHeaders(w http.ResponseWriter) {
210
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept, Accept-Encoding")
211
}
212
274
-func isLocalhost(r *http.Request) bool {
275
- host := r.RemoteAddr
276
- if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
277
- host = h
278
- }
279
-
280
- if strings.EqualFold(host, "host.docker.internal") {
281
- return true
282
- }
283
-
284
- ip := net.ParseIP(host)
285
- if ip == nil {
286
- if addrs, err := net.LookupIP(host); err == nil {
287
- for _, a := range addrs {
288
- if a.IsLoopback() || a.IsPrivate() {
289
- return true
290
- }
291
- }
292
- }
293
- return false
294
- }
295
-
296
- return ip.IsLoopback() || ip.IsPrivate()
297
-}
298
-
213
// extractBaseDomain extracts the base domain from a URL.
214
// For example, "https://app.portal.com" -> "portal.com"
215
func extractBaseDomain(portalURL string) string {
@@ -304,7 +218,6 @@ func extractBaseDomain(portalURL string) string {
218
return ""
219
}
220
307
- // Remove scheme if present
221
for _, prefix := range []string{"https://", "http://"} {
222
if strings.HasPrefix(strings.ToLower(portalURL), prefix) {
223
portalURL = portalURL[len(prefix):]
@@ -312,31 +225,25 @@ func extractBaseDomain(portalURL string) string {
225
}
226
}
227
315
- // Remove port if present
228
if idx := strings.Index(portalURL, ":"); idx > 0 {
229
portalURL = portalURL[:idx]
230
}
231
320
- // Remove path if present
232
if idx := strings.Index(portalURL, "/"); idx > 0 {
233
portalURL = portalURL[:idx]
234
}
235
325
- // Remove wildcard if present
236
portalURL = strings.TrimPrefix(portalURL, "*.")
237
328
- // Extract base domain (last two parts)
238
parts := strings.Split(portalURL, ".")
239
if len(parts) < 2 {
240
return ""
241
}
242
334
- // Return last two parts
243
return parts[len(parts)-2] + "." + parts[len(parts)-1]
244
}
245
246
// leaseNameFromHost extracts the lease name from a subdomain host.
339
-// It returns the lease name and true if the host is a valid subdomain of appURL.
247
func leaseNameFromHost(host, appURL string) (string, bool) {
248
if !isSubdomain(appURL, host) {
249
return "", false
@@ -358,81 +265,224 @@ func leaseNameFromHost(host, appURL string) (string, bool) {
265
266
leaseName := strings.TrimSuffix(normalizedHost, suffix)
267
if leaseName == "" || strings.Contains(leaseName, ".") {
361
- // Lease names do not include dots; avoid ambiguous nested subdomains.
268
return "", false
269
}
270
271
return leaseName, true
272
}
273
368
-// redirectToHTTPS redirects the request to HTTPS using configured SNI port.
369
-func redirectToHTTPS(w http.ResponseWriter, r *http.Request, sniListenAddr string) {
370
- targetHost := hostForHTTPSRedirect(r.Host, sniListenAddr)
371
- target := "https://" + targetHost + r.URL.Path
372
- if r.URL.RawQuery != "" {
373
- target += "?" + r.URL.RawQuery
274
+// openLeaseConnection acquires a reverse connection for the given lease ID.
275
+func openLeaseConnection(leaseID string, serv *portal.RelayServer) (net.Conn, func(), error) {
276
+ reverseConn, err := serv.GetReverseHub().AcquireStarted(leaseID, portal.ReverseHTTPWait)
277
+ if err != nil {
278
+ return nil, nil, fmt.Errorf("no reverse connection available for lease %s: %w", leaseID, err)
279
}
375
- http.Redirect(w, r, target, http.StatusMovedPermanently)
280
+ return reverseConn.Conn, reverseConn.Close, nil
281
}
282
378
-func hostForHTTPSRedirect(requestHost, sniListenAddr string) string {
379
- host := strings.TrimSpace(requestHost)
380
- if parsedHost, _, err := net.SplitHostPort(host); err == nil {
381
- host = parsedHost
283
+// withCORSMiddleware wraps a handler with CORS headers.
284
+func withCORSMiddleware(h http.HandlerFunc) http.HandlerFunc {
285
+ return func(w http.ResponseWriter, r *http.Request) {
286
+ setCORSHeaders(w)
287
+ if r.Method == http.MethodOptions {
288
+ w.WriteHeader(http.StatusOK)
289
+ return
290
+ }
291
+ h(w, r)
292
}
293
+}
294
384
- port := tlsPortForRedirect(sniListenAddr)
385
- if port == "443" {
386
- return host
295
+// leaseRow represents a lease entry for display in admin UI and frontend.
296
+type leaseRow struct {
297
+ Peer string
298
+ Name string
299
+ Kind string
300
+ Connected bool
301
+ DNS string
302
+ LastSeen string
303
+ LastSeenISO string
304
+ FirstSeenISO string
305
+ TTL string
306
+ Link string
307
+ StaleRed bool
308
+ Hide bool
309
+ Metadata string
310
+ BPS int64 // bytes-per-second limit (0 = unlimited)
311
+ IsApproved bool // whether lease is approved (for manual mode)
312
+ IsDenied bool // whether lease is denied (for manual mode)
313
+ IP string // client IP address (for IP-based ban)
314
+ IsIPBanned bool // whether the IP is banned
315
+}
316
+
317
+// formatDuration formats a duration for TTL display.
318
+func (leaseRow) formatDuration(d time.Duration) string {
319
+ if d <= 0 {
320
+ return ""
321
+ }
322
+ if d > time.Hour {
323
+ return fmt.Sprintf("%.0fh", d.Hours())
324
+ }
325
+ if d > time.Minute {
326
+ return fmt.Sprintf("%.0fm", d.Minutes())
327
+ }
328
+ return fmt.Sprintf("%.0fs", d.Seconds())
329
+}
330
+
331
+// formatLastSeen formats a duration since last seen.
332
+func (leaseRow) formatLastSeen(d time.Duration) string {
333
+ if d >= time.Hour {
334
+ h := int(d / time.Hour)
335
+ m := int((d % time.Hour) / time.Minute)
336
+ if m > 0 {
337
+ return fmt.Sprintf("%dh %dm", h, m)
338
+ }
339
+ return fmt.Sprintf("%dh", h)
340
+ }
341
+ if d >= time.Minute {
342
+ m := int(d / time.Minute)
343
+ s := int((d % time.Minute) / time.Second)
344
+ if s > 0 {
345
+ return fmt.Sprintf("%dm %ds", m, s)
346
+ }
347
+ return fmt.Sprintf("%dm", m)
348
}
349
+ return fmt.Sprintf("%ds", int(d/time.Second))
350
+}
351
389
- return net.JoinHostPort(host, port)
352
+// isConnected returns true if the lease was seen recently.
353
+func (leaseRow) isConnected(since time.Duration) bool {
354
+ return since < 15*time.Second
355
}
356
392
-func tlsPortForRedirect(sniListenAddr string) string {
393
- raw := strings.TrimSpace(sniListenAddr)
394
- if raw == "" {
395
- return "443"
357
+// fromLeaseEntry populates the leaseRow from a LeaseEntry with common fields.
358
+func (r *leaseRow) fromLeaseEntry(entry *portal.LeaseEntry, admin *Admin, portalURL string) {
359
+ lease := entry.Lease
360
+ identityID := lease.ID
361
+ since := max(time.Since(entry.LastSeen), 0)
362
+ connected := r.isConnected(since)
363
+
364
+ name := lease.Name
365
+ if name == "" {
366
+ name = "(unnamed)"
367
}
368
398
- port := ""
399
- switch {
400
- case strings.HasPrefix(raw, ":"):
401
- port = strings.TrimPrefix(raw, ":")
402
- case strings.Count(raw, ":") == 0:
403
- port = raw
404
- default:
405
- _, parsedPort, err := net.SplitHostPort(raw)
406
- if err != nil {
407
- return "443"
369
+ kind := "http"
370
+ if lease.TLSEnabled {
371
+ kind = "https"
372
+ }
373
+
374
+ dnsLabel := identityID
375
+ if len(dnsLabel) > 8 {
376
+ dnsLabel = dnsLabel[:8] + "..."
377
+ }
378
+
379
+ var bps int64
380
+ if admin != nil {
381
+ if bpsMgr := admin.GetBPSManager(); bpsMgr != nil {
382
+ bps = bpsMgr.GetBPSLimit(identityID)
383
}
409
- port = parsedPort
384
}
385
412
- n, err := strconv.Atoi(port)
413
- if err != nil || n < 1 || n > 65535 {
414
- return "443"
386
+ metadata := lease.Metadata
387
+ metadataStr := ""
388
+ if b, err := json.Marshal(metadata); err == nil {
389
+ metadataStr = string(b)
390
+ } else {
391
+ log.Warn().Err(err).Str("lease_id", identityID).Msg("[leaseRow] Failed to marshal lease metadata")
392
+ }
393
+
394
+ r.Peer = identityID
395
+ r.Name = name
396
+ r.Kind = kind
397
+ r.Connected = connected
398
+ r.DNS = dnsLabel
399
+ r.LastSeen = r.formatLastSeen(since)
400
+ r.LastSeenISO = entry.LastSeen.UTC().Format(time.RFC3339)
401
+ r.FirstSeenISO = entry.FirstSeen.UTC().Format(time.RFC3339)
402
+ r.TTL = r.formatDuration(time.Until(entry.Expires))
403
+ r.Link = fmt.Sprintf("//%s.%s/", lease.Name, portalHostPort(portalURL))
404
+ r.StaleRed = !connected && since >= 15*time.Second
405
+ r.Hide = entry.ParsedMetadata != nil && entry.ParsedMetadata.Hide
406
+ r.Metadata = metadataStr
407
+ r.BPS = bps
408
+
409
+ if admin != nil {
410
+ r.IsApproved = admin.approveManager.GetApprovalMode() == manager.ApprovalModeAuto || admin.approveManager.IsLeaseApproved(identityID)
411
+ r.IsDenied = admin.approveManager.IsLeaseDenied(identityID)
412
+
413
+ if admin.ipManager != nil {
414
+ r.IP = admin.ipManager.GetLeaseIP(identityID)
415
+ if r.IP != "" {
416
+ r.IsIPBanned = admin.ipManager.IsIPBanned(r.IP)
417
+ }
418
+ }
419
}
416
- return port
420
}
421
419
-// openLeaseConnection acquires a reverse connection for the given lease ID.
420
-func openLeaseConnection(leaseID string, serv *portal.RelayServer) (net.Conn, func(), error) {
421
- reverseConn, err := serv.GetReverseHub().AcquireStarted(leaseID, portal.ReverseHTTPWait)
422
- if err != nil {
423
- return nil, nil, fmt.Errorf("no reverse connection available for lease %s: %w", leaseID, err)
422
+// convertLeaseEntriesToRows converts LeaseEntry data to leaseRow format.
423
+// If forAdmin is true, includes all leases with admin-only fields.
424
+// If forAdmin is false, filters out banned, unapproved, hidden, and stale leases.
425
+func convertLeaseEntriesToRows(serv *portal.RelayServer, admin *Admin, forAdmin bool) []leaseRow {
426
+ leaseEntries := serv.GetAllLeaseEntries()
427
+ rows := []leaseRow{}
428
+ now := time.Now()
429
+
430
+ bannedList := serv.GetLeaseManager().GetBannedLeases()
431
+ bannedMap := make(map[string]struct{}, len(bannedList))
432
+ for _, b := range bannedList {
433
+ bannedMap[string(b)] = struct{}{}
434
}
425
- return reverseConn.Conn, reverseConn.Close, nil
435
+
436
+ for _, entry := range leaseEntries {
437
+ if now.After(entry.Expires) {
438
+ continue
439
+ }
440
+
441
+ identityID := entry.Lease.ID
442
+ metadata := entry.Lease.Metadata
443
+
444
+ if !forAdmin {
445
+ if _, banned := bannedMap[identityID]; banned {
446
+ continue
447
+ }
448
+ if admin != nil {
449
+ approveManager := admin.GetApproveManager()
450
+ if approveManager.GetApprovalMode() == manager.ApprovalModeManual && !approveManager.IsLeaseApproved(identityID) {
451
+ continue
452
+ }
453
+ }
454
+ if metadata.Hide {
455
+ continue
456
+ }
457
+ since := max(now.Sub(entry.LastSeen), 0)
458
+ connected := (&leaseRow{}).isConnected(since)
459
+ if !connected && since >= 3*time.Minute {
460
+ continue
461
+ }
462
+ }
463
+
464
+ var row leaseRow
465
+ row.fromLeaseEntry(entry, admin, flagPortalURL)
466
+ rows = append(rows, row)
467
+ }
468
+
469
+ return rows
470
}
471
428
-// withCORSMiddleware wraps a handler with CORS headers.
429
-func withCORSMiddleware(h http.HandlerFunc) http.HandlerFunc {
430
- return func(w http.ResponseWriter, r *http.Request) {
431
- setCORSHeaders(w)
432
- if r.Method == http.MethodOptions {
433
- w.WriteHeader(http.StatusOK)
434
- return
472
+func writeJSON(w http.ResponseWriter, v any) {
473
+ w.Header().Set("Content-Type", "application/json")
474
+ if err := json.NewEncoder(w).Encode(v); err != nil {
475
+ log.Error().Err(err).Msg("[HTTP] Failed to encode response")
476
+ }
477
+}
478
+
479
+func decodeLeaseID(encoded string) (string, bool) {
480
+ idBytes, err := base64.URLEncoding.DecodeString(encoded)
481
+ if err != nil {
482
+ idBytes, err = base64.RawURLEncoding.DecodeString(encoded)
483
+ if err != nil {
484
+ return "", false
485
}
436
- h(w, r)
486
}
487
+ return string(idBytes), true
488
}