package sandbox import ( "context" "encoding/json" "fmt" "io" "net" "net/http" "os" "strings" "sync" "time" "gitea.app.monadical.io/monadical/greywall/internal/config" ) // FilteringProxy is an HTTP CONNECT proxy that filters outbound connections by domain. // It runs on the host and is the only outbound target the sandbox allows. type FilteringProxy struct { listener net.Listener server *http.Server network *config.NetworkConfig debug bool mu sync.Mutex closed bool } // NewFilteringProxy creates and starts a new domain-filtering HTTP proxy. // It listens on 127.0.0.1 with a random available port. func NewFilteringProxy(network *config.NetworkConfig, debug bool) (*FilteringProxy, error) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return nil, fmt.Errorf("failed to listen: %w", err) } fp := &FilteringProxy{ listener: listener, network: network, debug: debug, } fp.server = &http.Server{ Handler: http.HandlerFunc(fp.serveHTTP), ReadHeaderTimeout: 30 * time.Second, } go func() { if err := fp.server.Serve(listener); err != nil && err != http.ErrServerClosed { fp.logDebug("Proxy server error: %v", err) } }() if debug { fmt.Fprintf(os.Stderr, "[greywall:proxy] Filtering proxy started on %s\n", listener.Addr().String()) } return fp, nil } func (fp *FilteringProxy) serveHTTP(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodConnect { fp.handleConnect(w, r) } else if r.Method == http.MethodGet && r.URL.Path == "/__greywall_dns" { fp.handleDNS(w, r) } else { fp.handleHTTP(w, r) } } // handleDNS resolves a hostname and returns the IP addresses as JSON. // Used by the Node.js bootstrap to patch dns.lookup inside the sandbox. func (fp *FilteringProxy) handleDNS(w http.ResponseWriter, r *http.Request) { host := r.URL.Query().Get("host") if host == "" { w.Header().Set("Content-Type", "application/json") http.Error(w, `{"error":"missing host parameter"}`, http.StatusBadRequest) return } if !fp.network.IsDomainAllowed(host) { fp.logDenied(host) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) fmt.Fprintf(w, `{"error":"domain denied: %s"}`, host) return } addrs, err := net.LookupHost(host) if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) fmt.Fprintf(w, `{"error":"%s"}`, err.Error()) return } type addrEntry struct { Address string `json:"address"` Family int `json:"family"` } var entries []addrEntry for _, addr := range addrs { family := 4 if strings.Contains(addr, ":") { family = 6 } entries = append(entries, addrEntry{Address: addr, Family: family}) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{ "addresses": entries, }) } func (fp *FilteringProxy) handleConnect(w http.ResponseWriter, r *http.Request) { host := extractHost(r.Host) if !fp.network.IsDomainAllowed(host) { fp.logDenied(host) http.Error(w, fmt.Sprintf("[greywall] domain denied: %s", host), http.StatusForbidden) return } // Dial the target target := r.Host if !strings.Contains(target, ":") { target = target + ":443" } destConn, err := net.DialTimeout("tcp", target, 10*time.Second) if err != nil { http.Error(w, fmt.Sprintf("[greywall] failed to connect to %s: %v", target, err), http.StatusBadGateway) return } // Hijack the client connection hijacker, ok := w.(http.Hijacker) if !ok { destConn.Close() http.Error(w, "[greywall] hijacking not supported", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) clientConn, _, err := hijacker.Hijack() if err != nil { destConn.Close() return } // Bidirectional copy go func() { defer destConn.Close() defer clientConn.Close() _, _ = io.Copy(destConn, clientConn) }() go func() { defer destConn.Close() defer clientConn.Close() _, _ = io.Copy(clientConn, destConn) }() } func (fp *FilteringProxy) handleHTTP(w http.ResponseWriter, r *http.Request) { host := extractHost(r.Host) if !fp.network.IsDomainAllowed(host) { fp.logDenied(host) http.Error(w, fmt.Sprintf("[greywall] domain denied: %s", host), http.StatusForbidden) return } // Forward the request r.RequestURI = "" resp, err := http.DefaultTransport.RoundTrip(r) if err != nil { http.Error(w, fmt.Sprintf("[greywall] failed to forward request: %v", err), http.StatusBadGateway) return } defer resp.Body.Close() // Copy response headers for key, values := range resp.Header { for _, value := range values { w.Header().Add(key, value) } } w.WriteHeader(resp.StatusCode) _, _ = io.Copy(w, resp.Body) } // Addr returns the listener address as a string (e.g. "127.0.0.1:12345"). func (fp *FilteringProxy) Addr() string { return fp.listener.Addr().String() } // Port returns the listener port as a string. func (fp *FilteringProxy) Port() string { _, port, _ := net.SplitHostPort(fp.listener.Addr().String()) return port } // Shutdown gracefully stops the proxy. func (fp *FilteringProxy) Shutdown() { fp.mu.Lock() defer fp.mu.Unlock() if fp.closed { return } fp.closed = true ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = fp.server.Shutdown(ctx) if fp.debug { fmt.Fprintf(os.Stderr, "[greywall:proxy] Filtering proxy stopped\n") } } func (fp *FilteringProxy) logDenied(host string) { fmt.Fprintf(os.Stderr, "\033[31m[greywall:proxy] domain denied: %s\033[0m\n", host) } func (fp *FilteringProxy) logDebug(format string, args ...interface{}) { if fp.debug { fmt.Fprintf(os.Stderr, "[greywall:proxy] "+format+"\n", args...) } } // extractHost extracts the hostname from a host:port string, stripping the port. func extractHost(hostport string) string { host, _, err := net.SplitHostPort(hostport) if err != nil { // No port present return hostport } return host }