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 }