[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,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