1package webterm
2
3import (
4 "errors"
5 "net"
6 "net/netip"
7 "net/url"
8 "strconv"
9 "strings"
10)
11
12// Settings separates the local HTTP listener from the browser-facing origin.
13// HTTPS, when selected, is terminated by a proxy preserving Host and Origin.
14type Settings struct {
15 Listen string `json:"listen"`
16 Origin string `json:"origin"`
17}
18
19func ParseSettings(listen, origin string) (Settings, error) {
20 if listen == "" {
21 listen = Address
22 }
23 addr, err := netip.ParseAddrPort(listen)
24 if err != nil || addr.Port() == 0 || addr.Addr().Zone() != "" || addr.Addr().IsMulticast() {
25 return Settings{}, errors.New("webterm: web.listen requires an IP address and port (1–65535)")
26 }
27 listen = addr.String()
28 if origin == "" {
29 if !addr.Addr().IsLoopback() {
30 return Settings{}, errors.New("webterm: web.origin is required for a non-loopback listener")
31 }
32 origin = "http://" + listen
33 }
34 u, err := url.Parse(origin)
35 if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.Opaque != "" || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(origin, "#") {
36 return Settings{}, errors.New("webterm: web.origin requires an HTTP(S) origin without credentials, path, query or fragment")
37 }
38 host := strings.ToLower(u.Hostname())
39 if ip, err := netip.ParseAddr(host); err == nil {
40 if ip.IsUnspecified() || ip.IsMulticast() || ip.Zone() != "" {
41 return Settings{}, errors.New("webterm: web.origin requires a concrete host")
42 }
43 host = ip.String()
44 } else {
45 if len(host) > 253 {
46 return Settings{}, errors.New("webterm: invalid web.origin hostname")
47 }
48 for _, label := range strings.Split(host, ".") {
49 if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
50 return Settings{}, errors.New("webterm: invalid web.origin hostname")
51 }
52 for _, c := range label {
53 if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') {
54 return Settings{}, errors.New("webterm: invalid web.origin hostname")
55 }
56 }
57 }
58 }
59 port := u.Port()
60 if port != "" {
61 n, err := strconv.Atoi(port)
62 if err != nil || n < 1 || n > 65535 {
63 return Settings{}, errors.New("webterm: invalid web.origin port")
64 }
65 port = strconv.Itoa(n)
66 if u.Scheme == "http" && port == "80" || u.Scheme == "https" && port == "443" {
67 port = ""
68 }
69 } else if strings.HasSuffix(u.Host, ":") {
70 return Settings{}, errors.New("webterm: invalid web.origin port")
71 }
72 if port != "" {
73 host = net.JoinHostPort(host, port)
74 } else if strings.Contains(host, ":") {
75 host = "[" + host + "]"
76 }
77 return Settings{Listen: listen, Origin: u.Scheme + "://" + host}, nil
78}
79
80// ProbeAddress never resolves or contacts the public origin. Wildcard listeners
81// are reached through the matching loopback family on this machine.
82// A zero-value Settings yields an empty address instead of panicking.
83func (s Settings) ProbeAddress() string {
84 addr, err := netip.ParseAddrPort(s.Listen)
85 if err != nil {
86 return ""
87 }
88 if addr.Addr().IsUnspecified() {
89 ip := netip.IPv6Loopback()
90 if addr.Addr().Is4() {
91 ip = netip.MustParseAddr("127.0.0.1")
92 }
93 addr = netip.AddrPortFrom(ip, addr.Port())
94 }
95 return addr.String()
96}
97
98func (s Settings) Host() string {
99 _, host, _ := strings.Cut(s.Origin, "://")
100 return host
101}