package main import ( "bufio" "fmt" "os" "regexp" "sort" "strconv" "strings" ) const ( // Every .domain entry resolves here; exact hosts get 127.1.x.y of their own. wildcardIP = "127.0.0.2" portBase = 10000 ) var hostRE = regexp.MustCompile(`^\.?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$`) // entry is one allowlist line; port is empty for plain web (80/443) access. type entry struct{ host, port string } // parseAllowlist returns the entries of allowlist file path, sorted bytewise // on "host port" and unique. func parseAllowlist(path string) ([]entry, error) { f, err := os.Open(path) if err != nil { return nil, err } defer func() { _ = f.Close() }() seen := map[string]bool{} var keys []string sc := bufio.NewScanner(f) for n := 1; sc.Scan(); n++ { line, _, _ := strings.Cut(sc.Text(), "#") fields := strings.Fields(line) if len(fields) == 0 { continue } if len(fields) > 1 { return nil, fmt.Errorf("%s:%d: one entry per line", path, n) } line = strings.ToLower(fields[0]) host, port := line, "" if i := strings.LastIndex(line, ":"); i >= 0 { host, port = line[:i], line[i+1:] } if !hostRE.MatchString(host) { return nil, fmt.Errorf("%s:%d: invalid host '%s'", path, n, host) } if port != "" { p, err := strconv.Atoi(port) if err != nil || strings.ContainsAny(port, "+-") || p < 1 || p > 65535 { return nil, fmt.Errorf("%s:%d: invalid port '%s'", path, n, port) } if strings.HasPrefix(host, ".") { return nil, fmt.Errorf("%s:%d: ports need an exact host, not '%s'", path, n, host) } port = strconv.Itoa(p) if p == 80 || p == 443 { port = "" } } if k := host + " " + port; !seen[k] { seen[k] = true keys = append(keys, k) } } if err := sc.Err(); err != nil { return nil, err } sort.Strings(keys) entries := make([]entry, len(keys)) for i, k := range keys { entries[i].host, entries[i].port, _ = strings.Cut(k, " ") } return entries, nil } // routerConfig renders dnsmasq.conf and nginx.conf for the router listening on // rip, and the sandbox's loopback forwards as "bind:port:router:router_port" // words. func routerConfig(entries []entry, rip string) (dnsmasq, nginx, forwards string) { var hosts []string isHost := map[string]bool{} for _, e := range entries { if !isHost[e.host] { isHost[e.host] = true hosts = append(hosts, e.host) } } ip := map[string]string{} i := 0 for _, h := range hosts { if !strings.HasPrefix(h, ".") { ip[h] = fmt.Sprintf("127.1.%d.%d", i/250, i%250+1) i++ } } var tcp strings.Builder fwd := []string{"0.0.0.0:443:" + rip + ":443", "0.0.0.0:80:" + rip + ":80"} j := 0 for _, e := range entries { if e.port == "" { continue } fmt.Fprintf(&tcp, " server { listen %s:%d; set $up %s:%s; proxy_pass $up; }\n", rip, portBase+j, e.host, e.port) fwd = append(fwd, fmt.Sprintf("%s:%s:%s:%d", ip[e.host], e.port, rip, portBase+j)) j++ } var d strings.Builder fmt.Fprintf(&d, "no-resolv\nno-hosts\nbind-interfaces\nlisten-address=%s\naddress=/#/\n", rip) for _, h := range hosts { if strings.HasPrefix(h, ".") { fmt.Fprintf(&d, "address=/%s/%s\n", h[1:], wildcardIP) } else { fmt.Fprintf(&d, "host-record=%s,%s\n", h, ip[h]) } } // nginx "hostnames" rejects an exact key that a .domain key already covers. var m strings.Builder for _, h := range hosts { if !strings.HasPrefix(h, ".") && isHost["."+h] { continue } fmt.Fprintf(&m, " %s 1;\n", h) } nginx = strings.NewReplacer("@RIP@", rip, "@MAP@", m.String(), "@TCP@", tcp.String()).Replace(nginxTemplate) return d.String(), nginx, strings.Join(fwd, " ") } // @RESOLVER@ is filled in by the router's start.sh. const nginxTemplate = `load_module /usr/lib/nginx/modules/ngx_stream_module.so; worker_processes 1; pid /tmp/nginx.pid; error_log stderr warn; events { worker_connections 1024; } stream { resolver @RESOLVER@ ipv6=off valid=30s; map $ssl_preread_server_name $tls_allowed { hostnames; default 0; @MAP@ } map $tls_allowed $tls_upstream { 1 $ssl_preread_server_name:443; default 127.0.0.1:9; } server { listen @RIP@:443; ssl_preread on; proxy_pass $tls_upstream; } @TCP@} http { resolver @RESOLVER@ ipv6=off valid=30s; access_log off; map $host $http_allowed { hostnames; default 0; @MAP@ } server { listen @RIP@:80; if ($http_allowed = 0) { return 403; } location / { proxy_pass http://$host; proxy_set_header Host $host; } } } `