main
go 555 lines 12.5 KB
Raw
1 package utils
2
3 import (
4 "context"
5 "crypto/rand"
6 "crypto/sha256"
7 "encoding/base64"
8 "encoding/hex"
9 "errors"
10 "fmt"
11 "net"
12 "net/url"
13 "path"
14 "strings"
15 "time"
16 "unicode"
17
18 "golang.org/x/net/idna"
19
20 "github.com/gosuda/portal-tunnel/v2/types"
21 )
22
23 func SplitCSV(raw string) []string {
24 if strings.TrimSpace(raw) == "" {
25 return nil
26 }
27
28 parts := strings.Split(raw, ",")
29 out := make([]string, 0, len(parts))
30 for _, part := range parts {
31 part = strings.TrimSpace(part)
32 if part != "" {
33 out = append(out, part)
34 }
35 }
36 return out
37 }
38
39 func TrimHexPrefix(raw string) string {
40 if len(raw) >= 2 && raw[0] == '0' && (raw[1] == 'x' || raw[1] == 'X') {
41 return raw[2:]
42 }
43 return raw
44 }
45
46 func ParseCIDRs(raw string) ([]*net.IPNet, error) {
47 parts := SplitCSV(raw)
48 if len(parts) == 0 {
49 return nil, nil
50 }
51
52 cidrs := make([]*net.IPNet, 0, len(parts))
53 seen := make(map[string]struct{}, len(parts))
54 for _, part := range parts {
55 _, network, err := net.ParseCIDR(part)
56 if err != nil {
57 return nil, fmt.Errorf("invalid cidr %q: %w", part, err)
58 }
59 key := network.String()
60 if _, ok := seen[key]; ok {
61 continue
62 }
63 seen[key] = struct{}{}
64 cidrs = append(cidrs, network)
65 }
66 return cidrs, nil
67 }
68
69 func NormalizeDNSLabel(raw string) (string, error) {
70 label := sanitizeDNSLabelInput(raw)
71 if label == "" {
72 return "", errors.New("name is required")
73 }
74
75 if !isPlainDNSLabel(label) {
76 ascii, err := idna.Lookup.ToASCII(label)
77 if err != nil {
78 return "", errors.New("name is invalid")
79 }
80 label = NormalizeHostname(ascii)
81 }
82 if strings.Contains(label, ".") {
83 return "", errors.New("name must be a single dns label")
84 }
85 if len(label) > 63 {
86 return "", errors.New("name must be 63 characters or fewer")
87 }
88 if label[0] == '-' || label[len(label)-1] == '-' {
89 return "", errors.New("name must not start or end with hyphen")
90 }
91 for _, r := range label {
92 if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
93 continue
94 }
95 return "", errors.New("name must contain only letters, numbers, or hyphen")
96 }
97 return label, nil
98 }
99
100 func sanitizeDNSLabelInput(raw string) string {
101 input := strings.TrimSpace(strings.ToLower(raw))
102 if input == "" {
103 return ""
104 }
105
106 var b strings.Builder
107 b.Grow(len(input))
108 previousHyphen := false
109
110 for _, r := range input {
111 if r == '-' || unicode.IsLetter(r) || unicode.IsDigit(r) {
112 b.WriteRune(r)
113 previousHyphen = false
114 continue
115 }
116 if previousHyphen {
117 continue
118 }
119 b.WriteByte('-')
120 previousHyphen = true
121 }
122
123 return strings.Trim(b.String(), "-")
124 }
125
126 func isPlainDNSLabel(label string) bool {
127 for _, r := range label {
128 if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
129 continue
130 }
131 return false
132 }
133 return true
134 }
135
136 func NormalizeRelayURL(raw string) (string, error) {
137 trimmed := strings.TrimSpace(raw)
138 if trimmed == "" {
139 return "", errors.New("relay url is empty")
140 }
141 if !strings.Contains(trimmed, "://") {
142 trimmed = "https://" + strings.TrimPrefix(trimmed, "//")
143 }
144
145 parsed, err := url.Parse(trimmed)
146 if err != nil {
147 return "", fmt.Errorf("parse relay url %q: %w", raw, err)
148 }
149 if parsed.Host == "" && parsed.Path != "" && !strings.Contains(parsed.Path, "/") {
150 parsed, err = url.Parse("https://" + strings.TrimSpace(parsed.Path))
151 if err != nil {
152 return "", fmt.Errorf("parse relay url %q: %w", raw, err)
153 }
154 }
155 if parsed.Host == "" {
156 return "", fmt.Errorf("relay url host is empty: %q", raw)
157 }
158 if !strings.EqualFold(parsed.Scheme, "https") {
159 return "", fmt.Errorf("relay url must use https: %q", raw)
160 }
161
162 parsed.RawQuery = ""
163 parsed.Fragment = ""
164 parsed.Path = strings.TrimRight(parsed.Path, "/")
165 if strings.HasSuffix(strings.ToLower(parsed.Path), "/relay") {
166 parsed.Path = strings.TrimSuffix(parsed.Path, "/relay")
167 }
168 return parsed.String(), nil
169 }
170
171 func PortalRootHost(portalURL string) string {
172 u, err := url.Parse(strings.TrimSpace(portalURL))
173 if err != nil || u.Host == "" {
174 return ""
175 }
176 return NormalizeHostname(u.Hostname())
177 }
178
179 func NormalizeHostname(host string) string {
180 host = strings.TrimSpace(strings.ToLower(host))
181 host = strings.TrimSuffix(host, ".")
182 return host
183 }
184
185 func NormalizeBaseDomain(domain string) string {
186 return strings.TrimPrefix(NormalizeHostname(domain), "*.")
187 }
188
189 func DomainCandidates(domain string) []string {
190 normalized := NormalizeHostname(domain)
191 parts := strings.Split(normalized, ".")
192 if len(parts) < 2 {
193 return nil
194 }
195
196 candidates := make([]string, 0, len(parts)-1)
197 for i := range len(parts) - 1 {
198 candidates = append(candidates, strings.Join(parts[i:], "."))
199 }
200 return candidates
201 }
202
203 func HostnameMatchesBaseDomain(hostname, baseDomain string) bool {
204 hostname = NormalizeHostname(hostname)
205 baseDomain = NormalizeBaseDomain(baseDomain)
206 if hostname == "" || baseDomain == "" {
207 return false
208 }
209 return hostname == baseDomain || strings.HasSuffix(hostname, "."+baseDomain)
210 }
211
212 func HostnameMatchesPattern(pattern, hostname string) bool {
213 pattern = NormalizeHostname(pattern)
214 hostname = NormalizeHostname(hostname)
215 if pattern == "" || hostname == "" {
216 return false
217 }
218 if pattern == hostname {
219 return true
220 }
221 if !strings.HasPrefix(pattern, "*.") {
222 return false
223 }
224 suffix := strings.TrimPrefix(pattern, "*.")
225 if !strings.Contains(suffix, ".") {
226 return false
227 }
228 _, rest, ok := strings.Cut(hostname, ".")
229 return ok && rest == suffix
230 }
231
232 func HostnameHash(hostname string) string {
233 hostname = NormalizeHostname(hostname)
234 if hostname == "" {
235 return ""
236 }
237 sum := sha256.Sum256([]byte("portal hostname hash v1\x00" + hostname))
238 return base64.RawURLEncoding.EncodeToString(sum[:])
239 }
240
241 func NormalizeChildHostnames(inputs []string, baseDomain string) []string {
242 if len(inputs) == 0 {
243 return nil
244 }
245
246 baseDomain = NormalizeBaseDomain(baseDomain)
247 return normalizeUniqueStrings(inputs, func(input string) string {
248 hostname := NormalizeHostname(input)
249 if hostname == "" || hostname == baseDomain || !HostnameMatchesBaseDomain(hostname, baseDomain) {
250 return ""
251 }
252 return hostname
253 })
254 }
255
256 func NormalizeURLPath(raw string) string {
257 clean := path.Clean(strings.TrimSpace(raw))
258 if clean == "." || clean == "" {
259 return "/"
260 }
261 if !strings.HasPrefix(clean, "/") {
262 clean = "/" + clean
263 }
264 // Prevent scheme-relative or otherwise ambiguous paths like "//example" or "/\example".
265 if len(clean) > 1 && (clean[1] == '/' || clean[1] == '\\') {
266 clean = "/"
267 }
268 if clean != "/" {
269 clean = strings.TrimSuffix(clean, "/")
270 }
271 return clean
272 }
273
274 func NormalizeRelayURLs(inputs ...string) ([]string, error) {
275 out := make([]string, 0, len(inputs))
276
277 for _, input := range inputs {
278 for _, part := range SplitCSV(input) {
279 normalized, err := NormalizeRelayURL(part)
280 if err != nil {
281 return nil, err
282 }
283 out = append(out, normalized)
284 }
285 }
286
287 return normalizeUniqueStrings(out, strings.TrimSpace), nil
288 }
289
290 func FilterRelayURLs(inputs, excluded []string) []string {
291 if len(inputs) == 0 {
292 return nil
293 }
294 if len(excluded) == 0 {
295 return append([]string(nil), inputs...)
296 }
297
298 skip := make(map[string]struct{}, len(excluded))
299 for _, input := range excluded {
300 input = strings.TrimSpace(input)
301 if input == "" {
302 continue
303 }
304 skip[input] = struct{}{}
305 }
306
307 filtered := make([]string, 0, len(inputs))
308 for _, input := range inputs {
309 input = strings.TrimSpace(input)
310 if input == "" {
311 continue
312 }
313 if _, ok := skip[input]; ok {
314 continue
315 }
316 filtered = append(filtered, input)
317 }
318 if len(filtered) == 0 {
319 return nil
320 }
321 return filtered
322 }
323
324 func RemoveRelayURL(inputs []string, target string) []string {
325 if len(inputs) == 0 {
326 return nil
327 }
328
329 target = strings.TrimSpace(target)
330 if target == "" {
331 return append([]string(nil), inputs...)
332 }
333
334 filtered := make([]string, 0, len(inputs))
335 for _, input := range inputs {
336 input = strings.TrimSpace(input)
337 if input == "" || input == target {
338 continue
339 }
340 filtered = append(filtered, input)
341 }
342 if len(filtered) == 0 {
343 return nil
344 }
345 return filtered
346 }
347
348 func MergeRelayURLs(current, excluded, inputs []string) ([]string, error) {
349 merged, err := NormalizeRelayURLs(append(append([]string(nil), current...), inputs...)...)
350 if err != nil {
351 return nil, err
352 }
353 if len(excluded) == 0 {
354 return merged, nil
355 }
356
357 excluded, err = NormalizeRelayURLs(excluded...)
358 if err != nil {
359 return nil, err
360 }
361
362 return FilterRelayURLs(merged, excluded), nil
363 }
364
365 func ResolvePortalRelayURLs(explicit []string, includeBootstrap bool) ([]string, error) {
366 explicit, err := NormalizeRelayURLs(explicit...)
367 if err != nil {
368 return nil, err
369 }
370 if !includeBootstrap {
371 return explicit, nil
372 }
373
374 defaults, err := NormalizeRelayURLs(types.BootstrapRelays...)
375 if err != nil {
376 return explicit, nil
377 }
378 if len(defaults) == 0 {
379 return explicit, nil
380 }
381 return MergeRelayURLs(defaults, nil, explicit)
382 }
383
384 func ExcludeLocalRelayURLs(inputs ...string) ([]string, error) {
385 normalized, err := NormalizeRelayURLs(inputs...)
386 if err != nil {
387 return nil, err
388 }
389 if len(normalized) == 0 {
390 return nil, nil
391 }
392
393 filtered := normalized[:0]
394 for _, input := range normalized {
395 parsed, err := url.Parse(input)
396 if err != nil {
397 return nil, fmt.Errorf("parse relay url %q: %w", input, err)
398 }
399 if IsLocalRelayHost(parsed.Hostname()) {
400 continue
401 }
402 filtered = append(filtered, input)
403 }
404 if len(filtered) == 0 {
405 return nil, nil
406 }
407 return filtered, nil
408 }
409
410 func LeaseHostname(name, rootHost string) (string, error) {
411 label, err := NormalizeDNSLabel(name)
412 if err != nil {
413 return "", err
414 }
415 rootHost = NormalizeHostname(rootHost)
416 if rootHost == "" {
417 return "", errors.New("root host is required")
418 }
419 return label + "." + rootHost, nil
420 }
421
422 func DecodeBase64URLString(encoded string) (string, error) {
423 decoded, err := base64.URLEncoding.DecodeString(encoded)
424 if err == nil {
425 return string(decoded), nil
426 }
427
428 decoded, err = base64.RawURLEncoding.DecodeString(encoded)
429 if err != nil {
430 return "", err
431 }
432 return string(decoded), nil
433 }
434
435 func NormalizeTargetAddr(raw string) (string, error) {
436 raw = strings.TrimSpace(raw)
437 if raw == "" {
438 return "", errors.New("target address is required")
439 }
440
441 if strings.Contains(raw, "://") {
442 targetURL, err := url.Parse(raw)
443 if err != nil {
444 return "", fmt.Errorf("parse target url: %w", err)
445 }
446 if !strings.EqualFold(targetURL.Scheme, "http") && !strings.EqualFold(targetURL.Scheme, "https") {
447 return "", fmt.Errorf("unsupported target url scheme %q", targetURL.Scheme)
448 }
449 if targetURL.Host == "" {
450 return "", errors.New("target url host is empty")
451 }
452 if targetURL.Path != "" && targetURL.Path != "/" {
453 return "", errors.New("target url path is not supported")
454 }
455 if targetURL.RawQuery != "" {
456 return "", errors.New("target url query is not supported")
457 }
458 if targetURL.Fragment != "" {
459 return "", errors.New("target url fragment is not supported")
460 }
461 raw = targetURL.Host
462 }
463
464 if _, _, err := net.SplitHostPort(raw); err == nil {
465 return raw, nil
466 }
467 if strings.Count(raw, ":") == 0 {
468 return net.JoinHostPort(raw, "80"), nil
469 }
470 if ip := net.ParseIP(raw); ip != nil {
471 return net.JoinHostPort(raw, "80"), nil
472 }
473 return "", fmt.Errorf("invalid target address %q", raw)
474 }
475
476 func HostPortOrLoopback(addr string) string {
477 host, port, err := net.SplitHostPort(addr)
478 if err != nil {
479 return addr
480 }
481 if host == "" || host == "::" || host == "0.0.0.0" {
482 host = "127.0.0.1"
483 }
484 return net.JoinHostPort(host, port)
485 }
486
487 func EnsurePort(host string) string {
488 if _, _, err := net.SplitHostPort(host); err == nil {
489 return host
490 }
491 return net.JoinHostPort(host, "443")
492 }
493
494 func IsLocalRelayHost(host string) bool {
495 host = NormalizeHostname(host)
496 switch host {
497 case "", "localhost":
498 return true
499 }
500 if ip := net.ParseIP(host); ip != nil {
501 return ip.IsLoopback()
502 }
503 return strings.HasSuffix(host, ".localhost")
504 }
505
506 func ValidateIPv4(raw string) error {
507 ip := net.ParseIP(strings.TrimSpace(raw))
508 if ip == nil || ip.To4() == nil {
509 return fmt.Errorf("invalid ipv4 address: %q", raw)
510 }
511 return nil
512 }
513
514 func SleepOrDone(ctx context.Context, d time.Duration) bool {
515 timer := time.NewTimer(d)
516 defer timer.Stop()
517 select {
518 case <-ctx.Done():
519 return false
520 case <-timer.C:
521 return true
522 }
523 }
524
525 func RandomID(prefix string) string {
526 buf := make([]byte, 8)
527 if _, err := rand.Read(buf); err != nil {
528 panic(err)
529 }
530 return prefix + hex.EncodeToString(buf)
531 }
532
533 func normalizeUniqueStrings(inputs []string, normalize func(string) string) []string {
534 if len(inputs) == 0 {
535 return nil
536 }
537
538 out := make([]string, 0, len(inputs))
539 seen := make(map[string]struct{}, len(inputs))
540 for _, input := range inputs {
541 normalized := normalize(input)
542 if normalized == "" {
543 continue
544 }
545 if _, ok := seen[normalized]; ok {
546 continue
547 }
548 seen[normalized] = struct{}{}
549 out = append(out, normalized)
550 }
551 if len(out) == 0 {
552 return nil
553 }
554 return out
555 }