[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:
@@ -17,15 +17,14 @@ package actions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/authmgmt"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/serviceproxy"
|
||||
)
|
||||
|
||||
func RunAction(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -35,43 +34,31 @@ func RunAction(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
logger.Debug("Received request to run action %s on container id %s", actionName, containerId)
|
||||
|
||||
// Get IP address of the container using containerId
|
||||
dockerClient, err := docker.NewDockerClient()
|
||||
// Resolve "where is this service" through the shared helper — the WebSocket
|
||||
// data plane (/api/{service}/ws) resolves through the same code, so the two
|
||||
// edges can never disagree about a service's address. The status codes and
|
||||
// messages below are the pre-refactor ones: only the lookup moved.
|
||||
baseURL, err := serviceproxy.Target(r.Context(), containerId)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to create Docker client", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
inspectData, err := docker.ResolveContainer(context.Background(), dockerClient, containerId)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to inspect container", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
network, _, err := docker.ExtractNetworkInfo(context.Background(), dockerClient, inspectData)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to extract network info", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// The service port can be declared via the org.dcntrlized.port label. Fall back to the
|
||||
// first mapped guest port when it is not set.
|
||||
port := inspectData.Config.Labels["org.dcntrlized.port"]
|
||||
if port == "" && len(network.Ports) > 0 {
|
||||
port = fmt.Sprintf("%d", network.Ports[0].Guest)
|
||||
}
|
||||
if port == "" {
|
||||
// Same status (500) and same message text as before the refactor; the real
|
||||
// cause now goes to the debug log instead of being folded into the message.
|
||||
logger.Debug("Failed to resolve container %s: %v", containerId, err)
|
||||
switch {
|
||||
case errors.Is(err, serviceproxy.ErrNoPort):
|
||||
http.Error(w, "Container has no configured service port", http.StatusInternalServerError)
|
||||
case errors.Is(err, serviceproxy.ErrDockerClient):
|
||||
http.Error(w, "Failed to create Docker client", http.StatusInternalServerError)
|
||||
case errors.Is(err, serviceproxy.ErrNoNetwork):
|
||||
http.Error(w, "Failed to extract network info", http.StatusInternalServerError)
|
||||
default:
|
||||
http.Error(w, "Failed to inspect container", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Construct the URL for the action endpoint, preserving the query string.
|
||||
// The service peer edge is mutual TLS (M2 §2): Maestro presents its own
|
||||
// identity cert and the service's listener requires a CA-issued client
|
||||
// cert, so only Maestro can drive actions (T4). In dev mode
|
||||
// (DCNTRLIZED_DEV=true) the satellites keep a plaintext listener for the
|
||||
// RN clients, so the action edge stays plaintext there too.
|
||||
actionURL := fmt.Sprintf("%s://%s:%s/action/%s", authmgmt.Scheme(), network.IPAddress.Address, port, actionName)
|
||||
// Rev 9: the service edge is plaintext HTTP on the single :8080 listener.
|
||||
actionURL := fmt.Sprintf("%s/action/%s", baseURL, actionName)
|
||||
if r.URL.RawQuery != "" {
|
||||
actionURL += "?" + r.URL.RawQuery
|
||||
}
|
||||
@@ -99,16 +86,8 @@ func RunAction(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Maestro's own identity (mTLS) as the peer; plaintext in dev mode.
|
||||
transport, err := authmgmt.PeerTransport()
|
||||
if err != nil {
|
||||
logger.Debug("Failed to build peer transport for action %s on container %s: %v", actionName, containerId, err)
|
||||
http.Error(w, "Failed to build peer transport", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Forward the request to the container's action endpoint
|
||||
resp, err := (&http.Client{Transport: transport}).Do(req)
|
||||
// Forward the request to the container's action endpoint (plaintext).
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
logger.Debug("Failed to forward action request %s to container %s: %v", actionName, containerId, err)
|
||||
http.Error(w, "Failed to forward request to container", http.StatusInternalServerError)
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
// 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 is Maestro's single data plane for every installed service.
|
||||
//
|
||||
// The model (user ruling, 2026-10-18): requests to a service are always expected
|
||||
// to be routed through Maestro, and may well be unauthenticated. Only one port
|
||||
// per instance therefore needs to be open, and setup stays simple. A service sets
|
||||
// up its own auth on top (typically via dcntrlized-auth); Maestro neither knows
|
||||
// nor cares whether a request is authenticated, and direct service addressing is
|
||||
// not a supported deployment.
|
||||
//
|
||||
// Reachable surface — exactly this, nothing else:
|
||||
//
|
||||
// /api/{service}/action/{actionName} → service /action/{actionName}
|
||||
// /api/{service}/auth/{path...} → service /auth/{path...}
|
||||
// /api/{service}/ws[/{path...}] → service /ws[/{path...}]
|
||||
//
|
||||
// ONE allowlist, ONE dispatcher, ONE passthrough: a request is relayed as a
|
||||
// WebSocket when it is an upgrade and as plain HTTP otherwise. Adding a service
|
||||
// namespace therefore takes no Maestro code beyond a name in this file, and the
|
||||
// two halves can never disagree about a service's address because both resolve
|
||||
// through lib/serviceproxy.
|
||||
//
|
||||
// WHAT THIS IS NOT: a general tunnel. /config and /_manifest are unreachable
|
||||
// because they are not namespaces here. /config in particular is Maestro's own
|
||||
// ES256-signed edge — Maestro mints the token and the service verifies it, so
|
||||
// exposing it through the data plane would let a caller skip that mint.
|
||||
//
|
||||
// NOT OWNER-GATED, deliberately (the reasoning lives on the registration in
|
||||
// main.go): the callers are apps and phones, which hold no Maestro owner session
|
||||
// and must never be handed one — that cookie belongs to the instance operator.
|
||||
// The service is the gate.
|
||||
//
|
||||
// Maestro envelopes ONLY what Maestro itself produced (400/404/409/500/502). A
|
||||
// service response — success, 4xx, 5xx, redirect, raw body — is mirrored
|
||||
// byte-for-byte: the service's body is the service's contract.
|
||||
package dataplane
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/serviceproxy"
|
||||
)
|
||||
|
||||
// The allowlist. A namespace is reachable only if it appears here.
|
||||
const (
|
||||
namespaceAction = "action"
|
||||
namespaceAuth = "auth"
|
||||
namespaceWS = "ws"
|
||||
)
|
||||
|
||||
// Resolver resolves a service reference to its dialable peer. Production uses
|
||||
// serviceproxy.Resolve; tests inject a fake so no Docker daemon is required.
|
||||
type Resolver func(ctx context.Context, service string) (*serviceproxy.Peer, error)
|
||||
|
||||
// Register mounts the service data plane on mux.
|
||||
func Register(mux *http.ServeMux) { RegisterWith(mux, serviceproxy.Resolve) }
|
||||
|
||||
// RegisterWith is Register with an injectable resolver (tests).
|
||||
//
|
||||
// Why a single "/api/" fallback instead of one pattern per shape: ServeMux
|
||||
// panics at boot when two patterns match a path and neither is more specific.
|
||||
// The naive pattern for the action surface, /api/{service}/action/{actionName},
|
||||
// collides with the legacy route /api/actions/{containerId}/{actionName} on
|
||||
// "/api/actions/action/<x>" (segment 2 is a wildcard in one pattern and the
|
||||
// literal "actions" in the other, so the literal "action" sits at different
|
||||
// positions) — and /api/{service}/auth/{path...} collides with it the same way.
|
||||
// Constructing the pattern set is the only way to see this: `go build` and
|
||||
// `go vet` cannot, because the mux is assembled inside main().
|
||||
//
|
||||
// The fallback is only reached when no concrete route matched, so every existing
|
||||
// console route keeps winning its own paths, and the requested public URLs are
|
||||
// exactly the ones in the package doc. TestProductionPatternSetDoesNotPanic
|
||||
// pins the pattern set; TestNaivePatternSetPanics pins the trap.
|
||||
func RegisterWith(mux *http.ServeMux, resolve Resolver) {
|
||||
mux.Handle("/api/", fallback(resolve))
|
||||
}
|
||||
|
||||
// fallback dispatches the data plane and answers 404 for every other /api/ path
|
||||
// that no concrete route claimed.
|
||||
func fallback(resolve Resolver) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
service, namespace, rest, ok := parse(r.URL.Path)
|
||||
if !ok {
|
||||
// Every other error under /api/ is a JSON envelope, and the phone
|
||||
// clients unwrap {error:{code,message}} into a rendered sentence; a
|
||||
// bare "404 page not found" would show up as raw text in the app.
|
||||
writeAPIError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown API path")
|
||||
return
|
||||
}
|
||||
|
||||
target, ok := upstreamPath(namespace, rest)
|
||||
if !ok {
|
||||
writeAPIError(w, http.StatusNotFound, "ERR_NOT_FOUND", unknownPathMessage(namespace))
|
||||
return
|
||||
}
|
||||
|
||||
// Upgrade presence decides the relay. A socket is a socket wherever the
|
||||
// service puts it, so /api/{service}/auth/events upgrading is the same
|
||||
// code path as /api/{service}/ws.
|
||||
if isUpgrade(r) {
|
||||
relayWebSocket(resolve, service, target, w, r)
|
||||
return
|
||||
}
|
||||
if namespace == namespaceWS {
|
||||
// A plain GET on a socket endpoint is a caller mistake, not a request
|
||||
// for the service's (non-existent) HTTP view of /ws.
|
||||
writeAPIError(w, http.StatusBadRequest, "ERR_INVALID_REQUEST", "this endpoint only accepts WebSocket upgrades")
|
||||
return
|
||||
}
|
||||
relayHTTP(resolve, service, target, w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// parse splits /api/{service}/{namespace}[/{rest...}].
|
||||
func parse(path string) (service, namespace, rest string, ok bool) {
|
||||
trimmed, found := strings.CutPrefix(path, "/api/")
|
||||
if !found {
|
||||
return "", "", "", false
|
||||
}
|
||||
parts := strings.Split(trimmed, "/")
|
||||
if len(parts) < 2 || !validServiceName(parts[0]) || !allowedNamespace(parts[1]) {
|
||||
return "", "", "", false
|
||||
}
|
||||
return parts[0], parts[1], strings.Join(parts[2:], "/"), true
|
||||
}
|
||||
|
||||
func allowedNamespace(namespace string) bool {
|
||||
switch namespace {
|
||||
case namespaceAction, namespaceAuth, namespaceWS:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// upstreamPath maps the namespace and its remainder onto the service's own path.
|
||||
// rest arrives as raw text, so it is validated here: this is the boundary between
|
||||
// "a path a caller chose" and "a URL Maestro dials".
|
||||
func upstreamPath(namespace, rest string) (string, bool) {
|
||||
segments, ok := splitSegments(rest)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
switch namespace {
|
||||
case namespaceWS:
|
||||
if len(segments) == 0 {
|
||||
return "/ws", true
|
||||
}
|
||||
return "/ws/" + strings.Join(segments, "/"), true
|
||||
case namespaceAction:
|
||||
// An action is one name; /action/{name}/{junk} is not a thing.
|
||||
if len(segments) != 1 {
|
||||
return "", false
|
||||
}
|
||||
return "/action/" + segments[0], true
|
||||
case namespaceAuth:
|
||||
// /auth/{path...} — login, register, logout, me, refresh, and any future
|
||||
// auth sub-path, without Maestro needing to know them.
|
||||
if len(segments) == 0 {
|
||||
return "", false
|
||||
}
|
||||
return "/auth/" + strings.Join(segments, "/"), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func unknownPathMessage(namespace string) string {
|
||||
switch namespace {
|
||||
case namespaceAction:
|
||||
return "an action path is /api/{service}/action/{action}"
|
||||
case namespaceAuth:
|
||||
return "an auth path is /api/{service}/auth/{path}"
|
||||
case namespaceWS:
|
||||
return "only the service's /ws namespace is proxied"
|
||||
}
|
||||
return "unknown API path"
|
||||
}
|
||||
|
||||
// splitSegments validates a slash-separated remainder: no empty segments, no dot
|
||||
// segments and nothing outside an unescaped-URL-safe charset. Descriptions are
|
||||
// deliberately conservative — a namespace that accepted "../" would be a tunnel.
|
||||
func splitSegments(rest string) ([]string, bool) {
|
||||
rest = strings.TrimPrefix(rest, "/")
|
||||
if rest == "" {
|
||||
return nil, true
|
||||
}
|
||||
segments := strings.Split(rest, "/")
|
||||
for _, segment := range segments {
|
||||
if segment == "" || segment == "." || segment == ".." {
|
||||
return nil, false
|
||||
}
|
||||
for _, char := range segment {
|
||||
switch {
|
||||
case char >= 'a' && char <= 'z',
|
||||
char >= 'A' && char <= 'Z',
|
||||
char >= '0' && char <= '9',
|
||||
char == '-', char == '_', char == '.', char == '~':
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
}
|
||||
return segments, true
|
||||
}
|
||||
|
||||
// validServiceName keeps the reference a plain service identifier before it is
|
||||
// handed to Docker: no dot segments, no encodings, nothing that could be read as
|
||||
// a path elsewhere.
|
||||
func validServiceName(name string) bool {
|
||||
if name == "" || name == "." || name == ".." || len(name) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, char := range name {
|
||||
switch {
|
||||
case char >= 'a' && char <= 'z',
|
||||
char >= 'A' && char <= 'Z',
|
||||
char >= '0' && char <= '9',
|
||||
char == '-', char == '_', char == '.':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// resolvePeer resolves the service and refuses to relay to a stopped container.
|
||||
func resolvePeer(resolve Resolver, service string, w http.ResponseWriter, r *http.Request) (*serviceproxy.Peer, bool) {
|
||||
peer, err := resolve(r.Context(), service)
|
||||
if err != nil {
|
||||
writeResolveError(w, service, err)
|
||||
return nil, false
|
||||
}
|
||||
if !peer.Running {
|
||||
writeAPIError(w, http.StatusConflict, "ERR_NOT_RUNNING", fmt.Sprintf("service %q is not running", service))
|
||||
return nil, false
|
||||
}
|
||||
return peer, true
|
||||
}
|
||||
|
||||
// errorBody is Maestro's uniform API error shape (same envelope as the owner-gated
|
||||
// handlers, with a message so every error still says something human-readable).
|
||||
type errorBody struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message,omitempty"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func writeAPIError(w http.ResponseWriter, status int, code, message string) {
|
||||
var body errorBody
|
||||
body.Error.Code = code
|
||||
body.Error.Message = message
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
// writeResolveError maps resolution failures onto the documented statuses.
|
||||
func writeResolveError(w http.ResponseWriter, service string, err error) {
|
||||
switch {
|
||||
case errors.Is(err, serviceproxy.ErrNotFound):
|
||||
writeAPIError(w, http.StatusNotFound, "ERR_NOT_FOUND", fmt.Sprintf("unknown service %q", service))
|
||||
case errors.Is(err, serviceproxy.ErrNotRunning):
|
||||
writeAPIError(w, http.StatusConflict, "ERR_NOT_RUNNING", fmt.Sprintf("service %q is not running", service))
|
||||
default:
|
||||
logger.Debug("Service resolution failed for %q: %v", service, err)
|
||||
// Maestro's own plumbing failed (dead daemon, no network, no port): a 502
|
||||
// would send an operator to the service's logs for a failure that is not
|
||||
// there.
|
||||
writeAPIError(w, http.StatusInternalServerError, "ERR_INTERNAL",
|
||||
fmt.Sprintf("failed to resolve service %q: %v", service, err))
|
||||
}
|
||||
}
|
||||
|
||||
// writeUnusableAddress answers a malformed base URL, which is Maestro's own
|
||||
// plumbing failing — never a 502.
|
||||
func writeUnusableAddress(w http.ResponseWriter, service string, err error) {
|
||||
logger.Debug("Bad service base URL for %q: %v", service, err)
|
||||
writeAPIError(w, http.StatusInternalServerError, "ERR_INTERNAL",
|
||||
fmt.Sprintf("service %q has an unusable address", service))
|
||||
}
|
||||
|
||||
// writeUpstreamUnreachable answers a transport failure to a resolved service.
|
||||
func writeUpstreamUnreachable(w http.ResponseWriter, service string, err error) {
|
||||
logger.Debug("Service relay failed for %q: %v", service, err)
|
||||
writeAPIError(w, http.StatusBadGateway, "ERR_UPSTREAM_UNREACHABLE",
|
||||
fmt.Sprintf("failed to reach service %q: %v", service, err))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/serviceproxy"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// httpRecorder captures what the fake service saw for a plain-HTTP relay.
|
||||
type httpRecorder struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
method string
|
||||
path string
|
||||
query string
|
||||
headers http.Header
|
||||
body string
|
||||
}
|
||||
|
||||
func (rec *httpRecorder) record(r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
rec.mu.Lock()
|
||||
defer rec.mu.Unlock()
|
||||
rec.calls++
|
||||
rec.method = r.Method
|
||||
rec.path = r.URL.Path
|
||||
rec.query = r.URL.RawQuery
|
||||
rec.headers = r.Header.Clone()
|
||||
rec.body = string(body)
|
||||
}
|
||||
|
||||
func (rec *httpRecorder) snapshot() (calls int, method, path, query string, headers http.Header, body string) {
|
||||
rec.mu.Lock()
|
||||
defer rec.mu.Unlock()
|
||||
return rec.calls, rec.method, rec.path, rec.query, rec.headers, rec.body
|
||||
}
|
||||
|
||||
// startHTTPService runs a fake service that records the request and answers with
|
||||
// the handler's response.
|
||||
func startHTTPService(t *testing.T, answer http.HandlerFunc) (*httpRecorder, string) {
|
||||
t.Helper()
|
||||
rec := &httpRecorder{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rec.record(r)
|
||||
answer(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return rec, srv.URL
|
||||
}
|
||||
|
||||
// TestActionRelayed is the core of the model: an app calls
|
||||
// /api/{service}/action/{action} with no Maestro session, and the service receives
|
||||
// the action, the query, the body and the token — but never the operator's cookie
|
||||
// or an Authorization header.
|
||||
func TestActionRelayed(t *testing.T) {
|
||||
const query = "alertId=0f8fad5b-0000-4000-8000-000000000001&accessToken=ACCESSTOKEN"
|
||||
rec, upstreamURL := startHTTPService(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = io.WriteString(w, `{"ok":true,"alert":{"id":"a1"}}`)
|
||||
})
|
||||
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, srv.URL+"/api/alarm/action/createAlert?"+query,
|
||||
strings.NewReader(`{"payload":{"alertId":"a1"}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Access-Token", "ACCESSTOKEN")
|
||||
req.Header.Set("X-Alarm-Token", "SUBTOKEN")
|
||||
req.Header.Set("Cookie", "maestro_access=operator-secret; maestro_refresh=operator-refresh")
|
||||
req.Header.Set("Authorization", "Bearer operator-session")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST through the data plane: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201 (body: %s)", resp.StatusCode, body)
|
||||
}
|
||||
if string(body) != `{"ok":true,"alert":{"id":"a1"}}` {
|
||||
t.Fatalf("body = %q, want the service's body byte-for-byte", body)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Type"); !strings.Contains(got, "application/json") {
|
||||
t.Fatalf("Content-Type = %q, want the service's", got)
|
||||
}
|
||||
|
||||
calls, method, path, gotQuery, headers, gotBody := rec.snapshot()
|
||||
if calls != 1 {
|
||||
t.Fatalf("service calls = %d, want 1", calls)
|
||||
}
|
||||
if method != http.MethodPost {
|
||||
t.Fatalf("method = %q, want POST", method)
|
||||
}
|
||||
if path != "/action/createAlert" {
|
||||
t.Fatalf("path = %q, want /action/createAlert", path)
|
||||
}
|
||||
if gotQuery != query {
|
||||
t.Fatalf("query = %q, want it forwarded verbatim (%q)", gotQuery, query)
|
||||
}
|
||||
if gotBody != `{"payload":{"alertId":"a1"}}` {
|
||||
t.Fatalf("body = %q, want the caller's body", gotBody)
|
||||
}
|
||||
if got := headers.Get("X-Access-Token"); got != "ACCESSTOKEN" {
|
||||
t.Fatalf("X-Access-Token = %q, want ACCESSTOKEN", got)
|
||||
}
|
||||
if got := headers.Get("X-Alarm-Token"); got != "SUBTOKEN" {
|
||||
t.Fatalf("X-Alarm-Token = %q, want SUBTOKEN", got)
|
||||
}
|
||||
if got := headers.Get("Content-Type"); !strings.Contains(got, "application/json") {
|
||||
t.Fatalf("Content-Type at the service = %q, want application/json", got)
|
||||
}
|
||||
if got := headers.Get("Cookie"); got != "" {
|
||||
t.Fatalf("Cookie = %q, want none — the operator's session must never ride into a service", got)
|
||||
}
|
||||
if got := headers.Get("Authorization"); got != "" {
|
||||
t.Fatalf("Authorization = %q, want none — services authenticate with their own tokens", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthRelayed: /api/{service}/auth/{path...} reaches the service's /auth/*
|
||||
// namespace with the raw JSON body, which is what the phone clients call directly.
|
||||
func TestAuthRelayed(t *testing.T) {
|
||||
rec, upstreamURL := startHTTPService(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
// A service error body in the service's own shape — must NOT be
|
||||
// reformatted into Maestro's envelope.
|
||||
_, _ = io.WriteString(w, `{"error":{"code":"ERR_INVALID_CREDENTIALS","message":"wrong password"}}`)
|
||||
})
|
||||
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
|
||||
|
||||
resp, err := http.Post(srv.URL+"/api/alarm/auth/login", "application/json",
|
||||
strings.NewReader(`{"username":"admin","password":"bad"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("POST /auth/login: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want the service's 401 (body: %s)", resp.StatusCode, body)
|
||||
}
|
||||
if string(body) != `{"error":{"code":"ERR_INVALID_CREDENTIALS","message":"wrong password"}}` {
|
||||
t.Fatalf("body = %q, want the service's body untouched", body)
|
||||
}
|
||||
|
||||
_, method, path, _, _, gotBody := rec.snapshot()
|
||||
if method != http.MethodPost || path != "/auth/login" {
|
||||
t.Fatalf("service saw %s %s, want POST /auth/login", method, path)
|
||||
}
|
||||
if gotBody != `{"username":"admin","password":"bad"}` {
|
||||
t.Fatalf("body at the service = %q, want the raw JSON", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthPathDepth keeps the auth namespace a prefix, not a fixed list: a future
|
||||
// service auth sub-path needs no Maestro change.
|
||||
func TestAuthPathDepth(t *testing.T) {
|
||||
rec, upstreamURL := startHTTPService(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
|
||||
|
||||
resp, err := http.Post(srv.URL+"/api/alarm/auth/device/pair/confirm", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", resp.StatusCode)
|
||||
}
|
||||
if _, _, path, _, _, _ := rec.snapshot(); path != "/auth/device/pair/confirm" {
|
||||
t.Fatalf("path = %q, want /auth/device/pair/confirm", path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMirrorsServiceResponses: whatever the service answers is the caller's
|
||||
// answer — status, body and content type, including redirects (never followed)
|
||||
// and the service's own error envelope (never re-wrapped).
|
||||
func TestMirrorsServiceResponses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
answer http.HandlerFunc
|
||||
wantStatus int
|
||||
wantBody string
|
||||
wantCT string
|
||||
wantLocation string
|
||||
}{
|
||||
{
|
||||
name: "200 with a service body",
|
||||
answer: func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"alerts":[]}`)
|
||||
},
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: `{"alerts":[]}`,
|
||||
wantCT: "application/json",
|
||||
},
|
||||
{
|
||||
name: "401 with the service's own envelope",
|
||||
answer: func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = io.WriteString(w, `{"error":{"code":"ERR_UNAUTHORIZED"}}`)
|
||||
},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
wantBody: `{"error":{"code":"ERR_UNAUTHORIZED"}}`,
|
||||
wantCT: "application/json",
|
||||
},
|
||||
{
|
||||
name: "500 with a plain-text body",
|
||||
answer: func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = io.WriteString(w, "database is down")
|
||||
},
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantBody: "database is down",
|
||||
wantCT: "text/plain",
|
||||
},
|
||||
{
|
||||
name: "302 is mirrored, not followed",
|
||||
answer: func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Location", "https://elsewhere.example/nowhere")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
},
|
||||
wantStatus: http.StatusFound,
|
||||
wantLocation: "https://elsewhere.example/nowhere",
|
||||
},
|
||||
{
|
||||
name: "204 with no body",
|
||||
answer: func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
},
|
||||
wantStatus: http.StatusNoContent,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, upstreamURL := startHTTPService(t, test.answer)
|
||||
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
|
||||
|
||||
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
resp, err := client.Post(srv.URL+"/api/alarm/action/getAlert", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != test.wantStatus {
|
||||
t.Fatalf("status = %d, want %d (body: %s)", resp.StatusCode, test.wantStatus, body)
|
||||
}
|
||||
if test.wantBody != "" && string(body) != test.wantBody {
|
||||
t.Fatalf("body = %q, want %q", body, test.wantBody)
|
||||
}
|
||||
if test.wantCT != "" {
|
||||
if got := resp.Header.Get("Content-Type"); !strings.Contains(got, test.wantCT) {
|
||||
t.Fatalf("Content-Type = %q, want %q", got, test.wantCT)
|
||||
}
|
||||
}
|
||||
if test.wantLocation != "" {
|
||||
if got := resp.Header.Get("Location"); got != test.wantLocation {
|
||||
t.Fatalf("Location = %q, want %q", got, test.wantLocation)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetCookieNotMirrored: a service must not be able to write cookies onto
|
||||
// Maestro's own origin, so Set-Cookie never rides back.
|
||||
func TestSetCookieNotMirrored(t *testing.T) {
|
||||
_, upstreamURL := startHTTPService(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name: "maestro_access", Value: "forged", Path: "/"})
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
})
|
||||
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
|
||||
|
||||
resp, err := http.Post(srv.URL+"/api/alarm/auth/login", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if cookies := resp.Header.Values("Set-Cookie"); len(cookies) != 0 {
|
||||
t.Fatalf("Set-Cookie = %v, want none — a service must not set cookies on the console origin", cookies)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveErrors: unknown service 404, stopped service 409, Maestro's own
|
||||
// plumbing 500 (never a 502), no answer at all 502. Every one carries a message.
|
||||
func TestResolveErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
resolve Resolver
|
||||
wantCode string
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "unknown service",
|
||||
resolve: resolverAnswer(nil, fmt.Errorf("%w: %q", serviceproxy.ErrNotFound, "alarm")),
|
||||
wantCode: "ERR_NOT_FOUND",
|
||||
wantBody: "unknown service",
|
||||
},
|
||||
{
|
||||
name: "service not running",
|
||||
resolve: resolverAnswer(&serviceproxy.Peer{BaseURL: "http://127.0.0.1:1", Running: false}, nil),
|
||||
wantCode: "ERR_NOT_RUNNING",
|
||||
wantBody: "is not running",
|
||||
},
|
||||
{
|
||||
name: "docker unavailable",
|
||||
resolve: resolverAnswer(nil, fmt.Errorf("%w: cannot connect", serviceproxy.ErrDocker)),
|
||||
wantCode: "ERR_INTERNAL",
|
||||
wantBody: "failed to resolve service",
|
||||
},
|
||||
{
|
||||
name: "no network address",
|
||||
resolve: resolverAnswer(nil, fmt.Errorf("%w: %q", serviceproxy.ErrNoNetwork, "alarm")),
|
||||
wantCode: "ERR_INTERNAL",
|
||||
wantBody: "failed to resolve service",
|
||||
},
|
||||
{
|
||||
name: "upstream unreachable",
|
||||
resolve: resolverAnswer(runningPeer("http://127.0.0.1:1"), nil),
|
||||
wantCode: "ERR_UPSTREAM_UNREACHABLE",
|
||||
wantBody: "failed to reach service",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
srv := startProxy(t, test.resolve)
|
||||
resp, err := http.Post(srv.URL+"/api/alarm/action/getAlert", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if !strings.Contains(string(body), `"code":"`+test.wantCode+`"`) {
|
||||
t.Fatalf("body = %q, want code %s", body, test.wantCode)
|
||||
}
|
||||
if !strings.Contains(string(body), test.wantBody) {
|
||||
t.Fatalf("body = %q, want it to contain %q", body, test.wantBody)
|
||||
}
|
||||
if !strings.Contains(string(body), `"message":"`) {
|
||||
t.Fatalf("body = %q, want a human-readable message", body)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Type"); !strings.Contains(got, "application/json") {
|
||||
t.Fatalf("Content-Type = %q, want application/json", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNamespaceAllowlist: only action, auth and ws are reachable. /config,
|
||||
// /_manifest and anything else are 404 and never reach the service — this surface
|
||||
// is not a tunnel.
|
||||
func TestNamespaceAllowlist(t *testing.T) {
|
||||
tests := []string{
|
||||
"/api/alarm/config",
|
||||
"/api/alarm/_manifest",
|
||||
"/api/alarm/manifest",
|
||||
"/api/alarm/actions/getAlert", // plural is the console's route, not a service namespace
|
||||
"/api/alarm",
|
||||
"/api/alarm/",
|
||||
"/api/alarm/action", // no action name
|
||||
"/api/alarm/action/a/b", // an action is one segment
|
||||
"/api/alarm/auth", // no auth path
|
||||
"/api/typo", // no namespace at all
|
||||
"/api/alarm/auth/%2e%2e/config",
|
||||
}
|
||||
|
||||
for _, path := range tests {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
rec, upstreamURL := startHTTPService(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
|
||||
|
||||
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
resp, err := client.Get(srv.URL + path)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("GET %s = %d (%s), want 404", path, resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(string(body), `"code":"ERR_NOT_FOUND"`) {
|
||||
t.Fatalf("GET %s body = %q, want Maestro's JSON envelope", path, body)
|
||||
}
|
||||
if calls, _, _, _, _, _ := rec.snapshot(); calls != 0 {
|
||||
t.Fatalf("GET %s reached the service %d times, want 0", path, calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDotSegmentsNeverReachTheService: a traversal attempt may be redirected by
|
||||
// the standard library's path cleaning, but it must never be relayed.
|
||||
func TestDotSegmentsNeverReachTheService(t *testing.T) {
|
||||
rec, upstreamURL := startHTTPService(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
|
||||
|
||||
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
resp, err := client.Get(srv.URL + "/api/../etc/passwd")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Either cleaned+404 by the mux or the mux's own redirect — never a relay.
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatalf("GET /api/../etc/passwd = 200, want no relay")
|
||||
}
|
||||
if calls, _, path, _, _, _ := rec.snapshot(); calls != 0 {
|
||||
t.Fatalf("service was called (%s) for a traversal attempt", path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyActionRouteStaysGated is the model in one test: the legacy console
|
||||
// route keeps its owner gate, while the data plane reaches the service with no
|
||||
// cookie at all.
|
||||
func TestLegacyActionRouteStaysGated(t *testing.T) {
|
||||
rec, upstreamURL := startHTTPService(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"ok":true}`)
|
||||
})
|
||||
|
||||
gate := func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeAPIError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED", "a session is required")
|
||||
})
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/api/actions/{containerId}/{actionName}", gate(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, "legacy")
|
||||
})))
|
||||
RegisterWith(mux, resolverAnswer(runningPeer(upstreamURL), nil))
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
// The legacy route still refuses an app.
|
||||
resp, err := http.Post(srv.URL+"/api/actions/alarm/createAlert", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("POST legacy: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("legacy route = %d, want the owner gate's 401", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The data plane does not, and the service receives the action.
|
||||
resp, err = http.Post(srv.URL+"/api/alarm/action/createAlert", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("POST data plane: %v", err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("data plane = %d (%s), want the service's 200", resp.StatusCode, body)
|
||||
}
|
||||
if calls, _, path, _, _, _ := rec.snapshot(); calls != 1 || path != "/action/createAlert" {
|
||||
t.Fatalf("service calls = %d path = %q, want 1 call to /action/createAlert", calls, path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveIsShared: an action and a socket to the same service must ask the
|
||||
// same resolver, so the two edges can never disagree about a service's address.
|
||||
func TestResolveIsShared(t *testing.T) {
|
||||
calls := make(chan string, 8)
|
||||
resolve := func(_ context.Context, service string) (*serviceproxy.Peer, error) {
|
||||
calls <- service
|
||||
return nil, fmt.Errorf("%w: %q", serviceproxy.ErrNotFound, service)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
RegisterWith(mux, resolve)
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
for _, path := range []string{"/api/alarm/action/x", "/api/alarm/auth/login"} {
|
||||
resp, err := http.Get(srv.URL + path)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
// The socket edge resolves only on a real upgrade, so drive one.
|
||||
if conn, _, err := websocket.DefaultDialer.Dial(
|
||||
"ws"+strings.TrimPrefix(srv.URL, "http")+"/api/alarm/ws", nil); err == nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
select {
|
||||
case got := <-calls:
|
||||
if got != "alarm" {
|
||||
t.Fatalf("resolver saw %q, want alarm", got)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("resolver was not called for every edge")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/serviceproxy"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
// dialTimeout bounds the service's handshake only. Once the socket is up
|
||||
// there is deliberately NO read/write deadline on either hop: an idle alarm
|
||||
// socket must survive indefinitely (a proxy deadline is how you get a socket
|
||||
// that dies every 60 seconds).
|
||||
dialTimeout = 10 * time.Second
|
||||
// relayWriteWait bounds one relayed control frame (ping/pong/close) so a
|
||||
// dead peer cannot block the frame pump forever.
|
||||
relayWriteWait = 10 * time.Second
|
||||
// maxCloseText keeps a relayed close reason inside a control frame (125 B).
|
||||
maxCloseText = 123
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
// Maestro is an instance-local pipe and the caller is an app, not a browser
|
||||
// tab: same permissive origin policy as handlers/containers/ws.go. Access
|
||||
// control is the service's token check, not the Origin header.
|
||||
CheckOrigin: func(*http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// isUpgrade reports whether this request is a WebSocket handshake. It is the
|
||||
// single switch between the two relay modes.
|
||||
func isUpgrade(r *http.Request) bool { return websocket.IsWebSocketUpgrade(r) }
|
||||
|
||||
// relayWebSocket proxies a WebSocket handshake and then pumps frames both ways.
|
||||
func relayWebSocket(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: it carries alertId, token,
|
||||
// deviceId and accessToken, and re-encoding it would corrupt the token.
|
||||
target, err := serviceproxy.Endpoint(peer.BaseURL, upstreamPath, r.URL.RawQuery)
|
||||
if err != nil {
|
||||
writeUnusableAddress(w, service, err)
|
||||
return
|
||||
}
|
||||
target = wsScheme(target)
|
||||
|
||||
// Dial the service BEFORE touching the caller's connection: while nothing has
|
||||
// been written to w we can still answer with a real HTTP status.
|
||||
dialer := *websocket.DefaultDialer
|
||||
dialer.HandshakeTimeout = dialTimeout
|
||||
dialer.Subprotocols = websocket.Subprotocols(r)
|
||||
|
||||
upstream, resp, err := dialer.DialContext(r.Context(), target, forwardableRequestHeaders(r.Header))
|
||||
if err != nil {
|
||||
writeDialError(w, service, resp, err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = upstream.Close() }()
|
||||
|
||||
// Echo the subprotocol the service accepted, if any: a caller that requested
|
||||
// one fails the connection when the answer does not match.
|
||||
var responseHeader http.Header
|
||||
if protocol := upstream.Subprotocol(); protocol != "" {
|
||||
responseHeader = http.Header{"Sec-Websocket-Protocol": {protocol}}
|
||||
}
|
||||
|
||||
client, err := upgrader.Upgrade(w, r, responseHeader)
|
||||
if err != nil {
|
||||
// Upgrade already wrote a plaintext error response.
|
||||
logger.Debug("Client WebSocket upgrade failed for %q: %v", service, err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
// Control frames are RELAYED, not answered. If this proxy answered pings
|
||||
// itself, the service's own 20s ping / 60s pong / 10s write accounting would
|
||||
// be satisfied by Maestro even with the phone switched off — liveness
|
||||
// detection would become a lie. Relay keeps it end-to-end.
|
||||
client.SetPingHandler(func(appData string) error {
|
||||
return relayControl(upstream, websocket.PingMessage, appData)
|
||||
})
|
||||
client.SetPongHandler(func(appData string) error {
|
||||
return relayControl(upstream, websocket.PongMessage, appData)
|
||||
})
|
||||
upstream.SetPingHandler(func(appData string) error {
|
||||
return relayControl(client, websocket.PingMessage, appData)
|
||||
})
|
||||
upstream.SetPongHandler(func(appData string) error {
|
||||
return relayControl(client, websocket.PongMessage, appData)
|
||||
})
|
||||
|
||||
// Two pumps, no barriers: whichever hop ends first ends the session.
|
||||
type pumpEnd struct {
|
||||
err error
|
||||
fromUpstream bool
|
||||
}
|
||||
ends := make(chan pumpEnd, 2)
|
||||
go func() { ends <- pumpEnd{err: pump(upstream, client), fromUpstream: true} }()
|
||||
go func() { ends <- pumpEnd{err: pump(client, upstream), fromUpstream: false} }()
|
||||
|
||||
first := <-ends
|
||||
propagateClose(first.err, first.fromUpstream, client, upstream)
|
||||
|
||||
// Closing both sockets unblocks the other pump; waiting for it keeps every
|
||||
// goroutine inside the handler's lifetime.
|
||||
_ = client.Close()
|
||||
_ = upstream.Close()
|
||||
<-ends
|
||||
}
|
||||
|
||||
// wsScheme maps the plaintext service base URL onto a WebSocket URL.
|
||||
func wsScheme(target string) string {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return target
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "http":
|
||||
u.Scheme = "ws"
|
||||
case "https":
|
||||
u.Scheme = "wss"
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// writeDialError reports a failed handshake. A service that answered and refused
|
||||
// (401/403/404) is mirrored with its own status and body: the service is the gate
|
||||
// on this surface, so a rejected token must look like a rejection, not like a
|
||||
// broken proxy. No answer at all is a 502.
|
||||
func writeDialError(w http.ResponseWriter, service string, resp *http.Response, err error) {
|
||||
if resp != nil {
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
||||
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
return
|
||||
}
|
||||
}
|
||||
logger.Debug("Service WebSocket dial failed for %q: %v", service, err)
|
||||
writeAPIError(w, http.StatusBadGateway, "ERR_UPSTREAM_UNREACHABLE",
|
||||
fmt.Sprintf("failed to reach the socket of service %q: %v", service, err))
|
||||
}
|
||||
|
||||
// relayControl forwards one control frame to the peer hop.
|
||||
func relayControl(dst *websocket.Conn, messageType int, appData string) error {
|
||||
return dst.WriteControl(messageType, []byte(appData), time.Now().Add(relayWriteWait))
|
||||
}
|
||||
|
||||
// pump copies data messages from src to dst, one frame at a time. Control frames
|
||||
// never reach this loop (the per-connection handlers relay them), so the only
|
||||
// outcomes are the next message and the terminal error.
|
||||
func pump(src, dst *websocket.Conn) error {
|
||||
for {
|
||||
messageType, reader, err := src.NextReader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writer, err := dst.NextWriter(messageType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(writer, reader); err != nil {
|
||||
_ = writer.Close()
|
||||
return err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// propagateClose tells the surviving hop why the session ended. A received close
|
||||
// frame is forwarded with its original code and reason, so the client can tell
|
||||
// "the admin disarmed the alert" (the service closed cleanly) from "the network
|
||||
// died" (abnormal closure). Nothing is invented for a clean code.
|
||||
func propagateClose(cause error, fromUpstream bool, client, upstream *websocket.Conn) {
|
||||
dst, ended := upstream, "client"
|
||||
if fromUpstream {
|
||||
dst, ended = client, "upstream"
|
||||
}
|
||||
deadline := time.Now().Add(relayWriteWait)
|
||||
|
||||
var closeErr *websocket.CloseError
|
||||
if errors.As(cause, &closeErr) &&
|
||||
closeErr.Code != websocket.CloseNoStatusReceived &&
|
||||
closeErr.Code != websocket.CloseAbnormalClosure {
|
||||
text := closeErr.Text
|
||||
if len(text) > maxCloseText {
|
||||
text = text[:maxCloseText]
|
||||
}
|
||||
_ = dst.WriteControl(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(closeErr.Code, text), deadline)
|
||||
return
|
||||
}
|
||||
|
||||
_ = dst.WriteControl(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseTryAgainLater, "the "+ended+" connection ended"),
|
||||
deadline)
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/serviceproxy"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// recorder captures what the fake service saw when the socket was opened.
|
||||
type recorder struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
query string
|
||||
headers http.Header
|
||||
}
|
||||
|
||||
func (rec *recorder) capture(r *http.Request) {
|
||||
rec.mu.Lock()
|
||||
defer rec.mu.Unlock()
|
||||
rec.path = r.URL.Path
|
||||
rec.query = r.URL.RawQuery
|
||||
rec.headers = r.Header.Clone()
|
||||
}
|
||||
|
||||
func (rec *recorder) snapshot() (path, query string, headers http.Header) {
|
||||
rec.mu.Lock()
|
||||
defer rec.mu.Unlock()
|
||||
return rec.path, rec.query, rec.headers
|
||||
}
|
||||
|
||||
// startUpstream runs a fake service socket server. run executes after a
|
||||
// successful upgrade and returning from it ends the connection.
|
||||
func startUpstream(t *testing.T, run func(c *websocket.Conn)) (*recorder, string) {
|
||||
t.Helper()
|
||||
rec := &recorder{}
|
||||
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rec.capture(r)
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
run(conn)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return rec, srv.URL
|
||||
}
|
||||
|
||||
// startProxy builds the same route table main.go builds, with a fake resolver.
|
||||
func startProxy(t *testing.T, resolve Resolver) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
RegisterWith(mux, resolve)
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func runningPeer(baseURL string) *serviceproxy.Peer {
|
||||
return &serviceproxy.Peer{Service: "alarm", Running: true, BaseURL: baseURL}
|
||||
}
|
||||
|
||||
// resolverAnswer resolves every reference to peer, or fails with err.
|
||||
func resolverAnswer(peer *serviceproxy.Peer, err error) Resolver {
|
||||
return func(context.Context, string) (*serviceproxy.Peer, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return peer, nil
|
||||
}
|
||||
}
|
||||
|
||||
func wsURL(t *testing.T, srv *httptest.Server, path string) string {
|
||||
t.Helper()
|
||||
return "ws" + strings.TrimPrefix(srv.URL, "http") + path
|
||||
}
|
||||
|
||||
// dialProxy connects a test client through the proxy.
|
||||
func dialProxy(t *testing.T, srv *httptest.Server, path string, header http.Header) *websocket.Conn {
|
||||
t.Helper()
|
||||
conn, resp, err := websocket.DefaultDialer.Dial(wsURL(t, srv, path), header)
|
||||
if err != nil {
|
||||
status := 0
|
||||
if resp != nil {
|
||||
status = resp.StatusCode
|
||||
}
|
||||
t.Fatalf("dial %s: %v (status %d)", path, err, status)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return conn
|
||||
}
|
||||
|
||||
// readText reads one text frame, failing the test on timeout or wrong type.
|
||||
func readText(t *testing.T, conn *websocket.Conn) string {
|
||||
t.Helper()
|
||||
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||
t.Fatalf("set read deadline: %v", err)
|
||||
}
|
||||
messageType, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("read frame: %v", err)
|
||||
}
|
||||
if messageType != websocket.TextMessage {
|
||||
t.Fatalf("frame type = %d, want text", messageType)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// TestFramesBothWays is (a): a frame from the service reaches the caller and a
|
||||
// frame from the caller reaches the service, through the real upgrade path.
|
||||
func TestFramesBothWays(t *testing.T) {
|
||||
_, upstreamURL := startUpstream(t, func(c *websocket.Conn) {
|
||||
_ = c.WriteMessage(websocket.TextMessage, []byte("alarm-frame-from-service"))
|
||||
for {
|
||||
messageType, data, err := c.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := c.WriteMessage(messageType, data); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
|
||||
conn := dialProxy(t, srv, "/api/alarm/ws/alarm", nil)
|
||||
|
||||
if got := readText(t, conn); got != "alarm-frame-from-service" {
|
||||
t.Fatalf("service frame = %q, want alarm-frame-from-service", got)
|
||||
}
|
||||
if err := conn.WriteMessage(websocket.TextMessage, []byte("ack-from-client")); err != nil {
|
||||
t.Fatalf("write client frame: %v", err)
|
||||
}
|
||||
if got := readText(t, conn); got != "ack-from-client" {
|
||||
t.Fatalf("echoed client frame = %q, want ack-from-client", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForwardsQueryAndHeaders is (b): the service sees the caller's query string
|
||||
// verbatim and the access token header, and never the caller's cookies.
|
||||
func TestForwardsQueryAndHeaders(t *testing.T) {
|
||||
const query = "alertId=0f8fad5b-0000-4000-8000-000000000001&token=SUBTOKEN&deviceId=pixel-7&accessToken=ACCESSTOKEN"
|
||||
|
||||
rec, upstreamURL := startUpstream(t, func(c *websocket.Conn) {
|
||||
time.Sleep(50 * time.Millisecond) // hold the socket open long enough to be captured
|
||||
})
|
||||
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
|
||||
|
||||
header := http.Header{}
|
||||
header.Set("X-Access-Token", "ACCESSTOKEN")
|
||||
header.Set("Cookie", "maestro_access=operator-secret")
|
||||
header.Set("Origin", "http://127.0.0.1:8081")
|
||||
_ = dialProxy(t, srv, "/api/alarm/ws/alarm?"+query, header)
|
||||
|
||||
path, gotQuery, headers := rec.snapshot()
|
||||
if path != "/ws/alarm" {
|
||||
t.Fatalf("upstream path = %q, want /ws/alarm", path)
|
||||
}
|
||||
if gotQuery != query {
|
||||
t.Fatalf("upstream query = %q, want %q", gotQuery, query)
|
||||
}
|
||||
if got := headers.Get("X-Access-Token"); got != "ACCESSTOKEN" {
|
||||
t.Fatalf("upstream X-Access-Token = %q, want ACCESSTOKEN", got)
|
||||
}
|
||||
if got := headers.Get("Origin"); got != "http://127.0.0.1:8081" {
|
||||
t.Fatalf("upstream Origin = %q, want it forwarded", got)
|
||||
}
|
||||
if got := headers.Get("Cookie"); got != "" {
|
||||
t.Fatalf("upstream Cookie = %q, want none — the owner session must never ride into a service", got)
|
||||
}
|
||||
// The dialer owns the handshake headers (Host/Upgrade/Connection/
|
||||
// Sec-WebSocket-Key), so the upstream MUST see a valid upgrade even though we
|
||||
// never forwarded those from the caller.
|
||||
if got := headers.Get("Upgrade"); !strings.EqualFold(got, "websocket") {
|
||||
t.Fatalf("upstream Upgrade = %q, want the dialer's own handshake", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusCodes is (c), (d), (g), (h) and (i) as a table: the proxy answers
|
||||
// with a real HTTP status before it hijacks anything.
|
||||
func TestStatusCodes(t *testing.T) {
|
||||
rejectingUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = io.WriteString(w, `{"error":{"code":"ERR_UNAUTHORIZED","message":"missing access token"}}`)
|
||||
}))
|
||||
t.Cleanup(rejectingUpstream.Close)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
resolve Resolver
|
||||
path string
|
||||
upgrade bool
|
||||
wantStatus int
|
||||
wantBody string
|
||||
// wantCode is Maestro's own error code; empty means the body is the
|
||||
// service's own shape, mirrored verbatim (never reformatted).
|
||||
wantCode string
|
||||
}{
|
||||
{
|
||||
name: "non-upgrade request is rejected",
|
||||
resolve: resolverAnswer(runningPeer("http://127.0.0.1:1"), nil),
|
||||
path: "/api/alarm/ws",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "only accepts WebSocket upgrades",
|
||||
wantCode: "ERR_INVALID_REQUEST",
|
||||
},
|
||||
{
|
||||
name: "unknown service",
|
||||
resolve: resolverAnswer(nil, fmt.Errorf("%w: %q", serviceproxy.ErrNotFound, "alarm")),
|
||||
path: "/api/alarm/ws",
|
||||
upgrade: true,
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantBody: "unknown service", // the quoted name is JSON-escaped inside the envelope
|
||||
wantCode: "ERR_NOT_FOUND",
|
||||
},
|
||||
{
|
||||
name: "service not running",
|
||||
resolve: resolverAnswer(&serviceproxy.Peer{BaseURL: "http://127.0.0.1:1", Running: false}, nil),
|
||||
path: "/api/alarm/ws",
|
||||
upgrade: true,
|
||||
wantStatus: http.StatusConflict,
|
||||
wantBody: "is not running",
|
||||
wantCode: "ERR_NOT_RUNNING",
|
||||
},
|
||||
{
|
||||
name: "docker unavailable",
|
||||
resolve: resolverAnswer(nil, fmt.Errorf("%w: cannot connect", serviceproxy.ErrDocker)),
|
||||
path: "/api/alarm/ws",
|
||||
upgrade: true,
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantBody: "failed to resolve service",
|
||||
wantCode: "ERR_INTERNAL",
|
||||
},
|
||||
{
|
||||
name: "upstream refuses the upgrade",
|
||||
resolve: resolverAnswer(runningPeer(rejectingUpstream.URL), nil),
|
||||
path: "/api/alarm/ws",
|
||||
upgrade: true,
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
wantBody: "err_unauthorized", // the service's own envelope, lower-cased by its code
|
||||
},
|
||||
{
|
||||
name: "upstream is down",
|
||||
resolve: resolverAnswer(runningPeer("http://127.0.0.1:1"), nil),
|
||||
path: "/api/alarm/ws",
|
||||
upgrade: true,
|
||||
wantStatus: http.StatusBadGateway,
|
||||
wantBody: "failed to reach the socket",
|
||||
wantCode: "ERR_UPSTREAM_UNREACHABLE",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
srv := startProxy(t, test.resolve)
|
||||
|
||||
var resp *http.Response
|
||||
if test.upgrade {
|
||||
conn, handshake, err := websocket.DefaultDialer.Dial(wsURL(t, srv, test.path), nil)
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("handshake succeeded, want status %d", test.wantStatus)
|
||||
}
|
||||
resp = handshake
|
||||
} else {
|
||||
var err error
|
||||
resp, err = http.Get(srv.URL + test.path)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", test.path, err)
|
||||
}
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("no HTTP response")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if resp.StatusCode != test.wantStatus {
|
||||
t.Fatalf("status = %d, want %d (body: %s)", resp.StatusCode, test.wantStatus, body)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(string(body)), strings.ToLower(test.wantBody)) {
|
||||
t.Fatalf("body = %q, want it to contain %q", body, test.wantBody)
|
||||
}
|
||||
if test.wantCode != "" {
|
||||
// Maestro's own errors must be the uniform envelope: the phone
|
||||
// clients unwrap {error:{code,message}} and render the message.
|
||||
if got := resp.Header.Get("Content-Type"); !strings.Contains(got, "application/json") {
|
||||
t.Fatalf("Content-Type = %q, want application/json for %s", got, test.wantCode)
|
||||
}
|
||||
if !strings.Contains(string(body), `"code":"`+test.wantCode+`"`) {
|
||||
t.Fatalf("body = %q, want code %s", body, test.wantCode)
|
||||
}
|
||||
if !strings.Contains(string(body), `"message":"`) {
|
||||
t.Fatalf("body = %q, want a human-readable message", body)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseCodePropagates is (e): a clean close from the service reaches the
|
||||
// caller with the same code and reason, so "the admin disarmed the alert" is
|
||||
// distinguishable from "the network died".
|
||||
func TestCloseCodePropagates(t *testing.T) {
|
||||
_, upstreamURL := startUpstream(t, func(c *websocket.Conn) {
|
||||
if _, _, err := c.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
_ = c.WriteControl(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "admin disarmed the alert"),
|
||||
time.Now().Add(time.Second))
|
||||
})
|
||||
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
|
||||
conn := dialProxy(t, srv, "/api/alarm/ws/alarm", nil)
|
||||
|
||||
if err := conn.WriteMessage(websocket.TextMessage, []byte("hello")); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||
t.Fatalf("set read deadline: %v", err)
|
||||
}
|
||||
_, _, err := conn.ReadMessage()
|
||||
var closeErr *websocket.CloseError
|
||||
if !errors.As(err, &closeErr) {
|
||||
t.Fatalf("read = %v, want a close error", err)
|
||||
}
|
||||
if closeErr.Code != websocket.CloseNormalClosure {
|
||||
t.Fatalf("close code = %d, want %d", closeErr.Code, websocket.CloseNormalClosure)
|
||||
}
|
||||
if !strings.Contains(closeErr.Text, "admin disarmed") {
|
||||
t.Fatalf("close reason = %q, want it to carry the service's reason", closeErr.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdleConnectionSurvives is (f): no proxy deadline kills a quiet socket.
|
||||
// A 2.5s idle gap is far beyond anything a naive 1s proxy deadline would allow,
|
||||
// and the session must still work afterwards.
|
||||
func TestIdleConnectionSurvives(t *testing.T) {
|
||||
_, upstreamURL := startUpstream(t, func(c *websocket.Conn) {
|
||||
for {
|
||||
messageType, data, err := c.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := c.WriteMessage(messageType, data); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
|
||||
conn := dialProxy(t, srv, "/api/alarm/ws/alarm", nil)
|
||||
|
||||
// Prove the socket is quiet but alive: say nothing for 2.5s, then use it.
|
||||
time.Sleep(2500 * time.Millisecond)
|
||||
|
||||
if err := conn.WriteMessage(websocket.TextMessage, []byte("after-idle")); err != nil {
|
||||
t.Fatalf("write after idle: %v", err)
|
||||
}
|
||||
if got := readText(t, conn); got != "after-idle" {
|
||||
t.Fatalf("frame after idle = %q, want after-idle", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPingRelayedNotAnswered is the liveness test: control frames are forwarded
|
||||
// to the other hop instead of being answered by Maestro.
|
||||
//
|
||||
// The caller has a custom ping handler that records the ping it receives, so a
|
||||
// proxy that answered pings itself would leave that channel empty (the alarm
|
||||
// service's ping would never reach the phone). The service's pong channel proves
|
||||
// the return trip: phone pong → proxy → service.
|
||||
func TestPingRelayedNotAnswered(t *testing.T) {
|
||||
servicePongs := make(chan string, 1)
|
||||
_, upstreamURL := startUpstream(t, func(c *websocket.Conn) {
|
||||
c.SetPongHandler(func(appData string) error {
|
||||
select {
|
||||
case servicePongs <- appData:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err := c.WriteControl(websocket.PingMessage, []byte("service-ping"), time.Now().Add(time.Second)); err != nil {
|
||||
return
|
||||
}
|
||||
// Keep reading so the pong can arrive.
|
||||
_ = c.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
for {
|
||||
if _, _, err := c.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
|
||||
conn := dialProxy(t, srv, "/api/alarm/ws/alarm", nil)
|
||||
|
||||
callerPings := make(chan string, 1)
|
||||
conn.SetPingHandler(func(appData string) error {
|
||||
select {
|
||||
case callerPings <- appData:
|
||||
default:
|
||||
}
|
||||
return conn.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(time.Second))
|
||||
})
|
||||
// The caller must read for control frames to be processed at all.
|
||||
go func() {
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case got := <-callerPings:
|
||||
if got != "service-ping" {
|
||||
t.Fatalf("relayed ping payload = %q, want service-ping", got)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the service's ping never reached the caller — the proxy answered it locally")
|
||||
}
|
||||
|
||||
select {
|
||||
case got := <-servicePongs:
|
||||
if got != "service-ping" {
|
||||
t.Fatalf("pong payload = %q, want service-ping", got)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the caller's pong never reached the service — the ping/pong path is not end-to-end")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathScoping is (3): only the service's /ws namespace is reachable.
|
||||
func TestPathScoping(t *testing.T) {
|
||||
// Unit level: the only paths that can be produced start at /ws.
|
||||
paths := []struct {
|
||||
sub string
|
||||
wantPath string
|
||||
wantOK bool
|
||||
}{
|
||||
{"", "/ws", true},
|
||||
{"alarm", "/ws/alarm", true},
|
||||
{"alarm/v2", "/ws/alarm/v2", true},
|
||||
{"/alarm", "/ws/alarm", true},
|
||||
{"../config", "", false},
|
||||
{"a/../config", "", false},
|
||||
{"..", "", false},
|
||||
{"a//b", "", false},
|
||||
{"alarm?x=1", "", false},
|
||||
{"alarm#x", "", false},
|
||||
{"%2e%2e", "", false},
|
||||
}
|
||||
for _, test := range paths {
|
||||
gotPath, ok := upstreamPath(namespaceWS, test.sub)
|
||||
if ok != test.wantOK || gotPath != test.wantPath {
|
||||
t.Fatalf("upstreamPath(ws, %q) = (%q, %v), want (%q, %v)", test.sub, gotPath, ok, test.wantPath, test.wantOK)
|
||||
}
|
||||
}
|
||||
|
||||
// Route level: /api/{service}/config is not a proxy route (404), and the
|
||||
// sub-path variant reaches the service's own /ws/alarm path.
|
||||
rec, upstreamURL := startUpstream(t, func(c *websocket.Conn) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
})
|
||||
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
|
||||
|
||||
_ = dialProxy(t, srv, "/api/alarm/ws/alarm", nil)
|
||||
if path, _, _ := rec.snapshot(); path != "/ws/alarm" {
|
||||
t.Fatalf("upstream path = %q, want /ws/alarm", path)
|
||||
}
|
||||
|
||||
for _, path := range []string{"/api/alarm/config", "/api/typo"} {
|
||||
resp, err := http.Get(srv.URL + path)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if readErr != nil {
|
||||
t.Fatalf("read %s body: %v", path, readErr)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("GET %s = %d, want 404 — this route must not tunnel to /config", path, resp.StatusCode)
|
||||
}
|
||||
// The fallback's own 404 must use Maestro's JSON envelope, not plain text:
|
||||
// the phone clients unwrap {error:{code,message}} into a rendered sentence.
|
||||
if got := resp.Header.Get("Content-Type"); !strings.Contains(got, "application/json") {
|
||||
t.Fatalf("GET %s Content-Type = %q, want application/json", path, got)
|
||||
}
|
||||
if !strings.Contains(string(body), `"code":"ERR_NOT_FOUND"`) ||
|
||||
!strings.Contains(string(body), `"message":"unknown API path"`) {
|
||||
t.Fatalf("GET %s body = %q, want the ERR_NOT_FOUND envelope with a message", path, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentSessions keeps -race honest: several proxied sessions at once,
|
||||
// each with its own frames.
|
||||
func TestConcurrentSessions(t *testing.T) {
|
||||
_, upstreamURL := startUpstream(t, func(c *websocket.Conn) {
|
||||
for {
|
||||
messageType, data, err := c.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := c.WriteMessage(messageType, data); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL(t, srv, "/api/alarm/ws"), nil)
|
||||
if err != nil {
|
||||
t.Errorf("session %d dial: %v", id, err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
payload := fmt.Sprintf("session-%d", id)
|
||||
if err := conn.WriteMessage(websocket.TextMessage, []byte(payload)); err != nil {
|
||||
t.Errorf("session %d write: %v", id, err)
|
||||
return
|
||||
}
|
||||
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Errorf("session %d read: %v", id, err)
|
||||
return
|
||||
}
|
||||
if string(data) != payload {
|
||||
t.Errorf("session %d got %q, want %q", id, data, payload)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestProductionPatternSetDoesNotPanic constructs the SAME pattern set main.go
|
||||
// constructs, because a ServeMux conflict is a boot panic that `go build` and
|
||||
// `go vet` cannot see. The data plane is a single "/api/" fallback precisely so
|
||||
// that no pattern in this set can collide with the legacy action route.
|
||||
func TestProductionPatternSetDoesNotPanic(t *testing.T) {
|
||||
marker := func(name string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, name)
|
||||
})
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
// The console's own routes, exactly as main.go registers them.
|
||||
mux.Handle("/api/instance", marker("instance"))
|
||||
mux.Handle("/api/containers", marker("containers"))
|
||||
mux.Handle("/api/actions/{containerId}/{actionName}", marker("legacy-action"))
|
||||
mux.Handle("/api/services/{service}/config", marker("config"))
|
||||
mux.Handle("/api/ws", marker("maestro-ws"))
|
||||
|
||||
// Panics here if the data plane ever regresses to per-shape patterns.
|
||||
RegisterWith(mux, resolverAnswer(runningPeer("http://127.0.0.1:1"), nil))
|
||||
|
||||
serve := func(path string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "http://x"+path, nil))
|
||||
return rec
|
||||
}
|
||||
|
||||
if rec := serve("/api/actions/abc/createAlert"); rec.Body.String() != "legacy-action" {
|
||||
t.Fatalf("legacy action route was shadowed: body = %q", rec.Body.String())
|
||||
}
|
||||
if rec := serve("/api/services/alarm/config"); rec.Body.String() != "config" {
|
||||
t.Fatalf("config route was shadowed: body = %q", rec.Body.String())
|
||||
}
|
||||
if rec := serve("/api/instance"); rec.Body.String() != "instance" {
|
||||
t.Fatalf("instance route was shadowed: body = %q", rec.Body.String())
|
||||
}
|
||||
|
||||
// The data plane answers its own namespaces through the fallback.
|
||||
if rec := serve("/api/alarm/ws"); rec.Code != http.StatusBadRequest ||
|
||||
!strings.Contains(rec.Body.String(), "only accepts WebSocket upgrades") {
|
||||
t.Fatalf("/api/alarm/ws = %d %q, want the data plane's 400", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := serve("/api/alarm/config"); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("/api/alarm/config = %d, want 404 — the data plane must not tunnel to /config", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNaivePatternSetPanics pins WHY the fallback exists: the literal patterns for
|
||||
// this surface cannot be registered next to the legacy action route. If a future
|
||||
// Go release makes this set legal, this test fails and the fallback can be
|
||||
// replaced by real patterns; until then, the fallback is the only option.
|
||||
func TestNaivePatternSetPanics(t *testing.T) {
|
||||
marker := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
|
||||
for _, naive := range []string{
|
||||
"/api/{service}/action/{actionName}",
|
||||
"/api/{service}/auth/{path...}",
|
||||
"/api/{service}/ws/{path...}",
|
||||
} {
|
||||
t.Run(naive, func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatalf("registering %q next to the legacy action route did NOT panic; "+
|
||||
"the /api/ fallback can be reconsidered", naive)
|
||||
}
|
||||
}()
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/api/actions/{containerId}/{actionName}", marker)
|
||||
mux.Handle(naive, marker)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpgradeOnAnotherNamespaceUsesWSRelay pins the single dispatch rule: upgrade
|
||||
// presence, not the namespace name, selects the WebSocket relay.
|
||||
func TestUpgradeOnAnotherNamespaceUsesWSRelay(t *testing.T) {
|
||||
rec, upstreamURL := startUpstream(t, func(c *websocket.Conn) {
|
||||
_ = c.WriteMessage(websocket.TextMessage, []byte("events-frame"))
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
})
|
||||
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
|
||||
|
||||
conn := dialProxy(t, srv, "/api/alarm/auth/events", nil)
|
||||
if got := readText(t, conn); got != "events-frame" {
|
||||
t.Fatalf("frame = %q, want events-frame", got)
|
||||
}
|
||||
if path, _, _ := rec.snapshot(); path != "/auth/events" {
|
||||
t.Fatalf("upstream path = %q, want /auth/events", path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// 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 serviceproxy answers the only question a Maestro proxy needs: where is
|
||||
// this service on the shared Docker network?
|
||||
//
|
||||
// It is the single implementation of that question — the service data plane
|
||||
// (handlers/dataplane: /api/{service}/action/... , /auth/... and /ws...) and the
|
||||
// legacy REST action proxy (/api/actions/{containerId}/{actionName}) both
|
||||
// resolve through it, so a service cannot be reachable on one edge and invisible
|
||||
// on the other.
|
||||
//
|
||||
// Resolution: Docker client → docker.ResolveContainer (which matches a container
|
||||
// by id, name, image or the org.dcntrlized.service label) → the container's IP on
|
||||
// dcntrlized_net → the port declared by the org.dcntrlized.port label, falling
|
||||
// back to the first mapped guest port. Rev 9: the service edge is plaintext HTTP
|
||||
// on the single :8080 listener, so the base URL is always http://.
|
||||
package serviceproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/authmgmt"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||
)
|
||||
|
||||
// Sentinel errors callers translate into HTTP statuses. Every one of them is
|
||||
// wrapped with the offending reference so the message stays human-readable.
|
||||
var (
|
||||
// ErrNotFound: the reference matches no container (unknown/uninstalled service).
|
||||
ErrNotFound = errors.New("service not found")
|
||||
// ErrNotRunning: the container exists but is stopped.
|
||||
ErrNotRunning = errors.New("service is not running")
|
||||
// ErrNoNetwork: the container has no usable dcntrlized_net address.
|
||||
ErrNoNetwork = errors.New("service has no network address")
|
||||
// ErrNoPort: the container declares no service port.
|
||||
ErrNoPort = errors.New("service has no configured port")
|
||||
// ErrDockerClient: a Docker client could not be constructed (bad DOCKER_HOST
|
||||
// / certificate config).
|
||||
ErrDockerClient = errors.New("docker client unavailable")
|
||||
// ErrDocker: the Docker daemon did not answer.
|
||||
ErrDocker = errors.New("docker unavailable")
|
||||
)
|
||||
|
||||
// Peer is a resolved, dialable service.
|
||||
type Peer struct {
|
||||
// Service is the container name without Docker's leading slash.
|
||||
Service string
|
||||
// IP is the container's address on dcntrlized_net.
|
||||
IP string
|
||||
// Port is the service's plaintext HTTP port.
|
||||
Port string
|
||||
// Running reports container state at resolution time.
|
||||
Running bool
|
||||
// BaseURL is scheme://ip:port, e.g. http://172.18.0.7:8080.
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
// Resolve locates a service by id, name, image or org.dcntrlized.service label.
|
||||
func Resolve(ctx context.Context, ref string) (*Peer, error) {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" {
|
||||
return nil, fmt.Errorf("%w: empty reference", ErrNotFound)
|
||||
}
|
||||
|
||||
dockerClient, err := docker.NewDockerClient()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrDockerClient, err)
|
||||
}
|
||||
defer func() { _ = dockerClient.Close() }()
|
||||
|
||||
inspectData, err := docker.ResolveContainer(ctx, dockerClient, ref)
|
||||
if err != nil {
|
||||
// ResolveContainer already searched by id, name, service label and image,
|
||||
// so a miss means "unknown reference" only if the daemon is answering.
|
||||
// Pinging distinguishes an unknown service (404) from a dead daemon (500)
|
||||
// instead of reporting every Docker problem as "not found".
|
||||
if _, pingErr := dockerClient.Client.Ping(ctx); pingErr != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrDocker, pingErr)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %q", ErrNotFound, ref)
|
||||
}
|
||||
|
||||
running := inspectData.State != nil && inspectData.State.Running
|
||||
service := strings.TrimPrefix(inspectData.Name, "/")
|
||||
|
||||
port := ""
|
||||
if inspectData.Config != nil {
|
||||
port = strings.TrimSpace(inspectData.Config.Labels["org.dcntrlized.port"])
|
||||
}
|
||||
|
||||
// ExtractNetworkInfo's bool is "this container uses a standard network", NOT
|
||||
// "we skipped it": it returns false for host/none/container networking. Read
|
||||
// it that way — inverting it here made every normal container look
|
||||
// unreachable (found by running the data plane against the real dev fleet,
|
||||
// where every service answered 500 "uses host/none/container networking").
|
||||
network, standard, err := docker.ExtractNetworkInfo(ctx, dockerClient, inspectData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract network info for %q: %w", ref, err)
|
||||
}
|
||||
if !standard {
|
||||
return nil, fmt.Errorf("%w: %q uses host/none/container networking", ErrNoNetwork, ref)
|
||||
}
|
||||
ip := network.IPAddress.Address
|
||||
if ip == "" {
|
||||
return nil, fmt.Errorf("%w: %q has no dcntrlized_net address", ErrNoNetwork, ref)
|
||||
}
|
||||
if port == "" && len(network.Ports) > 0 {
|
||||
port = strconv.Itoa(network.Ports[0].Guest)
|
||||
}
|
||||
if port == "" {
|
||||
return nil, fmt.Errorf("%w: %q", ErrNoPort, ref)
|
||||
}
|
||||
|
||||
return &Peer{
|
||||
Service: service,
|
||||
IP: ip,
|
||||
Port: port,
|
||||
Running: running,
|
||||
BaseURL: fmt.Sprintf("%s://%s:%s", authmgmt.Scheme(), ip, port),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Target returns the plaintext base URL of the referenced service, e.g.
|
||||
// http://172.18.0.7:8080. It is the convenience form for callers that only need
|
||||
// to build one URL.
|
||||
func Target(ctx context.Context, ref string) (string, error) {
|
||||
peer, err := Resolve(ctx, ref)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return peer.BaseURL, nil
|
||||
}
|
||||
|
||||
// Endpoint joins a resolved base URL with an absolute service path and a raw
|
||||
// query string (forwarded verbatim, never parsed and re-encoded).
|
||||
//
|
||||
// This is what handlers/dataplane uses for every namespace it relays:
|
||||
//
|
||||
// base, _ := serviceproxy.Target(ctx, service)
|
||||
// endpoint, _ := serviceproxy.Endpoint(base, "/action/"+action, r.URL.RawQuery)
|
||||
func Endpoint(baseURL, path, rawQuery string) (string, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bad service base URL %q: %w", baseURL, err)
|
||||
}
|
||||
u.Path = path
|
||||
u.RawQuery = rawQuery
|
||||
return u.String(), nil
|
||||
}
|
||||
Reference in New Issue
Block a user