[feat](maestro): proxy one data plane instead of per-service routes
Apps and phones reach services through the instance, so only one port per instance needs to be open and the service answers its own 401. Add handlers/dataplane: one allowlist (/api/{service}/action/{action}, /auth/{path...}, /ws[/...]) and one dispatcher that chooses HTTP or WebSocket by upgrade presence, outside the owner gate because callers hold no Maestro session. Both it and the legacy action proxy resolve through lib/serviceproxy, so the two edges cannot disagree about a service address.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user