This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
package reverseproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TokenSource interface {
|
||||
Token(context.Context) (string, error)
|
||||
}
|
||||
|
||||
type NPMClient struct {
|
||||
BaseURL string
|
||||
Token TokenSource
|
||||
HTTPClient *http.Client
|
||||
MaxBody int64
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (c NPMClient) ListRoutes(ctx context.Context) ([]RawRoute, error) {
|
||||
if ctx == nil {
|
||||
return nil, errors.New("reverse proxy context is nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base, err := url.Parse(strings.TrimSpace(c.BaseURL))
|
||||
if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" || base.User != nil || base.RawQuery != "" || base.Fragment != "" {
|
||||
return nil, errors.New("nginx proxy manager base URL is invalid")
|
||||
}
|
||||
token := ""
|
||||
if c.Token != nil {
|
||||
token, err = c.Token.Token(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errors.New("nginx proxy manager credential unavailable")
|
||||
}
|
||||
}
|
||||
client := c.HTTPClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
requestURL := strings.TrimRight(base.String(), "/") + "/api/nginx/proxy-hosts"
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("create nginx proxy manager request")
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if strings.TrimSpace(token) != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errors.New("nginx proxy manager request failed")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
maxBody := c.MaxBody
|
||||
if maxBody == 0 {
|
||||
maxBody = 2 << 20
|
||||
}
|
||||
if maxBody < 1024 || maxBody > 8<<20 {
|
||||
return nil, errors.New("nginx proxy manager body limit is outside bounds")
|
||||
}
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("nginx proxy manager returned status %d", response.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, maxBody+1))
|
||||
if err != nil {
|
||||
return nil, errors.New("read nginx proxy manager response")
|
||||
}
|
||||
if int64(len(body)) > maxBody {
|
||||
return nil, errors.New("nginx proxy manager response exceeds bounds")
|
||||
}
|
||||
var hosts []npmHost
|
||||
if err := json.Unmarshal(body, &hosts); err != nil {
|
||||
return nil, errors.New("decode nginx proxy manager response")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if c.Now != nil {
|
||||
now = c.Now().UTC()
|
||||
}
|
||||
routes := make([]RawRoute, 0, len(hosts))
|
||||
for _, host := range hosts {
|
||||
if len(host.DomainNames) > 32 {
|
||||
return nil, errors.New("nginx proxy manager hostnames exceed bounds")
|
||||
}
|
||||
id := string(host.ID)
|
||||
if decoded, decodeErr := strconv.Unquote(id); decodeErr == nil {
|
||||
id = decoded
|
||||
}
|
||||
if strings.TrimSpace(id) == "" || len(id) > 128 {
|
||||
return nil, errors.New("nginx proxy manager route id is invalid")
|
||||
}
|
||||
scheme := strings.ToLower(strings.TrimSpace(host.ForwardScheme))
|
||||
if scheme == "" {
|
||||
scheme = "http"
|
||||
}
|
||||
targetServiceID := ""
|
||||
if value, ok := host.Meta["pulse_service_id"].(string); ok {
|
||||
targetServiceID = value
|
||||
}
|
||||
for _, domain := range host.DomainNames {
|
||||
routes = append(routes, RawRoute{ID: id + ":" + domain, SourceID: "npm", Hostname: domain, Scheme: scheme, Port: host.Port, TargetServiceID: targetServiceID, TargetHost: host.ForwardHost, Enabled: host.Enabled, Description: "Nginx Proxy Manager", ObservedAt: now})
|
||||
}
|
||||
}
|
||||
if len(routes) > 1000 {
|
||||
return nil, errors.New("nginx proxy manager route count exceeds bounds")
|
||||
}
|
||||
return routes, nil
|
||||
}
|
||||
|
||||
type npmHost struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
DomainNames []string `json:"domain_names"`
|
||||
ForwardScheme string `json:"forward_scheme"`
|
||||
ForwardHost string `json:"forward_host"`
|
||||
Port int `json:"forward_port"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Meta map[string]any `json:"meta"`
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package reverseproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type staticToken string
|
||||
|
||||
func (s staticToken) Token(context.Context) (string, error) { return string(s), nil }
|
||||
|
||||
func TestNPMClientReadsOnlyProxyHostsAndUsesExternalToken(t *testing.T) {
|
||||
var method, authorization, path string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
method, authorization, path = r.Method, r.Header.Get("Authorization"), r.URL.Path
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte("[{\"id\":7,\"domain_names\":[\"pulse.example.test\"],\"forward_scheme\":\"http\",\"forward_host\":\"10.0.0.7\",\"forward_port\":8080,\"enabled\":true,\"meta\":{\"pulse_service_id\":\"svc-api\"}}]"))
|
||||
}))
|
||||
defer server.Close()
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
routes, err := (NPMClient{BaseURL: server.URL, Token: staticToken("external-token"), Now: func() time.Time { return now }}).ListRoutes(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if method != http.MethodGet || path != "/api/nginx/proxy-hosts" || authorization != "Bearer external-token" {
|
||||
t.Fatalf("unexpected request method=%s path=%s authorization=%s", method, path, authorization)
|
||||
}
|
||||
if len(routes) != 1 || routes[0].ID != "7:pulse.example.test" || routes[0].TargetServiceID != "svc-api" || routes[0].SourceID != "npm" {
|
||||
t.Fatalf("unexpected routes: %+v", routes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNPMClientRejectsMutationAndOversizedResponses(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("unexpected method %s", r.Method)
|
||||
}
|
||||
_, _ = w.Write([]byte("[]"))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := NPMClient{BaseURL: server.URL, MaxBody: 1024}
|
||||
if _, err := client.ListRoutes(context.Background()); err != nil {
|
||||
t.Fatalf("small valid response should pass: %v", err)
|
||||
}
|
||||
if _, err := (NPMClient{BaseURL: server.URL, MaxBody: 9 << 20}).ListRoutes(context.Background()); err == nil {
|
||||
t.Fatal("expected unsafe body bound")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := client.ListRoutes(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected cancellation, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
package reverseproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/datasource"
|
||||
)
|
||||
|
||||
const ContractVersion = "v1"
|
||||
|
||||
const (
|
||||
StateEnabled = "enabled"
|
||||
StateDisabled = "disabled"
|
||||
StateUnknown = "unknown"
|
||||
Fresh = "fresh"
|
||||
Unavailable = "unavailable"
|
||||
)
|
||||
|
||||
type Limits struct {
|
||||
MaxRoutes int
|
||||
MaxHostnames int
|
||||
MaxDescription int
|
||||
}
|
||||
|
||||
func (l Limits) withDefaults() Limits {
|
||||
if l.MaxRoutes == 0 {
|
||||
l.MaxRoutes = 150
|
||||
}
|
||||
if l.MaxHostnames == 0 {
|
||||
l.MaxHostnames = 8
|
||||
}
|
||||
if l.MaxDescription == 0 {
|
||||
l.MaxDescription = 256
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (l Limits) Validate() error {
|
||||
if l.MaxRoutes < 1 || l.MaxRoutes > 1000 || l.MaxHostnames < 1 || l.MaxHostnames > 32 || l.MaxDescription < 1 || l.MaxDescription > 1000 {
|
||||
return errors.New("reverse proxy limits are outside safe bounds")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
CapabilityVersion string `json:"capabilityVersion"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Freshness string `json:"freshness"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Capabilities datasource.CapabilitySet `json:"capabilities"`
|
||||
}
|
||||
|
||||
type RawRoute struct {
|
||||
ID string
|
||||
SourceID string
|
||||
Hostname string
|
||||
Scheme string
|
||||
Port int
|
||||
TargetServiceID string
|
||||
TargetHost string
|
||||
Enabled bool
|
||||
Description string
|
||||
ObservedAt time.Time
|
||||
}
|
||||
|
||||
type Route struct {
|
||||
ID string `json:"id"`
|
||||
SourceID string `json:"sourceId"`
|
||||
Hostname string `json:"hostname"`
|
||||
Scheme string `json:"scheme"`
|
||||
Port int `json:"port,omitempty"`
|
||||
URL string `json:"url"`
|
||||
TargetServiceID string `json:"targetServiceId,omitempty"`
|
||||
TargetHost string `json:"targetHost,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Description string `json:"description,omitempty"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
Overridden bool `json:"overridden"`
|
||||
OverrideSource string `json:"overrideSource,omitempty"`
|
||||
}
|
||||
|
||||
type Override struct {
|
||||
Hostname string
|
||||
TargetServiceID string
|
||||
UserID string
|
||||
Confirmed bool
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
ContractVersion string `json:"contractVersion"`
|
||||
Source Source `json:"source"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
Routes []Route `json:"routes"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type Provider interface {
|
||||
Snapshot(context.Context) (Snapshot, error)
|
||||
}
|
||||
|
||||
type ReadOnlyClient interface {
|
||||
ListRoutes(context.Context) ([]RawRoute, error)
|
||||
}
|
||||
|
||||
type Connector struct {
|
||||
Enabled bool
|
||||
SourceID string
|
||||
SourceType string
|
||||
EndpointURL string
|
||||
CredentialRef string
|
||||
Client ReadOnlyClient
|
||||
Overrides []Override
|
||||
Limits Limits
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (c Connector) Snapshot(ctx context.Context) (Snapshot, error) {
|
||||
if ctx == nil {
|
||||
return Snapshot{}, errors.New("reverse proxy context is nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
now := c.now()
|
||||
if !c.Enabled {
|
||||
return DisabledSnapshot(now, c.sourceID(), c.sourceType(), "connector_disabled"), nil
|
||||
}
|
||||
if c.Client == nil {
|
||||
return UnknownSnapshot(now, c.sourceID(), c.sourceType(), "connector_client_unavailable"), nil
|
||||
}
|
||||
routes, err := c.Client.ListRoutes(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
return UnknownSnapshot(now, c.sourceID(), c.sourceType(), "connector_unavailable"), nil
|
||||
}
|
||||
return BuildSnapshot(now, Source{ID: c.sourceID(), Type: c.sourceType(), State: StateEnabled, Freshness: Fresh, ObservedAt: now, ReceivedAt: now}, routes, c.Overrides, c.Limits)
|
||||
}
|
||||
|
||||
func (c Connector) Capabilities(ctx context.Context) (datasource.CapabilitySet, error) {
|
||||
if ctx == nil {
|
||||
return nil, errors.New("reverse proxy context is nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state := datasource.CapabilityDisabled
|
||||
reason := "connector_disabled"
|
||||
if c.Enabled {
|
||||
state = datasource.CapabilityUnavailable
|
||||
reason = "connector_client_unavailable"
|
||||
if c.Client != nil {
|
||||
state = datasource.CapabilityEnabled
|
||||
reason = ""
|
||||
}
|
||||
}
|
||||
return datasource.CapabilitySet{{ID: "reverse_proxy.routes", Version: ContractVersion, State: state, Description: "Read-only reverse-proxy host to service mapping.", ReasonCode: reason, ObservedAt: c.now()}}, nil
|
||||
}
|
||||
|
||||
type DisabledProvider struct {
|
||||
SourceID string
|
||||
SourceType string
|
||||
Reason string
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (p DisabledProvider) Snapshot(ctx context.Context) (Snapshot, error) {
|
||||
if ctx == nil {
|
||||
return Snapshot{}, errors.New("reverse proxy context is nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
reason := p.Reason
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
reason = "connector_disabled"
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if p.Now != nil {
|
||||
now = p.Now().UTC()
|
||||
}
|
||||
return DisabledSnapshot(now, p.sourceID(), p.sourceType(), reason), nil
|
||||
}
|
||||
|
||||
func BuildSnapshot(now time.Time, source Source, raw []RawRoute, overrides []Override, limits Limits) (Snapshot, error) {
|
||||
limits = limits.withDefaults()
|
||||
if err := limits.Validate(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
now = now.UTC()
|
||||
if strings.TrimSpace(source.ID) == "" || len(source.ID) > 120 {
|
||||
return Snapshot{}, errors.New("reverse proxy source id is required and bounded")
|
||||
}
|
||||
if strings.TrimSpace(source.Type) == "" || len(source.Type) > 64 {
|
||||
return Snapshot{}, errors.New("reverse proxy source type is required and bounded")
|
||||
}
|
||||
if len(raw) > limits.MaxRoutes {
|
||||
return Snapshot{}, errors.New("reverse proxy route count exceeds bounds")
|
||||
}
|
||||
if source.CapabilityVersion == "" {
|
||||
source.CapabilityVersion = ContractVersion
|
||||
}
|
||||
if source.ReceivedAt.IsZero() {
|
||||
source.ReceivedAt = now
|
||||
}
|
||||
if source.ObservedAt.IsZero() {
|
||||
source.ObservedAt = source.ReceivedAt
|
||||
}
|
||||
source.ObservedAt, source.ReceivedAt = source.ObservedAt.UTC(), source.ReceivedAt.UTC()
|
||||
source.Capabilities = capabilitySet(source.State, source.Reason, source.ObservedAt)
|
||||
overrideByHost, err := normalizeOverrides(overrides)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
routes := make([]Route, 0, len(raw))
|
||||
seenIDs := make(map[string]struct{}, len(raw))
|
||||
seenHosts := make(map[string]int, len(raw))
|
||||
for _, item := range raw {
|
||||
route, err := normalizeRoute(item, source.ID, limits)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if _, exists := seenIDs[route.ID]; exists {
|
||||
return Snapshot{}, fmt.Errorf("duplicate reverse proxy route %q", route.ID)
|
||||
}
|
||||
seenIDs[route.ID] = struct{}{}
|
||||
if previous, exists := seenHosts[route.Hostname]; exists {
|
||||
if routes[previous].TargetServiceID != route.TargetServiceID {
|
||||
override, confirmed := overrideByHost[route.Hostname]
|
||||
if !confirmed || override.TargetServiceID == "" {
|
||||
return Snapshot{}, fmt.Errorf("reverse proxy host conflict for %q requires confirmed override", route.Hostname)
|
||||
}
|
||||
if err := applyOverride(&routes[previous], override); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
return Snapshot{}, fmt.Errorf("duplicate reverse proxy hostname %q", route.Hostname)
|
||||
}
|
||||
if override, ok := overrideByHost[route.Hostname]; ok {
|
||||
if err := applyOverride(&route, override); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
}
|
||||
seenHosts[route.Hostname] = len(routes)
|
||||
routes = append(routes, route)
|
||||
}
|
||||
sort.SliceStable(routes, func(i, j int) bool {
|
||||
if routes[i].Hostname != routes[j].Hostname {
|
||||
return routes[i].Hostname < routes[j].Hostname
|
||||
}
|
||||
return routes[i].ID < routes[j].ID
|
||||
})
|
||||
return Snapshot{ContractVersion: ContractVersion, Source: source, ObservedAt: source.ObservedAt, Routes: routes, Total: len(routes)}, nil
|
||||
}
|
||||
|
||||
func DisabledSnapshot(now time.Time, sourceID, sourceType, reason string) Snapshot {
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
return Snapshot{ContractVersion: ContractVersion, Source: Source{ID: bound(sourceID, 120), Type: bound(sourceType, 64), CapabilityVersion: ContractVersion, ObservedAt: now.UTC(), ReceivedAt: now.UTC(), Freshness: Unavailable, State: StateDisabled, Reason: bound(reason, 128), Capabilities: capabilitySet(StateDisabled, reason, now)}, ObservedAt: now.UTC(), Routes: []Route{}, Total: 0}
|
||||
}
|
||||
|
||||
func UnknownSnapshot(now time.Time, sourceID, sourceType, reason string) Snapshot {
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
return Snapshot{ContractVersion: ContractVersion, Source: Source{ID: bound(sourceID, 120), Type: bound(sourceType, 64), CapabilityVersion: ContractVersion, ObservedAt: now.UTC(), ReceivedAt: now.UTC(), Freshness: Unavailable, State: StateUnknown, Reason: bound(reason, 128), Capabilities: capabilitySet(StateUnknown, reason, now)}, ObservedAt: now.UTC(), Routes: []Route{}, Total: 0}
|
||||
}
|
||||
|
||||
func normalizeRoute(raw RawRoute, fallbackSource string, limits Limits) (Route, error) {
|
||||
id := strings.TrimSpace(raw.ID)
|
||||
if id == "" || len(id) > 128 {
|
||||
return Route{}, errors.New("reverse proxy route id is required and bounded")
|
||||
}
|
||||
sourceID := strings.TrimSpace(raw.SourceID)
|
||||
if sourceID == "" {
|
||||
sourceID = fallbackSource
|
||||
}
|
||||
hostname, err := normalizeHostname(raw.Hostname)
|
||||
if err != nil {
|
||||
return Route{}, err
|
||||
}
|
||||
scheme := strings.ToLower(strings.TrimSpace(raw.Scheme))
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return Route{}, errors.New("reverse proxy route scheme must be http or https")
|
||||
}
|
||||
if raw.Port < 0 || raw.Port > 65535 {
|
||||
return Route{}, errors.New("reverse proxy route port is invalid")
|
||||
}
|
||||
targetHost := strings.TrimSpace(raw.TargetHost)
|
||||
if len(targetHost) > 253 || strings.ContainsAny(targetHost, "/@?#") {
|
||||
return Route{}, errors.New("reverse proxy target host is invalid")
|
||||
}
|
||||
if targetHost != "" && net.ParseIP(targetHost) == nil {
|
||||
if _, err := normalizeHostname(targetHost); err != nil {
|
||||
return Route{}, errors.New("reverse proxy target host is invalid")
|
||||
}
|
||||
}
|
||||
description := strings.TrimSpace(raw.Description)
|
||||
if len(description) > limits.MaxDescription {
|
||||
description = description[:limits.MaxDescription]
|
||||
}
|
||||
observedAt := raw.ObservedAt.UTC()
|
||||
if observedAt.IsZero() {
|
||||
observedAt = time.Now().UTC()
|
||||
}
|
||||
return Route{ID: id, SourceID: bound(sourceID, 120), Hostname: hostname, Scheme: scheme, Port: raw.Port, URL: routeURL(scheme, hostname, raw.Port), TargetServiceID: bound(strings.TrimSpace(raw.TargetServiceID), 128), TargetHost: targetHost, Enabled: raw.Enabled, Description: description, ObservedAt: observedAt}, nil
|
||||
}
|
||||
|
||||
func normalizeOverrides(overrides []Override) (map[string]Override, error) {
|
||||
byHost := make(map[string]Override, len(overrides))
|
||||
for _, override := range overrides {
|
||||
hostname, err := normalizeHostname(override.Hostname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !override.Confirmed || strings.TrimSpace(override.TargetServiceID) == "" || strings.TrimSpace(override.UserID) == "" {
|
||||
return nil, fmt.Errorf("reverse proxy override for %q is not user-confirmed", hostname)
|
||||
}
|
||||
if _, exists := byHost[hostname]; exists {
|
||||
return nil, fmt.Errorf("duplicate reverse proxy override for %q", hostname)
|
||||
}
|
||||
override.Hostname, override.TargetServiceID, override.UserID = hostname, strings.TrimSpace(override.TargetServiceID), strings.TrimSpace(override.UserID)
|
||||
byHost[hostname] = override
|
||||
}
|
||||
return byHost, nil
|
||||
}
|
||||
|
||||
func applyOverride(route *Route, override Override) error {
|
||||
if route == nil || !override.Confirmed || override.TargetServiceID == "" || override.UserID == "" {
|
||||
return errors.New("reverse proxy override is not user-confirmed")
|
||||
}
|
||||
route.TargetServiceID = override.TargetServiceID
|
||||
route.Overridden = true
|
||||
route.OverrideSource = "user:" + bound(override.UserID, 120)
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeHostname(value string) (string, error) {
|
||||
value = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(value), "."))
|
||||
if value == "" || len(value) > 253 || strings.ContainsAny(value, "/@?#: ") {
|
||||
return "", errors.New("reverse proxy hostname is invalid")
|
||||
}
|
||||
value = strings.TrimPrefix(value, "*.")
|
||||
if value == "" {
|
||||
return "", errors.New("reverse proxy hostname is invalid")
|
||||
}
|
||||
for _, label := range strings.Split(value, ".") {
|
||||
if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
||||
return "", errors.New("reverse proxy hostname is invalid")
|
||||
}
|
||||
for _, character := range label {
|
||||
if (character < 'a' || character > 'z') && (character < '0' || character > '9') && character != '-' {
|
||||
return "", errors.New("reverse proxy hostname is invalid")
|
||||
}
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func routeURL(scheme, hostname string, port int) string {
|
||||
host := hostname
|
||||
if port > 0 && (scheme != "http" || port != 80) && (scheme != "https" || port != 443) {
|
||||
host = net.JoinHostPort(hostname, fmt.Sprintf("%d", port))
|
||||
}
|
||||
parsed := url.URL{Scheme: scheme, Host: host}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func capabilitySet(state, reason string, observedAt time.Time) datasource.CapabilitySet {
|
||||
capabilityState := datasource.CapabilityUnavailable
|
||||
switch state {
|
||||
case StateEnabled:
|
||||
capabilityState = datasource.CapabilityEnabled
|
||||
case StateDisabled:
|
||||
capabilityState = datasource.CapabilityDisabled
|
||||
}
|
||||
return datasource.CapabilitySet{{ID: "reverse_proxy.routes", Version: ContractVersion, State: capabilityState, Description: "Read-only reverse-proxy host to service mapping.", ReasonCode: bound(reason, 80), ObservedAt: observedAt.UTC()}}
|
||||
}
|
||||
|
||||
func (c Connector) now() time.Time {
|
||||
if c.Now != nil {
|
||||
return c.Now().UTC()
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
func (c Connector) sourceID() string {
|
||||
if strings.TrimSpace(c.SourceID) != "" {
|
||||
return bound(c.SourceID, 120)
|
||||
}
|
||||
return "reverse-proxy"
|
||||
}
|
||||
func (c Connector) sourceType() string {
|
||||
if strings.TrimSpace(c.SourceType) != "" {
|
||||
return bound(c.SourceType, 64)
|
||||
}
|
||||
return "reverse_proxy"
|
||||
}
|
||||
func (p DisabledProvider) sourceID() string {
|
||||
if strings.TrimSpace(p.SourceID) != "" {
|
||||
return bound(p.SourceID, 120)
|
||||
}
|
||||
return "reverse-proxy"
|
||||
}
|
||||
func (p DisabledProvider) sourceType() string {
|
||||
if strings.TrimSpace(p.SourceType) != "" {
|
||||
return bound(p.SourceType, 64)
|
||||
}
|
||||
return "reverse_proxy"
|
||||
}
|
||||
func bound(value string, max int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) > max {
|
||||
return value[:max]
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package reverseproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type routeClient struct {
|
||||
routes []RawRoute
|
||||
err error
|
||||
}
|
||||
|
||||
func (c routeClient) ListRoutes(context.Context) ([]RawRoute, error) { return c.routes, c.err }
|
||||
|
||||
func TestBuildSnapshotNormalizesRoutesAndPreservesSourceOwnership(t *testing.T) {
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
snapshot, err := BuildSnapshot(now, Source{ID: "npm-prod", Type: "nginx_proxy_manager", ObservedAt: now, ReceivedAt: now}, []RawRoute{{ID: "host-2", Hostname: "B.Example.test.", Scheme: "HTTPS", Port: 443, TargetServiceID: "svc-b", TargetHost: "10.0.0.20", Enabled: true, SourceID: "npm-prod", ObservedAt: now}}, nil, Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(snapshot.Routes) != 1 || snapshot.Routes[0].Hostname != "b.example.test" || snapshot.Routes[0].URL != "https://b.example.test" || snapshot.Routes[0].SourceID != "npm-prod" {
|
||||
t.Fatalf("unexpected route projection: %+v", snapshot.Routes)
|
||||
}
|
||||
if snapshot.Source.Capabilities[0].State != "unavailable" {
|
||||
t.Fatalf("expected source capability to remain unavailable without a connector state, got %+v", snapshot.Source.Capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSnapshotRequiresConfirmedOverrideForHostConflict(t *testing.T) {
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
routes := []RawRoute{{ID: "a", Hostname: "pulse.example.test", Scheme: "https", TargetServiceID: "svc-a", Enabled: true}, {ID: "b", Hostname: "pulse.example.test", Scheme: "https", TargetServiceID: "svc-b", Enabled: true}}
|
||||
if _, err := BuildSnapshot(now, Source{ID: "npm", Type: "npm"}, routes, nil, Limits{}); err == nil {
|
||||
t.Fatal("expected unconfirmed route conflict")
|
||||
}
|
||||
snapshot, err := BuildSnapshot(now, Source{ID: "npm", Type: "npm"}, routes, []Override{{Hostname: "pulse.example.test", TargetServiceID: "svc-confirmed", UserID: "operator-1", Confirmed: true}}, Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(snapshot.Routes) != 1 || snapshot.Routes[0].TargetServiceID != "svc-confirmed" || !snapshot.Routes[0].Overridden || snapshot.Routes[0].OverrideSource != "user:operator-1" {
|
||||
t.Fatalf("unexpected confirmed override: %+v", snapshot.Routes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectorDisabledAndUnavailableAreNonFatalAndNeverExposeCredentialReference(t *testing.T) {
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
disabled := Connector{SourceID: "npm", CredentialRef: "secret/npm-token", Now: func() time.Time { return now }}
|
||||
snapshot, err := disabled.Snapshot(context.Background())
|
||||
if err != nil || snapshot.Source.State != StateDisabled || snapshot.Total != 0 {
|
||||
t.Fatalf("unexpected disabled snapshot: %+v, %v", snapshot, err)
|
||||
}
|
||||
payload, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(payload), "secret/npm-token") {
|
||||
t.Fatalf("credential reference leaked into snapshot: %s", payload)
|
||||
}
|
||||
unavailable := Connector{Enabled: true, SourceID: "npm", Client: routeClient{err: errors.New("connection refused")}, Now: func() time.Time { return now }}
|
||||
snapshot, err = unavailable.Snapshot(context.Background())
|
||||
if err != nil || snapshot.Source.State != StateUnknown || snapshot.Source.Reason != "connector_unavailable" {
|
||||
t.Fatalf("unexpected unavailable snapshot: %+v, %v", snapshot, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectorPropagatesCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err := (Connector{}).Snapshot(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected cancellation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSnapshotTargetScaleIsBounded(t *testing.T) {
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
raw := make([]RawRoute, 150)
|
||||
for index := range raw {
|
||||
raw[index] = RawRoute{ID: fmt.Sprintf("route-%03d", index), Hostname: fmt.Sprintf("service-%03d.example.test", index), Scheme: "https", TargetServiceID: fmt.Sprintf("svc-%03d", index), Enabled: true, ObservedAt: now}
|
||||
}
|
||||
snapshot, err := BuildSnapshot(now, Source{ID: "npm", Type: "npm", State: StateEnabled}, raw, nil, Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.Total != 150 || len(snapshot.Routes) != 150 {
|
||||
t.Fatalf("target-scale routes were not preserved within bounds: total=%d len=%d", snapshot.Total, len(snapshot.Routes))
|
||||
}
|
||||
if _, err := BuildSnapshot(now, Source{ID: "npm", Type: "npm", State: StateEnabled}, append(raw, RawRoute{ID: "route-over", Hostname: "over.example.test", Scheme: "https", Enabled: true}), nil, Limits{}); err == nil {
|
||||
t.Fatal("expected route bound rejection above target")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user