// Copyright (C) 2026 Dcntrlized // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package dataplane import ( "io" "net" "net/http" "strings" "time" "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger" "git.dcntrlized.org/Dcntrlized/Maestro/lib/serviceproxy" ) // connectTimeout bounds reaching the service. There is deliberately no overall // client timeout: an action may legitimately take minutes (a scan, a sync, a // long poll). The caller's request context bounds the request, exactly as it // would for any other handler. const connectTimeout = 10 * time.Second // relayClient is the plain-HTTP half of the data plane. A fresh http.Client per // request would leak connection pools; this one keeps them warm across calls. var relayClient = &http.Client{ Transport: &http.Transport{ DialContext: (&net.Dialer{Timeout: connectTimeout, KeepAlive: 30 * time.Second}).DialContext, MaxIdleConns: 64, MaxIdleConnsPerHost: 8, IdleConnTimeout: 90 * time.Second, ExpectContinueTimeout: 1 * time.Second, }, // Mirror the service's redirects instead of following them: a 302 from a // service is an answer to the caller, not an instruction to Maestro. CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } // relayHTTP forwards one request to the service and mirrors the answer. func relayHTTP(resolve Resolver, service, upstreamPath string, w http.ResponseWriter, r *http.Request) { peer, ok := resolvePeer(resolve, service, w, r) if !ok { return } // The query string is forwarded verbatim (tokens live there and re-encoding // would corrupt them). target, err := serviceproxy.Endpoint(peer.BaseURL, upstreamPath, r.URL.RawQuery) if err != nil { writeUnusableAddress(w, service, err) return } req, err := http.NewRequestWithContext(r.Context(), r.Method, target, r.Body) if err != nil { logger.Debug("Failed to build relay request for %q: %v", service, err) writeAPIError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to build the request for service "+service) return } req.ContentLength = r.ContentLength req.Header = forwardableRequestHeaders(r.Header) resp, err := relayClient.Do(req) if err != nil { writeUpstreamUnreachable(w, service, err) return } defer func() { _ = resp.Body.Close() }() // The service's status, headers and body ARE the answer: mirrored, never // re-shaped and never enveloped (see the package doc). Only Maestro's own // failures above use the envelope. copyResponseHeaders(w.Header(), resp.Header) w.WriteHeader(resp.StatusCode) if _, err := io.Copy(w, resp.Body); err != nil { logger.Debug("Relay of service %q response to client failed: %v", service, err) } } // forwardableRequestHeaders copies the caller's headers, minus the ones a relay // must never forward. func forwardableRequestHeaders(src http.Header) http.Header { dst := http.Header{} for name, values := range src { if !forwardableRequestHeader(name) { continue } for _, value := range values { dst.Add(name, value) } } return dst } // forwardableRequestHeader is a SECURITY decision, not a convenience. // // Hop-by-hop headers are the relay's own business. Cookie and Authorization are // excluded deliberately twice over: this surface is not owner-gated, so a Maestro // session cookie must never ride into a service, and services authenticate with // their own tokens (X-Access-Token / their own Cookie issued by themselves, which // this relay can never forward because it never forwards cookies at all). // Sec-WebSocket-* is set by the WebSocket dialer itself; forwarding a caller's key // would corrupt the handshake. func forwardableRequestHeader(name string) bool { switch strings.ToLower(name) { case "host", "content-length", "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "cookie", "authorization": return false } return !strings.HasPrefix(strings.ToLower(name), "sec-websocket-") } // copyResponseHeaders mirrors the service's response headers, minus hop-by-hop // ones and Set-Cookie: that cookie would be scoped to Maestro's own origin, so // mirroring it would let any service write cookies onto the console's origin. func copyResponseHeaders(dst, src http.Header) { for name, values := range src { if !forwardableResponseHeader(name) { continue } for _, value := range values { dst.Add(name, value) } } } func forwardableResponseHeader(name string) bool { switch strings.ToLower(name) { case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "set-cookie": return false } return true }