Refactor RDP proxy handling and update related tests
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
package webingress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const AdminRuntimeResponseSchema = "rap.web_ingress.admin_runtime_response.v1"
|
||||
const ControlAPIProjectionRequestSchema = "rap.web_ingress.control_api_projection_request.v1"
|
||||
const ControlAPIProjectionResponseSchema = "rap.web_ingress.control_api_projection_response.v1"
|
||||
|
||||
type AdminRuntimeDispatcher struct {
|
||||
ProjectionClient ControlAPIProjectionClient
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type ControlAPIProjectionClient interface {
|
||||
Project(ctx context.Context, request ControlAPIProjectionRequest) (ControlAPIProjectionResponse, error)
|
||||
}
|
||||
|
||||
type ControlAPIProjectionRequest struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Query string `json:"query,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Scope string `json:"scope"`
|
||||
ServiceClass string `json:"service_class"`
|
||||
ObservedAt string `json:"observed_at"`
|
||||
}
|
||||
|
||||
type ControlAPIProjectionResponse struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
StatusCode int `json:"status_code"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Body json.RawMessage `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
type AdminRuntimeJSONResponse struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ServiceClass string `json:"service_class,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Manifest map[string]any `json:"manifest,omitempty"`
|
||||
ObservedAt string `json:"observed_at"`
|
||||
}
|
||||
|
||||
func (d AdminRuntimeDispatcher) HandleFabricRequest(ctx context.Context, request FabricRequest) (FabricResponse, error) {
|
||||
method := strings.ToUpper(strings.TrimSpace(request.Method))
|
||||
path := normalizeRuntimePath(request.Path)
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
if !allowedAdminRuntimeScope(strings.TrimSpace(request.Scope), strings.TrimSpace(request.ServiceClass)) {
|
||||
return d.json(http.StatusForbidden, request, "blocked", "admin_runtime_scope_rejected", nil), nil
|
||||
}
|
||||
switch {
|
||||
case method == http.MethodGet && (path == "/healthz" || path == "/readyz"):
|
||||
return d.json(http.StatusOK, request, "ready", "admin_runtime_ready", nil), nil
|
||||
case d.ProjectionClient != nil && (method == http.MethodGet || method == http.MethodHead):
|
||||
return d.project(ctx, request)
|
||||
case method == http.MethodGet && (path == "/ui-manifest" || strings.HasSuffix(path, "/ui-manifest")):
|
||||
return d.json(http.StatusOK, request, "ready", "ui_manifest_ready", d.manifest(request)), nil
|
||||
case method != http.MethodGet && method != http.MethodHead:
|
||||
return d.json(http.StatusForbidden, request, "blocked", "control_api_mutation_binding_not_implemented", nil), nil
|
||||
default:
|
||||
return d.json(http.StatusNotImplemented, request, "blocked", "control_api_projection_binding_not_implemented", nil), nil
|
||||
}
|
||||
}
|
||||
|
||||
func allowedAdminRuntimeScope(scope string, serviceClass string) bool {
|
||||
switch serviceClass {
|
||||
case "platform_admin":
|
||||
return scope == "platform"
|
||||
case "cluster_admin":
|
||||
return scope == "cluster"
|
||||
case "organization_portal":
|
||||
return scope == "organization"
|
||||
case "user_portal":
|
||||
return scope == "user" || scope == "organization"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (d AdminRuntimeDispatcher) project(ctx context.Context, request FabricRequest) (FabricResponse, error) {
|
||||
response, err := d.ProjectionClient.Project(ctx, ControlAPIProjectionRequest{
|
||||
SchemaVersion: ControlAPIProjectionRequestSchema,
|
||||
Method: strings.ToUpper(strings.TrimSpace(request.Method)),
|
||||
Path: normalizeRuntimePath(request.Path),
|
||||
Query: request.Query,
|
||||
Host: request.Host,
|
||||
Scope: request.Scope,
|
||||
ServiceClass: request.ServiceClass,
|
||||
ObservedAt: d.observedAt(),
|
||||
})
|
||||
if err != nil {
|
||||
return d.json(http.StatusBadGateway, request, "blocked", "control_api_projection_failed", nil), nil
|
||||
}
|
||||
if response.SchemaVersion != ControlAPIProjectionResponseSchema {
|
||||
return d.json(http.StatusBadGateway, request, "blocked", "control_api_projection_invalid_response", nil), nil
|
||||
}
|
||||
headers := http.Header{"Content-Type": []string{"application/json"}}
|
||||
for key, value := range response.Headers {
|
||||
if safeResponseHeader(key) && strings.TrimSpace(value) != "" {
|
||||
headers.Set(key, value)
|
||||
}
|
||||
}
|
||||
statusCode := response.StatusCode
|
||||
if statusCode < 100 || statusCode > 599 {
|
||||
statusCode = http.StatusOK
|
||||
}
|
||||
return FabricResponse{StatusCode: statusCode, Headers: headers, Body: append([]byte(nil), response.Body...)}, nil
|
||||
}
|
||||
|
||||
func (d AdminRuntimeDispatcher) json(statusCode int, request FabricRequest, status string, reason string, manifest map[string]any) FabricResponse {
|
||||
payload, _ := json.Marshal(AdminRuntimeJSONResponse{
|
||||
SchemaVersion: AdminRuntimeResponseSchema,
|
||||
Status: status,
|
||||
Reason: reason,
|
||||
Scope: request.Scope,
|
||||
ServiceClass: request.ServiceClass,
|
||||
Path: request.Path,
|
||||
Manifest: manifest,
|
||||
ObservedAt: d.observedAt(),
|
||||
})
|
||||
return FabricResponse{
|
||||
StatusCode: statusCode,
|
||||
Headers: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: payload,
|
||||
}
|
||||
}
|
||||
|
||||
func (d AdminRuntimeDispatcher) manifest(request FabricRequest) map[string]any {
|
||||
serviceClass := strings.TrimSpace(request.ServiceClass)
|
||||
sections := []string{}
|
||||
actions := []string{}
|
||||
switch serviceClass {
|
||||
case "platform_admin":
|
||||
sections = []string{"clusters", "nodes", "roles", "fabric", "workloads", "audit"}
|
||||
actions = []string{"read_platform_summary", "read_cluster_summaries", "read_node_status"}
|
||||
case "cluster_admin":
|
||||
sections = []string{"cluster", "nodes", "fabric", "workloads", "audit"}
|
||||
actions = []string{"read_cluster_summary", "read_node_status"}
|
||||
case "organization_portal":
|
||||
sections = []string{"organization", "sessions", "resources", "audit"}
|
||||
actions = []string{"read_organization_summary", "read_sessions"}
|
||||
case "user_portal":
|
||||
sections = []string{"profile", "sessions", "resources"}
|
||||
actions = []string{"read_profile", "read_sessions"}
|
||||
default:
|
||||
sections = []string{"status"}
|
||||
actions = []string{"read_status"}
|
||||
}
|
||||
return map[string]any{
|
||||
"schema_version": "rap.web_ingress.ui_manifest.v1",
|
||||
"scope": request.Scope,
|
||||
"service_class": serviceClass,
|
||||
"sections": sections,
|
||||
"allowed_actions": actions,
|
||||
"mutation_enabled": false,
|
||||
"projection_binding": "control_api_not_bound",
|
||||
}
|
||||
}
|
||||
|
||||
func (d AdminRuntimeDispatcher) observedAt() string {
|
||||
now := time.Now().UTC()
|
||||
if d.Now != nil {
|
||||
now = d.Now().UTC()
|
||||
}
|
||||
return now.Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
func normalizeRuntimePath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package webingress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminRuntimeDispatcherReturnsHealthAndManifest(t *testing.T) {
|
||||
dispatcher := AdminRuntimeDispatcher{Now: fixedEnvelopeNow}
|
||||
|
||||
health, err := dispatcher.HandleFabricRequest(context.Background(), FabricRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/readyz",
|
||||
Scope: "platform",
|
||||
ServiceClass: "platform_admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("health: %v", err)
|
||||
}
|
||||
if health.StatusCode != http.StatusOK {
|
||||
t.Fatalf("health = %+v", health)
|
||||
}
|
||||
|
||||
manifest, err := dispatcher.HandleFabricRequest(context.Background(), FabricRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/platform-admin/ui-manifest",
|
||||
Scope: "platform",
|
||||
ServiceClass: "platform_admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("manifest: %v", err)
|
||||
}
|
||||
var payload AdminRuntimeJSONResponse
|
||||
if err := json.Unmarshal(manifest.Body, &payload); err != nil {
|
||||
t.Fatalf("decode manifest: %v", err)
|
||||
}
|
||||
if manifest.StatusCode != http.StatusOK ||
|
||||
payload.SchemaVersion != AdminRuntimeResponseSchema ||
|
||||
payload.Status != "ready" ||
|
||||
payload.Reason != "ui_manifest_ready" ||
|
||||
payload.Manifest["schema_version"] != "rap.web_ingress.ui_manifest.v1" ||
|
||||
payload.Manifest["mutation_enabled"] != false {
|
||||
t.Fatalf("payload = %+v status=%d", payload, manifest.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRuntimeDispatcherBlocksMutationsAndUnknownProjection(t *testing.T) {
|
||||
dispatcher := AdminRuntimeDispatcher{Now: fixedEnvelopeNow}
|
||||
|
||||
mutation, err := dispatcher.HandleFabricRequest(context.Background(), FabricRequest{
|
||||
Method: http.MethodPost,
|
||||
Path: "/platform-admin/nodes",
|
||||
Scope: "platform",
|
||||
ServiceClass: "platform_admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("mutation: %v", err)
|
||||
}
|
||||
var mutationPayload AdminRuntimeJSONResponse
|
||||
if err := json.Unmarshal(mutation.Body, &mutationPayload); err != nil {
|
||||
t.Fatalf("decode mutation: %v", err)
|
||||
}
|
||||
if mutation.StatusCode != http.StatusForbidden || mutationPayload.Reason != "control_api_mutation_binding_not_implemented" {
|
||||
t.Fatalf("mutation payload = %+v status=%d", mutationPayload, mutation.StatusCode)
|
||||
}
|
||||
|
||||
projection, err := dispatcher.HandleFabricRequest(context.Background(), FabricRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/platform-admin/nodes",
|
||||
Scope: "platform",
|
||||
ServiceClass: "platform_admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("projection: %v", err)
|
||||
}
|
||||
var projectionPayload AdminRuntimeJSONResponse
|
||||
if err := json.Unmarshal(projection.Body, &projectionPayload); err != nil {
|
||||
t.Fatalf("decode projection: %v", err)
|
||||
}
|
||||
if projection.StatusCode != http.StatusNotImplemented || projectionPayload.Reason != "control_api_projection_binding_not_implemented" {
|
||||
t.Fatalf("projection payload = %+v status=%d", projectionPayload, projection.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRuntimeDispatcherRejectsInvalidScopeClassPair(t *testing.T) {
|
||||
dispatcher := AdminRuntimeDispatcher{ProjectionClient: &recordingProjectionClient{}, Now: fixedEnvelopeNow}
|
||||
response, err := dispatcher.HandleFabricRequest(context.Background(), FabricRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/platform-admin/ui-manifest",
|
||||
Scope: "organization",
|
||||
ServiceClass: "platform_admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("projection: %v", err)
|
||||
}
|
||||
var payload AdminRuntimeJSONResponse
|
||||
if err := json.Unmarshal(response.Body, &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.StatusCode != http.StatusForbidden || payload.Reason != "admin_runtime_scope_rejected" {
|
||||
t.Fatalf("payload = %+v status=%d", payload, response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRuntimeDispatcherUsesControlAPIProjectionClientForReadRequests(t *testing.T) {
|
||||
client := &recordingProjectionClient{
|
||||
response: ControlAPIProjectionResponse{
|
||||
SchemaVersion: ControlAPIProjectionResponseSchema,
|
||||
Status: "ready",
|
||||
StatusCode: http.StatusOK,
|
||||
Headers: map[string]string{"X-RAP-Projection": "control-api", "Set-Cookie": "blocked"},
|
||||
Body: json.RawMessage(`{"schema_version":"control.projection.v1","ok":true}`),
|
||||
},
|
||||
}
|
||||
dispatcher := AdminRuntimeDispatcher{ProjectionClient: client, Now: fixedEnvelopeNow}
|
||||
|
||||
response, err := dispatcher.HandleFabricRequest(context.Background(), FabricRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/platform-admin/nodes",
|
||||
Query: "limit=10",
|
||||
Host: "admin.example.test",
|
||||
Scope: "platform",
|
||||
ServiceClass: "platform_admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("projection: %v", err)
|
||||
}
|
||||
if response.StatusCode != http.StatusOK ||
|
||||
response.Headers.Get("X-RAP-Projection") != "control-api" ||
|
||||
response.Headers.Get("Set-Cookie") != "" ||
|
||||
string(response.Body) != `{"schema_version":"control.projection.v1","ok":true}` {
|
||||
t.Fatalf("response = %+v body=%s", response, string(response.Body))
|
||||
}
|
||||
if client.request.Path != "/platform-admin/nodes" ||
|
||||
client.request.Query != "limit=10" ||
|
||||
client.request.Scope != "platform" ||
|
||||
client.request.ServiceClass != "platform_admin" {
|
||||
t.Fatalf("request = %+v", client.request)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRuntimeDispatcherReportsProjectionClientFailure(t *testing.T) {
|
||||
dispatcher := AdminRuntimeDispatcher{ProjectionClient: failingProjectionClient{}, Now: fixedEnvelopeNow}
|
||||
response, err := dispatcher.HandleFabricRequest(context.Background(), FabricRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/platform-admin/nodes",
|
||||
Scope: "platform",
|
||||
ServiceClass: "platform_admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("projection: %v", err)
|
||||
}
|
||||
var payload AdminRuntimeJSONResponse
|
||||
if err := json.Unmarshal(response.Body, &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.StatusCode != http.StatusBadGateway || payload.Reason != "control_api_projection_failed" {
|
||||
t.Fatalf("payload = %+v status=%d", payload, response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRuntimeDispatcherRejectsInvalidProjectionResponseSchema(t *testing.T) {
|
||||
dispatcher := AdminRuntimeDispatcher{
|
||||
ProjectionClient: &recordingProjectionClient{
|
||||
response: ControlAPIProjectionResponse{
|
||||
SchemaVersion: "wrong.schema",
|
||||
Status: "ready",
|
||||
StatusCode: http.StatusOK,
|
||||
Body: json.RawMessage(`{"ok":true}`),
|
||||
},
|
||||
},
|
||||
Now: fixedEnvelopeNow,
|
||||
}
|
||||
response, err := dispatcher.HandleFabricRequest(context.Background(), FabricRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/platform-admin/nodes",
|
||||
Scope: "platform",
|
||||
ServiceClass: "platform_admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("projection: %v", err)
|
||||
}
|
||||
var payload AdminRuntimeJSONResponse
|
||||
if err := json.Unmarshal(response.Body, &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.StatusCode != http.StatusBadGateway || payload.Reason != "control_api_projection_invalid_response" {
|
||||
t.Fatalf("payload = %+v status=%d", payload, response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingProjectionClient struct {
|
||||
request ControlAPIProjectionRequest
|
||||
response ControlAPIProjectionResponse
|
||||
}
|
||||
|
||||
func (c *recordingProjectionClient) Project(_ context.Context, request ControlAPIProjectionRequest) (ControlAPIProjectionResponse, error) {
|
||||
c.request = request
|
||||
return c.response, nil
|
||||
}
|
||||
|
||||
type failingProjectionClient struct{}
|
||||
|
||||
func (failingProjectionClient) Project(context.Context, ControlAPIProjectionRequest) (ControlAPIProjectionResponse, error) {
|
||||
return ControlAPIProjectionResponse{}, errTestProjectionFailure{}
|
||||
}
|
||||
|
||||
type errTestProjectionFailure struct{}
|
||||
|
||||
func (errTestProjectionFailure) Error() string { return "projection failed" }
|
||||
@@ -0,0 +1,151 @@
|
||||
package webingress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const FabricServiceChannelEnvelopeSchema = "rap.web_ingress.fabric_service_channel_envelope.v1"
|
||||
|
||||
var (
|
||||
ErrFabricEnvelopeSignerRequired = errors.New("web ingress fabric envelope signer required")
|
||||
ErrFabricEnvelopeSenderRequired = errors.New("web ingress fabric envelope sender required")
|
||||
ErrFabricEnvelopeScopeRequired = errors.New("web ingress fabric envelope scope required")
|
||||
ErrFabricEnvelopeClassRequired = errors.New("web ingress fabric envelope service class required")
|
||||
)
|
||||
|
||||
type EnvelopeSigner interface {
|
||||
Sign(ctx context.Context, canonical []byte) (FabricEnvelopeSignature, error)
|
||||
}
|
||||
|
||||
type EnvelopeSender interface {
|
||||
Send(ctx context.Context, envelope SignedFabricServiceChannelEnvelope) (FabricResponse, error)
|
||||
}
|
||||
|
||||
type DefaultFabricBinder struct {
|
||||
Signer EnvelopeSigner
|
||||
Sender EnvelopeSender
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type FabricServiceChannelEnvelope struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
RequestSchema string `json:"request_schema"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Query string `json:"query,omitempty"`
|
||||
Host string `json:"host"`
|
||||
ServiceType string `json:"service_type"`
|
||||
Scope string `json:"scope"`
|
||||
ServiceClass string `json:"service_class"`
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
BodyBase64 string `json:"body_b64,omitempty"`
|
||||
ObservedAt string `json:"observed_at"`
|
||||
EnvelopedAt string `json:"enveloped_at"`
|
||||
}
|
||||
|
||||
type FabricEnvelopeSignature struct {
|
||||
KeyID string `json:"key_id"`
|
||||
Alg string `json:"alg"`
|
||||
Signature string `json:"signature"`
|
||||
SignedAt string `json:"signed_at,omitempty"`
|
||||
}
|
||||
|
||||
type SignedFabricServiceChannelEnvelope struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Envelope FabricServiceChannelEnvelope `json:"envelope"`
|
||||
Signature FabricEnvelopeSignature `json:"signature"`
|
||||
Canonical []byte `json:"-"`
|
||||
}
|
||||
|
||||
func (b DefaultFabricBinder) Forward(ctx context.Context, request FabricRequest) (FabricResponse, error) {
|
||||
if b.Signer == nil {
|
||||
return FabricResponse{}, ErrFabricEnvelopeSignerRequired
|
||||
}
|
||||
if b.Sender == nil {
|
||||
return FabricResponse{}, ErrFabricEnvelopeSenderRequired
|
||||
}
|
||||
if strings.TrimSpace(request.Scope) == "" {
|
||||
return FabricResponse{}, ErrFabricEnvelopeScopeRequired
|
||||
}
|
||||
if strings.TrimSpace(request.ServiceClass) == "" {
|
||||
return FabricResponse{}, ErrFabricEnvelopeClassRequired
|
||||
}
|
||||
|
||||
envelope := b.envelope(request)
|
||||
canonical, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return FabricResponse{}, err
|
||||
}
|
||||
signature, err := b.Signer.Sign(ctx, canonical)
|
||||
if err != nil {
|
||||
return FabricResponse{}, err
|
||||
}
|
||||
return b.Sender.Send(ctx, SignedFabricServiceChannelEnvelope{
|
||||
SchemaVersion: SignedFabricServiceChannelEnvelopeSchema,
|
||||
Envelope: envelope,
|
||||
Signature: signature,
|
||||
Canonical: canonical,
|
||||
})
|
||||
}
|
||||
|
||||
func (b DefaultFabricBinder) envelope(request FabricRequest) FabricServiceChannelEnvelope {
|
||||
now := time.Now().UTC()
|
||||
if b.Now != nil {
|
||||
now = b.Now().UTC()
|
||||
}
|
||||
observedAt := request.ObservedAt.UTC()
|
||||
if observedAt.IsZero() {
|
||||
observedAt = now
|
||||
}
|
||||
return FabricServiceChannelEnvelope{
|
||||
SchemaVersion: FabricServiceChannelEnvelopeSchema,
|
||||
RequestSchema: strings.TrimSpace(request.SchemaVersion),
|
||||
Method: strings.ToUpper(strings.TrimSpace(request.Method)),
|
||||
Path: request.Path,
|
||||
Query: request.Query,
|
||||
Host: strings.TrimSpace(request.Host),
|
||||
ServiceType: strings.TrimSpace(request.ServiceType),
|
||||
Scope: strings.TrimSpace(request.Scope),
|
||||
ServiceClass: strings.TrimSpace(request.ServiceClass),
|
||||
Headers: canonicalHeaders(request.Headers),
|
||||
BodyBase64: base64.StdEncoding.EncodeToString(request.Body),
|
||||
ObservedAt: observedAt.Format(time.RFC3339Nano),
|
||||
EnvelopedAt: now.Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalHeaders(headers http.Header) map[string][]string {
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := map[string][]string{}
|
||||
for key, values := range headers {
|
||||
canonicalKey := http.CanonicalHeaderKey(strings.TrimSpace(key))
|
||||
if canonicalKey == "" || !safeRequestHeader(canonicalKey) {
|
||||
continue
|
||||
}
|
||||
copied := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
copied = append(copied, value)
|
||||
}
|
||||
}
|
||||
if len(copied) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Strings(copied)
|
||||
out[canonicalKey] = copied
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package webingress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultFabricBinderBuildsSignedEnvelopeAndSendsIt(t *testing.T) {
|
||||
signer := &recordingEnvelopeSigner{
|
||||
signature: FabricEnvelopeSignature{KeyID: "node-key-1", Alg: "ed25519", Signature: "sig-1", SignedAt: "2026-05-17T00:00:02Z"},
|
||||
}
|
||||
sender := &recordingEnvelopeSender{
|
||||
response: FabricResponse{StatusCode: http.StatusAccepted, Body: []byte(`{"accepted":true}`)},
|
||||
}
|
||||
binder := DefaultFabricBinder{Signer: signer, Sender: sender, Now: fixedEnvelopeNow}
|
||||
|
||||
response, err := binder.Forward(context.Background(), FabricRequest{
|
||||
SchemaVersion: "rap.web_ingress.fabric_request.v1",
|
||||
Method: "post",
|
||||
Path: "/platform-admin/root",
|
||||
Query: "tab=nodes",
|
||||
Host: "admin.example.test",
|
||||
ServiceType: "admin-ingress",
|
||||
Scope: "platform",
|
||||
ServiceClass: "platform_admin",
|
||||
Headers: http.Header{
|
||||
"X-Trace-Id": []string{"trace-b", "trace-a"},
|
||||
"Authorization": []string{"Bearer should-not-forward"},
|
||||
"X-Empty-Header": []string{" "},
|
||||
},
|
||||
Body: []byte(`{"hello":"world"}`),
|
||||
ObservedAt: fixedNow(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Forward failed: %v", err)
|
||||
}
|
||||
if response.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("response = %+v", response)
|
||||
}
|
||||
if len(signer.canonical) == 0 {
|
||||
t.Fatal("signer did not receive canonical envelope")
|
||||
}
|
||||
if !bytes.Equal(sender.envelope.Canonical, signer.canonical) {
|
||||
t.Fatalf("sender canonical does not match signer canonical")
|
||||
}
|
||||
if sender.envelope.SchemaVersion != "rap.web_ingress.signed_fabric_service_channel_envelope.v1" {
|
||||
t.Fatalf("signed schema = %q", sender.envelope.SchemaVersion)
|
||||
}
|
||||
if sender.envelope.Signature.KeyID != "node-key-1" || sender.envelope.Signature.Signature != "sig-1" {
|
||||
t.Fatalf("signature = %+v", sender.envelope.Signature)
|
||||
}
|
||||
|
||||
var canonical FabricServiceChannelEnvelope
|
||||
if err := json.Unmarshal(signer.canonical, &canonical); err != nil {
|
||||
t.Fatalf("decode canonical: %v", err)
|
||||
}
|
||||
if canonical.SchemaVersion != FabricServiceChannelEnvelopeSchema ||
|
||||
canonical.RequestSchema != "rap.web_ingress.fabric_request.v1" ||
|
||||
canonical.Method != http.MethodPost ||
|
||||
canonical.Scope != "platform" ||
|
||||
canonical.ServiceClass != "platform_admin" ||
|
||||
canonical.BodyBase64 != "eyJoZWxsbyI6IndvcmxkIn0=" ||
|
||||
canonical.ObservedAt != "2026-05-17T00:00:00Z" ||
|
||||
canonical.EnvelopedAt != "2026-05-17T00:00:01Z" {
|
||||
t.Fatalf("canonical envelope = %+v", canonical)
|
||||
}
|
||||
if got := canonical.Headers["X-Trace-Id"]; len(got) != 2 || got[0] != "trace-a" || got[1] != "trace-b" {
|
||||
t.Fatalf("canonical headers = %#v", canonical.Headers)
|
||||
}
|
||||
if canonical.Headers["Authorization"] != nil || canonical.Headers["X-Empty-Header"] != nil {
|
||||
t.Fatalf("unsafe/empty headers leaked: %#v", canonical.Headers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultFabricBinderRequiresSignerAndSender(t *testing.T) {
|
||||
request := FabricRequest{Scope: "platform", ServiceClass: "platform_admin"}
|
||||
|
||||
_, err := (DefaultFabricBinder{Sender: &recordingEnvelopeSender{}}).Forward(context.Background(), request)
|
||||
if !errors.Is(err, ErrFabricEnvelopeSignerRequired) {
|
||||
t.Fatalf("signer error = %v", err)
|
||||
}
|
||||
|
||||
_, err = (DefaultFabricBinder{Signer: &recordingEnvelopeSigner{}}).Forward(context.Background(), request)
|
||||
if !errors.Is(err, ErrFabricEnvelopeSenderRequired) {
|
||||
t.Fatalf("sender error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultFabricBinderRequiresScopeAndServiceClass(t *testing.T) {
|
||||
binder := DefaultFabricBinder{Signer: &recordingEnvelopeSigner{}, Sender: &recordingEnvelopeSender{}}
|
||||
|
||||
_, err := binder.Forward(context.Background(), FabricRequest{ServiceClass: "platform_admin"})
|
||||
if !errors.Is(err, ErrFabricEnvelopeScopeRequired) {
|
||||
t.Fatalf("scope error = %v", err)
|
||||
}
|
||||
|
||||
_, err = binder.Forward(context.Background(), FabricRequest{Scope: "platform"})
|
||||
if !errors.Is(err, ErrFabricEnvelopeClassRequired) {
|
||||
t.Fatalf("class error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultFabricBinderPropagatesSignerAndSenderFailures(t *testing.T) {
|
||||
signerErr := errors.New("sign failed")
|
||||
senderErr := errors.New("send failed")
|
||||
request := FabricRequest{Scope: "platform", ServiceClass: "platform_admin"}
|
||||
|
||||
_, err := (DefaultFabricBinder{
|
||||
Signer: &recordingEnvelopeSigner{err: signerErr},
|
||||
Sender: &recordingEnvelopeSender{},
|
||||
}).Forward(context.Background(), request)
|
||||
if !errors.Is(err, signerErr) {
|
||||
t.Fatalf("signer error = %v", err)
|
||||
}
|
||||
|
||||
_, err = (DefaultFabricBinder{
|
||||
Signer: &recordingEnvelopeSigner{},
|
||||
Sender: &recordingEnvelopeSender{err: senderErr},
|
||||
}).Forward(context.Background(), request)
|
||||
if !errors.Is(err, senderErr) {
|
||||
t.Fatalf("sender error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func fixedEnvelopeNow() time.Time {
|
||||
return time.Date(2026, 5, 17, 0, 0, 1, 0, time.UTC)
|
||||
}
|
||||
|
||||
type recordingEnvelopeSigner struct {
|
||||
canonical []byte
|
||||
signature FabricEnvelopeSignature
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *recordingEnvelopeSigner) Sign(_ context.Context, canonical []byte) (FabricEnvelopeSignature, error) {
|
||||
s.canonical = append([]byte{}, canonical...)
|
||||
if s.err != nil {
|
||||
return FabricEnvelopeSignature{}, s.err
|
||||
}
|
||||
if s.signature.KeyID == "" {
|
||||
s.signature = FabricEnvelopeSignature{KeyID: "test-key", Alg: "ed25519", Signature: "test-signature"}
|
||||
}
|
||||
return s.signature, nil
|
||||
}
|
||||
|
||||
type recordingEnvelopeSender struct {
|
||||
envelope SignedFabricServiceChannelEnvelope
|
||||
response FabricResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *recordingEnvelopeSender) Send(_ context.Context, envelope SignedFabricServiceChannelEnvelope) (FabricResponse, error) {
|
||||
s.envelope = envelope
|
||||
if s.err != nil {
|
||||
return FabricResponse{}, s.err
|
||||
}
|
||||
return s.response, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package webingress
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type TrustedKeyConfig struct {
|
||||
KeyID string `json:"key_id"`
|
||||
PublicKey string `json:"public_key"`
|
||||
}
|
||||
|
||||
func ParseTrustedKeysJSON(value string) (StaticEnvelopeKeyResolver, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
resolver := StaticEnvelopeKeyResolver{}
|
||||
var byID map[string]string
|
||||
if err := json.Unmarshal([]byte(value), &byID); err == nil && len(byID) > 0 {
|
||||
for keyID, publicKeyB64 := range byID {
|
||||
if err := resolver.addBase64(keyID, publicKeyB64); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return resolver, nil
|
||||
}
|
||||
var list []TrustedKeyConfig
|
||||
if err := json.Unmarshal([]byte(value), &list); err != nil {
|
||||
return nil, fmt.Errorf("%w: trusted keys json must be object or array", ErrFabricEnvelopeSignatureInvalid)
|
||||
}
|
||||
for _, item := range list {
|
||||
if err := resolver.addBase64(item.KeyID, item.PublicKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return resolver, nil
|
||||
}
|
||||
|
||||
func (r StaticEnvelopeKeyResolver) addBase64(keyID string, publicKeyB64 string) error {
|
||||
keyID = strings.TrimSpace(keyID)
|
||||
if keyID == "" {
|
||||
return fmt.Errorf("%w: trusted key id required", ErrFabricEnvelopeSignatureInvalid)
|
||||
}
|
||||
decoded, err := decodeEnvelopeBase64(strings.TrimSpace(publicKeyB64))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: trusted public key must be base64 encoded", ErrFabricEnvelopeSignatureInvalid)
|
||||
}
|
||||
if len(decoded) != ed25519.PublicKeySize {
|
||||
return fmt.Errorf("%w: trusted public key must decode to %d bytes", ErrFabricEnvelopeSignatureInvalid, ed25519.PublicKeySize)
|
||||
}
|
||||
r[keyID] = append(ed25519.PublicKey(nil), decoded...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TrustedKeysJSONForPublicKey(keyID string, publicKey ed25519.PublicKey) string {
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
strings.TrimSpace(keyID): base64.StdEncoding.EncodeToString(publicKey),
|
||||
})
|
||||
return string(payload)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package webingress
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseTrustedKeysJSONAcceptsMapAndArray(t *testing.T) {
|
||||
publicKey, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
keyB64 := base64.StdEncoding.EncodeToString(publicKey)
|
||||
|
||||
resolver, err := ParseTrustedKeysJSON(`{"key-1":"` + keyB64 + `"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse map: %v", err)
|
||||
}
|
||||
if got, ok, err := resolver.PublicKey(nil, "key-1"); err != nil || !ok || string(got) != string(publicKey) {
|
||||
t.Fatalf("map resolver got=%x ok=%t err=%v", got, ok, err)
|
||||
}
|
||||
|
||||
resolver, err = ParseTrustedKeysJSON(`[{"key_id":"key-2","public_key":"` + keyB64 + `"}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse array: %v", err)
|
||||
}
|
||||
if _, ok, err := resolver.PublicKey(nil, "key-2"); err != nil || !ok {
|
||||
t.Fatalf("array resolver ok=%t err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrustedKeysJSONRejectsInvalidKeys(t *testing.T) {
|
||||
_, err := ParseTrustedKeysJSON(`{"":"abc"}`)
|
||||
if !errors.Is(err, ErrFabricEnvelopeSignatureInvalid) {
|
||||
t.Fatalf("empty key err = %v", err)
|
||||
}
|
||||
|
||||
_, err = ParseTrustedKeysJSON(`{"key-1":"abc"}`)
|
||||
if !errors.Is(err, ErrFabricEnvelopeSignatureInvalid) {
|
||||
t.Fatalf("bad public key err = %v", err)
|
||||
}
|
||||
|
||||
_, err = ParseTrustedKeysJSON(`not-json`)
|
||||
if !errors.Is(err, ErrFabricEnvelopeSignatureInvalid) {
|
||||
t.Fatalf("bad json err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedKeysJSONForPublicKey(t *testing.T) {
|
||||
publicKey, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
resolver, err := ParseTrustedKeysJSON(TrustedKeysJSONForPublicKey("key-1", publicKey))
|
||||
if err != nil {
|
||||
t.Fatalf("parse generated json: %v", err)
|
||||
}
|
||||
if _, ok, err := resolver.PublicKey(nil, "key-1"); err != nil || !ok {
|
||||
t.Fatalf("generated resolver ok=%t err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package webingress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ListenerConfig struct {
|
||||
RuntimeConfig
|
||||
HTTPAddr string
|
||||
HTTPSAddr string
|
||||
TLSCertFile string
|
||||
TLSKeyFile string
|
||||
Binder FabricBinder
|
||||
}
|
||||
|
||||
type ListenerStatus struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Running bool `json:"running"`
|
||||
HTTPRunning bool `json:"http_running"`
|
||||
HTTPSRunning bool `json:"https_running"`
|
||||
HTTPAddr string `json:"http_addr,omitempty"`
|
||||
HTTPSAddr string `json:"https_addr,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
ObservedAt string `json:"observed_at"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
http *http.Server
|
||||
https *http.Server
|
||||
status ListenerStatus
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
return &Manager{now: time.Now}
|
||||
}
|
||||
|
||||
func (m *Manager) Apply(ctx context.Context, cfg ListenerConfig) ListenerStatus {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
_ = m.stopLocked(ctx)
|
||||
|
||||
runtime := Runtime{Config: cfg.RuntimeConfig, Binder: cfg.Binder, Now: m.now}
|
||||
status := ListenerStatus{
|
||||
SchemaVersion: "rap.web_ingress.listener_status.v1",
|
||||
Reason: "started",
|
||||
ObservedAt: m.observedAt(),
|
||||
}
|
||||
errorsOut := []string{}
|
||||
if strings.TrimSpace(cfg.HTTPAddr) == "" {
|
||||
cfg.HTTPAddr = ":80"
|
||||
}
|
||||
if strings.TrimSpace(cfg.HTTPSAddr) == "" {
|
||||
cfg.HTTPSAddr = ":443"
|
||||
}
|
||||
if server, addr, err := startHTTPServer(ctx, cfg.HTTPAddr, runtime.HTTPHandler()); err == nil {
|
||||
m.http = server
|
||||
status.HTTPRunning = true
|
||||
status.HTTPAddr = addr
|
||||
} else {
|
||||
errorsOut = append(errorsOut, "http:"+err.Error())
|
||||
}
|
||||
if cfg.TLSCertFile == "" || cfg.TLSKeyFile == "" {
|
||||
errorsOut = append(errorsOut, "https:tls_cert_file_and_key_file_required")
|
||||
} else if server, addr, err := startHTTPSServer(ctx, cfg.HTTPSAddr, cfg.TLSCertFile, cfg.TLSKeyFile, runtime.HTTPSHandler()); err == nil {
|
||||
m.https = server
|
||||
status.HTTPSRunning = true
|
||||
status.HTTPSAddr = addr
|
||||
} else {
|
||||
errorsOut = append(errorsOut, "https:"+err.Error())
|
||||
}
|
||||
status.Running = status.HTTPRunning || status.HTTPSRunning
|
||||
if len(errorsOut) > 0 {
|
||||
status.Errors = errorsOut
|
||||
if status.Running {
|
||||
status.Reason = "partial"
|
||||
} else {
|
||||
status.Reason = "blocked"
|
||||
}
|
||||
}
|
||||
m.status = status
|
||||
return status
|
||||
}
|
||||
|
||||
func (m *Manager) Stop(ctx context.Context) ListenerStatus {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
_ = m.stopLocked(ctx)
|
||||
m.status = ListenerStatus{
|
||||
SchemaVersion: "rap.web_ingress.listener_status.v1",
|
||||
Reason: "stopped",
|
||||
ObservedAt: m.observedAt(),
|
||||
}
|
||||
return m.status
|
||||
}
|
||||
|
||||
func (m *Manager) Status() ListenerStatus {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.status.SchemaVersion == "" {
|
||||
return ListenerStatus{
|
||||
SchemaVersion: "rap.web_ingress.listener_status.v1",
|
||||
Reason: "not_started",
|
||||
ObservedAt: m.observedAt(),
|
||||
}
|
||||
}
|
||||
return m.status
|
||||
}
|
||||
|
||||
func (m *Manager) stopLocked(ctx context.Context) error {
|
||||
var out error
|
||||
if m.http != nil {
|
||||
out = errors.Join(out, m.http.Shutdown(ctx))
|
||||
m.http = nil
|
||||
}
|
||||
if m.https != nil {
|
||||
out = errors.Join(out, m.https.Shutdown(ctx))
|
||||
m.https = nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Manager) observedAt() string {
|
||||
now := time.Now().UTC()
|
||||
if m.now != nil {
|
||||
now = m.now().UTC()
|
||||
}
|
||||
return now.Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
func startHTTPServer(ctx context.Context, addr string, handler http.Handler) (*http.Server, string, error) {
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = server.Shutdown(context.Background())
|
||||
}()
|
||||
go func() {
|
||||
if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
_ = server.Close()
|
||||
}
|
||||
}()
|
||||
return server, listener.Addr().String(), nil
|
||||
}
|
||||
|
||||
func startHTTPSServer(ctx context.Context, addr, certFile, keyFile string, handler http.Handler) (*http.Server, string, error) {
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
server := &http.Server{
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{cert}},
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = server.Shutdown(context.Background())
|
||||
}()
|
||||
go func() {
|
||||
if err := server.ServeTLS(listener, "", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
_ = server.Close()
|
||||
}
|
||||
}()
|
||||
return server, listener.Addr().String(), nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package webingress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestManagerStartsHTTPRedirectAndStops(t *testing.T) {
|
||||
manager := NewManager()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
status := manager.Apply(ctx, ListenerConfig{
|
||||
RuntimeConfig: RuntimeConfig{ServiceType: "admin-ingress", Scope: "platform", ServiceClasses: []string{"platform_admin"}},
|
||||
HTTPAddr: "127.0.0.1:0",
|
||||
HTTPSAddr: "127.0.0.1:0",
|
||||
})
|
||||
if !status.HTTPRunning || status.HTTPSRunning || !status.Running || status.HTTPAddr == "" {
|
||||
t.Fatalf("status = %+v", status)
|
||||
}
|
||||
if status.Reason != "partial" || !containsError(status.Errors, "https:tls_cert_file_and_key_file_required") {
|
||||
t.Fatalf("status = %+v", status)
|
||||
}
|
||||
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
resp, err := client.Get("http://" + status.HTTPAddr + "/cluster-admin")
|
||||
if err != nil {
|
||||
t.Fatalf("http get: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusPermanentRedirect {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
stopped := manager.Stop(context.Background())
|
||||
if stopped.Running || stopped.Reason != "stopped" {
|
||||
t.Fatalf("stopped = %+v", stopped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerStartsHTTPSWhenCertificateProvided(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certFile, keyFile := writeSelfSignedCert(t, dir)
|
||||
manager := NewManager()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
status := manager.Apply(ctx, ListenerConfig{
|
||||
RuntimeConfig: RuntimeConfig{ServiceType: "admin-ingress", Scope: "platform", ServiceClasses: []string{"platform_admin"}},
|
||||
HTTPAddr: "127.0.0.1:0",
|
||||
HTTPSAddr: "127.0.0.1:0",
|
||||
TLSCertFile: certFile,
|
||||
TLSKeyFile: keyFile,
|
||||
})
|
||||
if !status.HTTPRunning || !status.HTTPSRunning || status.HTTPAddr == "" || status.HTTPSAddr == "" || len(status.Errors) != 0 {
|
||||
t.Fatalf("status = %+v", status)
|
||||
}
|
||||
}
|
||||
|
||||
func writeSelfSignedCert(t *testing.T, dir string) (string, string) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "localhost"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
certFile := filepath.Join(dir, "cert.pem")
|
||||
keyFile := filepath.Join(dir, "key.pem")
|
||||
if err := os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil {
|
||||
t.Fatalf("write cert: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}), 0o600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
return certFile, keyFile
|
||||
}
|
||||
|
||||
func containsError(values []string, needle string) bool {
|
||||
for _, value := range values {
|
||||
if value == needle || strings.Contains(value, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package webingress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/example/remote-access-platform/agents/rap-node-agent/internal/mesh"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMeshEnvelopeRuntimeRequired = errors.New("web ingress mesh envelope runtime required")
|
||||
ErrMeshEnvelopeRouteRequired = errors.New("web ingress mesh envelope route set required")
|
||||
ErrMeshEnvelopeIdentityInvalid = errors.New("web ingress mesh envelope identity invalid")
|
||||
)
|
||||
|
||||
type FabricChannelReliableRuntime interface {
|
||||
SendReliable(ctx context.Context, spec mesh.FabricChannelSpec, routeSet mesh.FabricRouteSet, payloads [][]byte) (mesh.FabricChannelRuntimeResult, error)
|
||||
}
|
||||
|
||||
type FabricChannelRequestResponseRuntime interface {
|
||||
SendRequestResponse(ctx context.Context, spec mesh.FabricChannelSpec, routeSet mesh.FabricRouteSet, payload []byte) (mesh.FabricChannelRequestResponseResult, error)
|
||||
}
|
||||
|
||||
type MeshEnvelopeSender struct {
|
||||
Runtime FabricChannelReliableRuntime
|
||||
ResponseRuntime FabricChannelRequestResponseRuntime
|
||||
RouteSet mesh.FabricRouteSet
|
||||
ClusterID string
|
||||
SourceNodeID string
|
||||
TargetKind mesh.FabricChannelTargetKind
|
||||
TargetID string
|
||||
ChannelID string
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type MeshEnvelopeDeliveryResponse struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Status string `json:"status"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
RouteID string `json:"route_id,omitempty"`
|
||||
TargetNode string `json:"target_node,omitempty"`
|
||||
BytesSent uint64 `json:"bytes_sent"`
|
||||
FramesSent uint64 `json:"frames_sent"`
|
||||
AcksReceived uint64 `json:"acks_received"`
|
||||
MigrationEvents int `json:"migration_events"`
|
||||
}
|
||||
|
||||
func (s MeshEnvelopeSender) Send(ctx context.Context, envelope SignedFabricServiceChannelEnvelope) (FabricResponse, error) {
|
||||
if s.Runtime == nil && s.ResponseRuntime == nil {
|
||||
return FabricResponse{}, ErrMeshEnvelopeRuntimeRequired
|
||||
}
|
||||
if strings.TrimSpace(s.RouteSet.Primary.RouteID) == "" && len(s.RouteSet.WarmStandby) == 0 && len(s.RouteSet.ColdFallbacks) == 0 {
|
||||
return FabricResponse{}, ErrMeshEnvelopeRouteRequired
|
||||
}
|
||||
spec, err := s.channelSpec(envelope)
|
||||
if err != nil {
|
||||
return FabricResponse{}, err
|
||||
}
|
||||
payload, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return FabricResponse{}, err
|
||||
}
|
||||
if s.ResponseRuntime != nil {
|
||||
result, err := s.ResponseRuntime.SendRequestResponse(ctx, spec, s.routeSet(spec), payload)
|
||||
if err != nil {
|
||||
return FabricResponse{}, err
|
||||
}
|
||||
responsePayload, err := unwrapWebIngressForwardResponse(result.ResponsePayload)
|
||||
if err != nil {
|
||||
return FabricResponse{}, err
|
||||
}
|
||||
if response, ok := decodeRuntimeHTTPResponse(responsePayload); ok {
|
||||
return response, nil
|
||||
}
|
||||
return acceptedDeliveryResponse(spec.ChannelID, result.FabricChannelRuntimeResult)
|
||||
}
|
||||
result, err := s.Runtime.SendReliable(ctx, spec, s.routeSet(spec), [][]byte{payload})
|
||||
if err != nil {
|
||||
return FabricResponse{}, err
|
||||
}
|
||||
return acceptedDeliveryResponse(spec.ChannelID, result)
|
||||
}
|
||||
|
||||
func unwrapWebIngressForwardResponse(payload []byte) ([]byte, error) {
|
||||
var response struct {
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
if len(payload) == 0 || json.Unmarshal(payload, &response) != nil {
|
||||
return payload, nil
|
||||
}
|
||||
if strings.TrimSpace(response.Error) != "" {
|
||||
return nil, fmt.Errorf("%w: %s", ErrMeshEnvelopeRuntimeRequired, response.Error)
|
||||
}
|
||||
if len(response.Payload) == 0 {
|
||||
return payload, nil
|
||||
}
|
||||
return append([]byte(nil), response.Payload...), nil
|
||||
}
|
||||
|
||||
func acceptedDeliveryResponse(channelID string, result mesh.FabricChannelRuntimeResult) (FabricResponse, error) {
|
||||
response, err := json.Marshal(MeshEnvelopeDeliveryResponse{
|
||||
SchemaVersion: "rap.web_ingress.mesh_envelope_delivery_response.v1",
|
||||
Status: "accepted",
|
||||
ChannelID: channelID,
|
||||
RouteID: result.Channel.RouteID,
|
||||
TargetNode: result.Channel.TargetNode,
|
||||
BytesSent: result.BytesSent,
|
||||
FramesSent: result.FramesSent,
|
||||
AcksReceived: result.AcksReceived,
|
||||
MigrationEvents: result.MigrationEvents,
|
||||
})
|
||||
if err != nil {
|
||||
return FabricResponse{}, err
|
||||
}
|
||||
return FabricResponse{
|
||||
StatusCode: http.StatusAccepted,
|
||||
Headers: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: response,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeRuntimeHTTPResponse(payload []byte) (FabricResponse, bool) {
|
||||
var response struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
StatusCode int `json:"status_code"`
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
BodyBase64 string `json:"body_b64,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
}
|
||||
if len(payload) == 0 || json.Unmarshal(payload, &response) != nil {
|
||||
return FabricResponse{}, false
|
||||
}
|
||||
if response.SchemaVersion != FabricRuntimeResponseSchema {
|
||||
return FabricResponse{}, false
|
||||
}
|
||||
body := []byte(response.Body)
|
||||
if response.BodyBase64 != "" {
|
||||
decoded, err := decodeEnvelopeBase64(response.BodyBase64)
|
||||
if err != nil {
|
||||
return FabricResponse{}, false
|
||||
}
|
||||
body = decoded
|
||||
}
|
||||
headers := http.Header{}
|
||||
for key, values := range response.Headers {
|
||||
if !safeResponseHeader(key) {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
headers.Add(key, value)
|
||||
}
|
||||
}
|
||||
return FabricResponse{StatusCode: response.StatusCode, Headers: headers, Body: body}, true
|
||||
}
|
||||
|
||||
func (s MeshEnvelopeSender) channelSpec(envelope SignedFabricServiceChannelEnvelope) (mesh.FabricChannelSpec, error) {
|
||||
clusterID := strings.TrimSpace(s.ClusterID)
|
||||
sourceNodeID := strings.TrimSpace(s.SourceNodeID)
|
||||
targetID := strings.TrimSpace(s.TargetID)
|
||||
if clusterID == "" || sourceNodeID == "" || targetID == "" {
|
||||
return mesh.FabricChannelSpec{}, ErrMeshEnvelopeIdentityInvalid
|
||||
}
|
||||
targetKind := s.TargetKind
|
||||
if targetKind == "" {
|
||||
targetKind = mesh.FabricChannelTargetPool
|
||||
}
|
||||
channelID := strings.TrimSpace(s.ChannelID)
|
||||
if channelID == "" {
|
||||
channelID = defaultMeshEnvelopeChannelID(envelope, s.now())
|
||||
}
|
||||
spec := mesh.FabricChannelSpec{
|
||||
ChannelID: channelID,
|
||||
ClusterID: clusterID,
|
||||
SourceNodeID: sourceNodeID,
|
||||
TargetKind: targetKind,
|
||||
TargetID: targetID,
|
||||
TrafficClass: "control",
|
||||
StickyKey: envelope.Envelope.Scope + ":" + envelope.Envelope.ServiceClass,
|
||||
CreatedAt: s.now(),
|
||||
}
|
||||
if err := mesh.ValidateFabricChannelSpec(spec); err != nil {
|
||||
return mesh.FabricChannelSpec{}, err
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func (s MeshEnvelopeSender) routeSet(spec mesh.FabricChannelSpec) mesh.FabricRouteSet {
|
||||
routeSet := s.RouteSet
|
||||
if routeSet.TargetKind == "" {
|
||||
routeSet.TargetKind = spec.TargetKind
|
||||
}
|
||||
if strings.TrimSpace(routeSet.TargetID) == "" {
|
||||
routeSet.TargetID = spec.TargetID
|
||||
}
|
||||
return routeSet
|
||||
}
|
||||
|
||||
func (s MeshEnvelopeSender) now() time.Time {
|
||||
if s.Now != nil {
|
||||
return s.Now().UTC()
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func defaultMeshEnvelopeChannelID(envelope SignedFabricServiceChannelEnvelope, now time.Time) string {
|
||||
serviceClass := strings.ReplaceAll(strings.TrimSpace(envelope.Envelope.ServiceClass), "_", "-")
|
||||
if serviceClass == "" {
|
||||
serviceClass = "web-ingress"
|
||||
}
|
||||
return fmt.Sprintf("web-ingress-%s-%d", serviceClass, now.UnixNano())
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package webingress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/example/remote-access-platform/agents/rap-node-agent/internal/mesh"
|
||||
)
|
||||
|
||||
func TestMeshEnvelopeSenderSendsSignedEnvelopeOverReliableFabricRuntime(t *testing.T) {
|
||||
runtime := &recordingReliableRuntime{
|
||||
result: mesh.FabricChannelRuntimeResult{
|
||||
Channel: mesh.FabricChannel{RouteID: "route-fast", TargetNode: "node-runtime"},
|
||||
BytesSent: 123,
|
||||
FramesSent: 1,
|
||||
AcksReceived: 1,
|
||||
},
|
||||
}
|
||||
sender := MeshEnvelopeSender{
|
||||
Runtime: runtime,
|
||||
RouteSet: testWebIngressRouteSet(),
|
||||
ClusterID: "cluster-1",
|
||||
SourceNodeID: "node-ingress",
|
||||
TargetKind: mesh.FabricChannelTargetPool,
|
||||
TargetID: "pool-admin-runtime",
|
||||
ChannelID: "channel-web-1",
|
||||
Now: fixedEnvelopeNow,
|
||||
}
|
||||
envelope := SignedFabricServiceChannelEnvelope{
|
||||
SchemaVersion: "rap.web_ingress.signed_fabric_service_channel_envelope.v1",
|
||||
Envelope: FabricServiceChannelEnvelope{
|
||||
SchemaVersion: FabricServiceChannelEnvelopeSchema,
|
||||
Scope: "platform",
|
||||
ServiceClass: "platform_admin",
|
||||
},
|
||||
Signature: FabricEnvelopeSignature{KeyID: "node-key", Alg: "ed25519", Signature: "sig"},
|
||||
}
|
||||
|
||||
response, err := sender.Send(context.Background(), envelope)
|
||||
if err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
if response.StatusCode != http.StatusAccepted || response.Headers.Get("Content-Type") != "application/json" {
|
||||
t.Fatalf("response = %+v", response)
|
||||
}
|
||||
if runtime.spec.ChannelID != "channel-web-1" ||
|
||||
runtime.spec.ClusterID != "cluster-1" ||
|
||||
runtime.spec.SourceNodeID != "node-ingress" ||
|
||||
runtime.spec.TargetID != "pool-admin-runtime" ||
|
||||
runtime.spec.TargetKind != mesh.FabricChannelTargetPool ||
|
||||
runtime.spec.TrafficClass != "control" ||
|
||||
runtime.spec.StickyKey != "platform:platform_admin" {
|
||||
t.Fatalf("spec = %+v", runtime.spec)
|
||||
}
|
||||
if runtime.routeSet.TargetID != "pool-admin-runtime" || len(runtime.payloads) != 1 {
|
||||
t.Fatalf("route/payload = %+v payloads=%d", runtime.routeSet, len(runtime.payloads))
|
||||
}
|
||||
var delivered SignedFabricServiceChannelEnvelope
|
||||
if err := json.Unmarshal(runtime.payloads[0], &delivered); err != nil {
|
||||
t.Fatalf("decode delivered envelope: %v", err)
|
||||
}
|
||||
if delivered.Signature.Signature != "sig" || delivered.Envelope.ServiceClass != "platform_admin" {
|
||||
t.Fatalf("delivered = %+v", delivered)
|
||||
}
|
||||
var body MeshEnvelopeDeliveryResponse
|
||||
if err := json.Unmarshal(response.Body, &body); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if body.SchemaVersion != "rap.web_ingress.mesh_envelope_delivery_response.v1" ||
|
||||
body.Status != "accepted" ||
|
||||
body.RouteID != "route-fast" ||
|
||||
body.AcksReceived != 1 {
|
||||
t.Fatalf("body = %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeshEnvelopeSenderReturnsRuntimeHTTPResponse(t *testing.T) {
|
||||
runtime := &recordingRequestResponseRuntime{
|
||||
result: mesh.FabricChannelRequestResponseResult{
|
||||
FabricChannelRuntimeResult: mesh.FabricChannelRuntimeResult{
|
||||
Channel: mesh.FabricChannel{RouteID: "route-runtime", TargetNode: "node-runtime"},
|
||||
BytesSent: 123,
|
||||
BytesRecv: 16,
|
||||
FramesSent: 1,
|
||||
FramesRecv: 1,
|
||||
AcksReceived: 1,
|
||||
},
|
||||
ResponsePayload: []byte(`{"payload":{"schema_version":"rap.web_ingress.fabric_runtime_response.v1","status_code":201,"headers":{"X-RAP-Runtime":["ok"],"Set-Cookie":["blocked"]},"body_b64":"eyJvayI6dHJ1ZX0="}}`),
|
||||
},
|
||||
}
|
||||
sender := MeshEnvelopeSender{
|
||||
ResponseRuntime: runtime,
|
||||
RouteSet: testWebIngressRouteSet(),
|
||||
ClusterID: "cluster-1",
|
||||
SourceNodeID: "node-ingress",
|
||||
TargetKind: mesh.FabricChannelTargetPool,
|
||||
TargetID: "pool-admin-runtime",
|
||||
ChannelID: "channel-web-1",
|
||||
Now: fixedEnvelopeNow,
|
||||
}
|
||||
|
||||
response, err := sender.Send(context.Background(), SignedFabricServiceChannelEnvelope{
|
||||
SchemaVersion: "rap.web_ingress.signed_fabric_service_channel_envelope.v1",
|
||||
Envelope: FabricServiceChannelEnvelope{SchemaVersion: FabricServiceChannelEnvelopeSchema, Scope: "platform", ServiceClass: "platform_admin"},
|
||||
Signature: FabricEnvelopeSignature{KeyID: "node-key", Alg: "ed25519", Signature: "sig"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
if response.StatusCode != http.StatusCreated || response.Headers.Get("X-RAP-Runtime") != "ok" || response.Headers.Get("Set-Cookie") != "" || string(response.Body) != `{"ok":true}` {
|
||||
t.Fatalf("response = %+v body=%s", response, string(response.Body))
|
||||
}
|
||||
if runtime.spec.ChannelID != "channel-web-1" || len(runtime.payload) == 0 {
|
||||
t.Fatalf("runtime spec=%+v payload=%s", runtime.spec, string(runtime.payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeshEnvelopeSenderReportsWrappedRuntimeError(t *testing.T) {
|
||||
sender := MeshEnvelopeSender{
|
||||
ResponseRuntime: &recordingRequestResponseRuntime{
|
||||
result: mesh.FabricChannelRequestResponseResult{ResponsePayload: []byte(`{"error":"runtime unavailable"}`)},
|
||||
},
|
||||
RouteSet: testWebIngressRouteSet(),
|
||||
ClusterID: "cluster-1",
|
||||
SourceNodeID: "node-ingress",
|
||||
TargetID: "pool-admin-runtime",
|
||||
ChannelID: "channel-web-1",
|
||||
}
|
||||
|
||||
_, err := sender.Send(context.Background(), SignedFabricServiceChannelEnvelope{
|
||||
Envelope: FabricServiceChannelEnvelope{Scope: "platform", ServiceClass: "platform_admin"},
|
||||
})
|
||||
if !errors.Is(err, ErrMeshEnvelopeRuntimeRequired) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeshEnvelopeSenderFallsBackToDeliveryAckForNonHTTPRuntimePayload(t *testing.T) {
|
||||
runtime := &recordingRequestResponseRuntime{
|
||||
result: mesh.FabricChannelRequestResponseResult{
|
||||
FabricChannelRuntimeResult: mesh.FabricChannelRuntimeResult{
|
||||
Channel: mesh.FabricChannel{RouteID: "route-runtime", TargetNode: "node-runtime"},
|
||||
BytesSent: 123,
|
||||
FramesSent: 1,
|
||||
AcksReceived: 1,
|
||||
},
|
||||
ResponsePayload: []byte(`{"not":"http"}`),
|
||||
},
|
||||
}
|
||||
sender := MeshEnvelopeSender{
|
||||
ResponseRuntime: runtime,
|
||||
RouteSet: testWebIngressRouteSet(),
|
||||
ClusterID: "cluster-1",
|
||||
SourceNodeID: "node-ingress",
|
||||
TargetID: "pool-admin-runtime",
|
||||
ChannelID: "channel-web-1",
|
||||
}
|
||||
|
||||
response, err := sender.Send(context.Background(), SignedFabricServiceChannelEnvelope{
|
||||
Envelope: FabricServiceChannelEnvelope{Scope: "platform", ServiceClass: "platform_admin"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
if response.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("response = %+v", response)
|
||||
}
|
||||
var body MeshEnvelopeDeliveryResponse
|
||||
if err := json.Unmarshal(response.Body, &body); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if body.Status != "accepted" || body.RouteID != "route-runtime" {
|
||||
t.Fatalf("body = %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeshEnvelopeSenderReportsRuntimeRouteAndIdentityErrors(t *testing.T) {
|
||||
_, err := (MeshEnvelopeSender{}).Send(context.Background(), SignedFabricServiceChannelEnvelope{})
|
||||
if !errors.Is(err, ErrMeshEnvelopeRuntimeRequired) {
|
||||
t.Fatalf("runtime error = %v", err)
|
||||
}
|
||||
|
||||
_, err = (MeshEnvelopeSender{
|
||||
Runtime: &recordingReliableRuntime{},
|
||||
ClusterID: "cluster-1",
|
||||
SourceNodeID: "node-ingress",
|
||||
TargetID: "pool-admin-runtime",
|
||||
}).Send(context.Background(), SignedFabricServiceChannelEnvelope{})
|
||||
if !errors.Is(err, ErrMeshEnvelopeRouteRequired) {
|
||||
t.Fatalf("route error = %v", err)
|
||||
}
|
||||
|
||||
_, err = (MeshEnvelopeSender{
|
||||
Runtime: &recordingReliableRuntime{},
|
||||
RouteSet: testWebIngressRouteSet(),
|
||||
}).Send(context.Background(), SignedFabricServiceChannelEnvelope{})
|
||||
if !errors.Is(err, ErrMeshEnvelopeIdentityInvalid) {
|
||||
t.Fatalf("identity error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeshEnvelopeSenderPropagatesReliableRuntimeFailure(t *testing.T) {
|
||||
sendErr := errors.New("send failed")
|
||||
_, err := (MeshEnvelopeSender{
|
||||
Runtime: &recordingReliableRuntime{err: sendErr},
|
||||
RouteSet: testWebIngressRouteSet(),
|
||||
ClusterID: "cluster-1",
|
||||
SourceNodeID: "node-ingress",
|
||||
TargetID: "pool-admin-runtime",
|
||||
}).Send(context.Background(), SignedFabricServiceChannelEnvelope{})
|
||||
if !errors.Is(err, sendErr) {
|
||||
t.Fatalf("send error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingReliableRuntime struct {
|
||||
spec mesh.FabricChannelSpec
|
||||
routeSet mesh.FabricRouteSet
|
||||
payloads [][]byte
|
||||
result mesh.FabricChannelRuntimeResult
|
||||
err error
|
||||
}
|
||||
|
||||
type recordingRequestResponseRuntime struct {
|
||||
spec mesh.FabricChannelSpec
|
||||
routeSet mesh.FabricRouteSet
|
||||
payload []byte
|
||||
result mesh.FabricChannelRequestResponseResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *recordingRequestResponseRuntime) SendRequestResponse(_ context.Context, spec mesh.FabricChannelSpec, routeSet mesh.FabricRouteSet, payload []byte) (mesh.FabricChannelRequestResponseResult, error) {
|
||||
r.spec = spec
|
||||
r.routeSet = routeSet
|
||||
r.payload = payload
|
||||
if r.err != nil {
|
||||
return mesh.FabricChannelRequestResponseResult{}, r.err
|
||||
}
|
||||
return r.result, nil
|
||||
}
|
||||
|
||||
func (r *recordingReliableRuntime) SendReliable(_ context.Context, spec mesh.FabricChannelSpec, routeSet mesh.FabricRouteSet, payloads [][]byte) (mesh.FabricChannelRuntimeResult, error) {
|
||||
r.spec = spec
|
||||
r.routeSet = routeSet
|
||||
r.payloads = payloads
|
||||
if r.err != nil {
|
||||
return mesh.FabricChannelRuntimeResult{}, r.err
|
||||
}
|
||||
return r.result, nil
|
||||
}
|
||||
|
||||
func testWebIngressRouteSet() mesh.FabricRouteSet {
|
||||
return mesh.FabricRouteSet{
|
||||
Primary: mesh.FabricRoute{
|
||||
RouteID: "route-fast",
|
||||
ClusterID: "cluster-1",
|
||||
SourceNodeID: "node-ingress",
|
||||
DestinationNodeID: "node-runtime",
|
||||
PoolID: "pool-admin-runtime",
|
||||
Healthy: true,
|
||||
Capacity: 100,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package webingress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
SignedFabricServiceChannelEnvelopeSchema = "rap.web_ingress.signed_fabric_service_channel_envelope.v1"
|
||||
FabricRuntimeResponseSchema = "rap.web_ingress.fabric_runtime_response.v1"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFabricEnvelopeSignatureInvalid = errors.New("web ingress fabric envelope signature invalid")
|
||||
ErrFabricEnvelopeUnauthorized = errors.New("web ingress fabric envelope unauthorized")
|
||||
ErrFabricEnvelopeRuntimeRequired = errors.New("web ingress fabric runtime handler required")
|
||||
)
|
||||
|
||||
type EnvelopeKeyResolver interface {
|
||||
PublicKey(ctx context.Context, keyID string) (ed25519.PublicKey, bool, error)
|
||||
}
|
||||
|
||||
type EnvelopeRuntimeHandler interface {
|
||||
HandleFabricRequest(ctx context.Context, request FabricRequest) (FabricResponse, error)
|
||||
}
|
||||
|
||||
type RuntimeHandlerFunc func(ctx context.Context, request FabricRequest) (FabricResponse, error)
|
||||
|
||||
func (f RuntimeHandlerFunc) HandleFabricRequest(ctx context.Context, request FabricRequest) (FabricResponse, error) {
|
||||
return f(ctx, request)
|
||||
}
|
||||
|
||||
type ReceiverConfig struct {
|
||||
ServiceType string
|
||||
Scope string
|
||||
ServiceClasses []string
|
||||
MaxClockSkew time.Duration
|
||||
}
|
||||
|
||||
type FabricRuntimeReceiver struct {
|
||||
Config ReceiverConfig
|
||||
Keys EnvelopeKeyResolver
|
||||
Handler EnvelopeRuntimeHandler
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type StaticEnvelopeKeyResolver map[string]ed25519.PublicKey
|
||||
|
||||
func (r StaticEnvelopeKeyResolver) PublicKey(_ context.Context, keyID string) (ed25519.PublicKey, bool, error) {
|
||||
key, ok := r[strings.TrimSpace(keyID)]
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
return append(ed25519.PublicKey(nil), key...), true, nil
|
||||
}
|
||||
|
||||
func (r FabricRuntimeReceiver) Receive(ctx context.Context, payload []byte) ([]byte, error) {
|
||||
response, err := r.ReceiveResponse(ctx, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeFabricRuntimeResponse(response)
|
||||
}
|
||||
|
||||
func (r FabricRuntimeReceiver) ReceiveResponse(ctx context.Context, payload []byte) (FabricResponse, error) {
|
||||
if r.Handler == nil {
|
||||
return FabricResponse{}, ErrFabricEnvelopeRuntimeRequired
|
||||
}
|
||||
var signed SignedFabricServiceChannelEnvelope
|
||||
if err := json.Unmarshal(payload, &signed); err != nil {
|
||||
return FabricResponse{}, fmt.Errorf("%w: invalid signed envelope json", ErrFabricEnvelopeSignatureInvalid)
|
||||
}
|
||||
if err := r.verify(ctx, signed); err != nil {
|
||||
return FabricResponse{}, err
|
||||
}
|
||||
request, err := requestFromEnvelope(signed.Envelope)
|
||||
if err != nil {
|
||||
return FabricResponse{}, err
|
||||
}
|
||||
return r.Handler.HandleFabricRequest(ctx, request)
|
||||
}
|
||||
|
||||
func (r FabricRuntimeReceiver) verify(ctx context.Context, signed SignedFabricServiceChannelEnvelope) error {
|
||||
if signed.SchemaVersion != SignedFabricServiceChannelEnvelopeSchema {
|
||||
return fmt.Errorf("%w: signed schema mismatch", ErrFabricEnvelopeSignatureInvalid)
|
||||
}
|
||||
if signed.Envelope.SchemaVersion != FabricServiceChannelEnvelopeSchema ||
|
||||
strings.TrimSpace(signed.Envelope.Scope) == "" ||
|
||||
strings.TrimSpace(signed.Envelope.ServiceClass) == "" {
|
||||
return fmt.Errorf("%w: envelope contract invalid", ErrFabricEnvelopeSignatureInvalid)
|
||||
}
|
||||
if scope := strings.TrimSpace(r.Config.Scope); scope != "" && signed.Envelope.Scope != scope {
|
||||
return fmt.Errorf("%w: scope mismatch", ErrFabricEnvelopeUnauthorized)
|
||||
}
|
||||
if len(r.Config.ServiceClasses) > 0 && !contains(r.Config.ServiceClasses, signed.Envelope.ServiceClass) {
|
||||
return fmt.Errorf("%w: service class not allowed", ErrFabricEnvelopeUnauthorized)
|
||||
}
|
||||
if err := r.verifyClock(signed.Envelope); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Keys == nil {
|
||||
return fmt.Errorf("%w: key resolver required", ErrFabricEnvelopeSignatureInvalid)
|
||||
}
|
||||
keyID := strings.TrimSpace(signed.Signature.KeyID)
|
||||
publicKey, ok, err := r.Keys.PublicKey(ctx, keyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok || len(publicKey) != ed25519.PublicKeySize {
|
||||
return fmt.Errorf("%w: signing key not trusted", ErrFabricEnvelopeUnauthorized)
|
||||
}
|
||||
if signed.Signature.Alg != "ed25519" {
|
||||
return fmt.Errorf("%w: algorithm mismatch", ErrFabricEnvelopeSignatureInvalid)
|
||||
}
|
||||
signature, err := decodeEnvelopeBase64(signed.Signature.Signature)
|
||||
if err != nil || len(signature) != ed25519.SignatureSize {
|
||||
return fmt.Errorf("%w: signature must be base64 ed25519", ErrFabricEnvelopeSignatureInvalid)
|
||||
}
|
||||
canonical, err := json.Marshal(signed.Envelope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ed25519.Verify(publicKey, canonical, signature) {
|
||||
return ErrFabricEnvelopeSignatureInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r FabricRuntimeReceiver) verifyClock(envelope FabricServiceChannelEnvelope) error {
|
||||
maxSkew := r.Config.MaxClockSkew
|
||||
if maxSkew <= 0 {
|
||||
maxSkew = 5 * time.Minute
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if r.Now != nil {
|
||||
now = r.Now().UTC()
|
||||
}
|
||||
for _, value := range []string{envelope.ObservedAt, envelope.EnvelopedAt} {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
continue
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: invalid envelope timestamp", ErrFabricEnvelopeSignatureInvalid)
|
||||
}
|
||||
if parsed.After(now.Add(maxSkew)) || parsed.Before(now.Add(-maxSkew)) {
|
||||
return fmt.Errorf("%w: envelope timestamp outside skew", ErrFabricEnvelopeUnauthorized)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requestFromEnvelope(envelope FabricServiceChannelEnvelope) (FabricRequest, error) {
|
||||
body, err := base64.StdEncoding.DecodeString(envelope.BodyBase64)
|
||||
if err != nil && envelope.BodyBase64 != "" {
|
||||
return FabricRequest{}, fmt.Errorf("%w: invalid body_b64", ErrFabricEnvelopeSignatureInvalid)
|
||||
}
|
||||
observedAt, _ := time.Parse(time.RFC3339Nano, envelope.ObservedAt)
|
||||
headers := http.Header{}
|
||||
for key, values := range envelope.Headers {
|
||||
if !safeRequestHeader(key) {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
headers.Add(key, value)
|
||||
}
|
||||
}
|
||||
return FabricRequest{
|
||||
SchemaVersion: envelope.RequestSchema,
|
||||
Method: envelope.Method,
|
||||
Path: envelope.Path,
|
||||
Query: envelope.Query,
|
||||
Host: envelope.Host,
|
||||
ServiceType: envelope.ServiceType,
|
||||
Scope: envelope.Scope,
|
||||
ServiceClass: envelope.ServiceClass,
|
||||
Headers: headers,
|
||||
Body: body,
|
||||
ObservedAt: observedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func encodeFabricRuntimeResponse(response FabricResponse) ([]byte, error) {
|
||||
headers := map[string][]string{}
|
||||
for key, values := range response.Headers {
|
||||
if !safeResponseHeader(key) {
|
||||
continue
|
||||
}
|
||||
copied := append([]string(nil), values...)
|
||||
if len(copied) > 0 {
|
||||
headers[http.CanonicalHeaderKey(key)] = copied
|
||||
}
|
||||
}
|
||||
payload := struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
StatusCode int `json:"status_code"`
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
BodyBase64 string `json:"body_b64,omitempty"`
|
||||
}{
|
||||
SchemaVersion: FabricRuntimeResponseSchema,
|
||||
StatusCode: response.StatusCode,
|
||||
Headers: headers,
|
||||
BodyBase64: base64.StdEncoding.EncodeToString(response.Body),
|
||||
}
|
||||
if payload.StatusCode < 100 || payload.StatusCode > 599 {
|
||||
payload.StatusCode = http.StatusOK
|
||||
}
|
||||
if len(payload.Headers) == 0 {
|
||||
payload.Headers = nil
|
||||
}
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package webingress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFabricRuntimeReceiverVerifiesEnvelopeAndReturnsRuntimeResponse(t *testing.T) {
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
keyID := ed25519EnvelopeKeyID(publicKey)
|
||||
receiver := FabricRuntimeReceiver{
|
||||
Config: ReceiverConfig{ServiceType: "global-admin-runtime", Scope: "platform", ServiceClasses: []string{"platform_admin"}},
|
||||
Keys: StaticEnvelopeKeyResolver{keyID: publicKey},
|
||||
Handler: recordingRuntimeHandler{response: FabricResponse{
|
||||
StatusCode: http.StatusCreated,
|
||||
Headers: http.Header{"X-RAP-Runtime": []string{"ok"}, "Set-Cookie": []string{"blocked"}},
|
||||
Body: []byte(`{"ok":true}`),
|
||||
}},
|
||||
Now: fixedEnvelopeNow,
|
||||
}
|
||||
payload := signedReceiverEnvelope(t, privateKey, keyID, FabricServiceChannelEnvelope{
|
||||
SchemaVersion: FabricServiceChannelEnvelopeSchema,
|
||||
RequestSchema: "rap.web_ingress.fabric_request.v1",
|
||||
Method: http.MethodPost,
|
||||
Path: "/platform-admin/root",
|
||||
Query: "tab=nodes",
|
||||
Host: "admin.example.test",
|
||||
ServiceType: "admin-ingress",
|
||||
Scope: "platform",
|
||||
ServiceClass: "platform_admin",
|
||||
Headers: map[string][]string{"X-Trace-Id": {"trace-1"}},
|
||||
BodyBase64: base64.StdEncoding.EncodeToString([]byte(`{"hello":"world"}`)),
|
||||
ObservedAt: "2026-05-17T00:00:00Z",
|
||||
EnvelopedAt: "2026-05-17T00:00:01Z",
|
||||
})
|
||||
|
||||
responsePayload, err := receiver.Receive(context.Background(), payload)
|
||||
if err != nil {
|
||||
t.Fatalf("receive: %v", err)
|
||||
}
|
||||
var response struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
StatusCode int `json:"status_code"`
|
||||
Headers map[string][]string `json:"headers"`
|
||||
BodyBase64 string `json:"body_b64"`
|
||||
}
|
||||
if err := json.Unmarshal(responsePayload, &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.SchemaVersion != FabricRuntimeResponseSchema ||
|
||||
response.StatusCode != http.StatusCreated ||
|
||||
response.Headers["X-Rap-Runtime"][0] != "ok" ||
|
||||
response.Headers["Set-Cookie"] != nil ||
|
||||
response.BodyBase64 != "eyJvayI6dHJ1ZX0=" {
|
||||
t.Fatalf("response = %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFabricRuntimeReceiverRejectsBadSignatureScopeClassAndStaleEnvelope(t *testing.T) {
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
keyID := ed25519EnvelopeKeyID(publicKey)
|
||||
receiver := FabricRuntimeReceiver{
|
||||
Config: ReceiverConfig{Scope: "platform", ServiceClasses: []string{"platform_admin"}},
|
||||
Keys: StaticEnvelopeKeyResolver{keyID: publicKey},
|
||||
Handler: recordingRuntimeHandler{},
|
||||
Now: fixedEnvelopeNow,
|
||||
}
|
||||
|
||||
base := FabricServiceChannelEnvelope{
|
||||
SchemaVersion: FabricServiceChannelEnvelopeSchema,
|
||||
RequestSchema: "rap.web_ingress.fabric_request.v1",
|
||||
Method: http.MethodGet,
|
||||
Path: "/platform-admin/root",
|
||||
Scope: "platform",
|
||||
ServiceClass: "platform_admin",
|
||||
ObservedAt: "2026-05-17T00:00:00Z",
|
||||
EnvelopedAt: "2026-05-17T00:00:01Z",
|
||||
}
|
||||
badSignature := signedReceiverEnvelope(t, privateKey, keyID, base)
|
||||
badSignature[len(badSignature)-2] = 'x'
|
||||
if _, err := receiver.Receive(context.Background(), badSignature); !errors.Is(err, ErrFabricEnvelopeSignatureInvalid) {
|
||||
t.Fatalf("bad signature err = %v", err)
|
||||
}
|
||||
|
||||
wrongScope := base
|
||||
wrongScope.Scope = "organization"
|
||||
if _, err := receiver.Receive(context.Background(), signedReceiverEnvelope(t, privateKey, keyID, wrongScope)); !errors.Is(err, ErrFabricEnvelopeUnauthorized) {
|
||||
t.Fatalf("wrong scope err = %v", err)
|
||||
}
|
||||
|
||||
wrongClass := base
|
||||
wrongClass.ServiceClass = "cluster_admin"
|
||||
if _, err := receiver.Receive(context.Background(), signedReceiverEnvelope(t, privateKey, keyID, wrongClass)); !errors.Is(err, ErrFabricEnvelopeUnauthorized) {
|
||||
t.Fatalf("wrong class err = %v", err)
|
||||
}
|
||||
|
||||
stale := base
|
||||
stale.EnvelopedAt = "2026-05-16T00:00:00Z"
|
||||
if _, err := receiver.Receive(context.Background(), signedReceiverEnvelope(t, privateKey, keyID, stale)); !errors.Is(err, ErrFabricEnvelopeUnauthorized) {
|
||||
t.Fatalf("stale err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFabricRuntimeReceiverRequiresTrustedKeyAndHandler(t *testing.T) {
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
keyID := ed25519EnvelopeKeyID(publicKey)
|
||||
payload := signedReceiverEnvelope(t, privateKey, keyID, FabricServiceChannelEnvelope{
|
||||
SchemaVersion: FabricServiceChannelEnvelopeSchema,
|
||||
Scope: "platform",
|
||||
ServiceClass: "platform_admin",
|
||||
ObservedAt: "2026-05-17T00:00:00Z",
|
||||
EnvelopedAt: "2026-05-17T00:00:01Z",
|
||||
})
|
||||
|
||||
_, err = (FabricRuntimeReceiver{Keys: StaticEnvelopeKeyResolver{keyID: publicKey}, Now: fixedEnvelopeNow}).Receive(context.Background(), payload)
|
||||
if !errors.Is(err, ErrFabricEnvelopeRuntimeRequired) {
|
||||
t.Fatalf("handler err = %v", err)
|
||||
}
|
||||
|
||||
_, err = (FabricRuntimeReceiver{Handler: recordingRuntimeHandler{}, Now: fixedEnvelopeNow}).Receive(context.Background(), payload)
|
||||
if !errors.Is(err, ErrFabricEnvelopeSignatureInvalid) {
|
||||
t.Fatalf("key resolver err = %v", err)
|
||||
}
|
||||
|
||||
_, otherPrivateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate other key: %v", err)
|
||||
}
|
||||
untrusted := signedReceiverEnvelope(t, otherPrivateKey, "other-key", FabricServiceChannelEnvelope{
|
||||
SchemaVersion: FabricServiceChannelEnvelopeSchema,
|
||||
Scope: "platform",
|
||||
ServiceClass: "platform_admin",
|
||||
ObservedAt: "2026-05-17T00:00:00Z",
|
||||
EnvelopedAt: "2026-05-17T00:00:01Z",
|
||||
})
|
||||
_, err = (FabricRuntimeReceiver{Keys: StaticEnvelopeKeyResolver{keyID: publicKey}, Handler: recordingRuntimeHandler{}, Now: fixedEnvelopeNow}).Receive(context.Background(), untrusted)
|
||||
if !errors.Is(err, ErrFabricEnvelopeUnauthorized) {
|
||||
t.Fatalf("untrusted key err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func signedReceiverEnvelope(t *testing.T, privateKey ed25519.PrivateKey, keyID string, envelope FabricServiceChannelEnvelope) []byte {
|
||||
t.Helper()
|
||||
canonical, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal envelope: %v", err)
|
||||
}
|
||||
payload, err := json.Marshal(SignedFabricServiceChannelEnvelope{
|
||||
SchemaVersion: SignedFabricServiceChannelEnvelopeSchema,
|
||||
Envelope: envelope,
|
||||
Signature: FabricEnvelopeSignature{
|
||||
KeyID: keyID,
|
||||
Alg: "ed25519",
|
||||
Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)),
|
||||
SignedAt: "2026-05-17T00:00:01Z",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal signed envelope: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
type recordingRuntimeHandler struct {
|
||||
request FabricRequest
|
||||
response FabricResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (h recordingRuntimeHandler) HandleFabricRequest(_ context.Context, request FabricRequest) (FabricResponse, error) {
|
||||
h.request = request
|
||||
if h.err != nil {
|
||||
return FabricResponse{}, h.err
|
||||
}
|
||||
if h.response.StatusCode == 0 {
|
||||
h.response = FabricResponse{StatusCode: http.StatusOK, Body: []byte(`{"ready":true}`)}
|
||||
}
|
||||
return h.response, nil
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package webingress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RuntimeConfig struct {
|
||||
ServiceType string
|
||||
Scope string
|
||||
ServiceClasses []string
|
||||
TLSMode string
|
||||
HTTPPort int
|
||||
HTTPSPort int
|
||||
}
|
||||
|
||||
type Runtime struct {
|
||||
Config RuntimeConfig
|
||||
Binder FabricBinder
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type FabricBinder interface {
|
||||
Forward(ctx context.Context, request FabricRequest) (FabricResponse, error)
|
||||
}
|
||||
|
||||
type FabricRequest struct {
|
||||
SchemaVersion string
|
||||
Method string
|
||||
Path string
|
||||
Query string
|
||||
Host string
|
||||
ServiceType string
|
||||
Scope string
|
||||
ServiceClass string
|
||||
Headers http.Header
|
||||
Body []byte
|
||||
ObservedAt time.Time
|
||||
}
|
||||
|
||||
type FabricResponse struct {
|
||||
StatusCode int
|
||||
Headers http.Header
|
||||
Body []byte
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
ServiceType string `json:"service_type,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ServiceClass string `json:"service_class,omitempty"`
|
||||
Allowed []string `json:"allowed_service_classes,omitempty"`
|
||||
ObservedAt string `json:"observed_at"`
|
||||
}
|
||||
|
||||
func (r Runtime) HTTPHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if strings.HasPrefix(req.URL.Path, "/.well-known/acme-challenge/") {
|
||||
writeJSON(w, http.StatusNotFound, r.response("not_found", "acme_challenge_backend_not_configured", ""))
|
||||
return
|
||||
}
|
||||
if req.URL.Path == "/healthz" || req.URL.Path == "/readyz" {
|
||||
writeJSON(w, http.StatusOK, r.response("ready", "http_redirect_runtime_ready", ""))
|
||||
return
|
||||
}
|
||||
target := "https://" + req.Host + req.URL.RequestURI()
|
||||
w.Header().Set("Location", target)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusPermanentRedirect)
|
||||
})
|
||||
}
|
||||
|
||||
func (r Runtime) HTTPSHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.URL.Path == "/healthz" || req.URL.Path == "/readyz" {
|
||||
writeJSON(w, http.StatusOK, r.response("ready", "https_runtime_ready", ""))
|
||||
return
|
||||
}
|
||||
serviceClass := strings.TrimSpace(req.Header.Get("X-RAP-Service-Class"))
|
||||
if serviceClass == "" {
|
||||
serviceClass = serviceClassFromPath(req.URL.Path)
|
||||
}
|
||||
if serviceClass == "" {
|
||||
writeJSON(w, http.StatusBadRequest, r.response("blocked", "service_class_required", ""))
|
||||
return
|
||||
}
|
||||
if !contains(r.Config.ServiceClasses, serviceClass) {
|
||||
writeJSON(w, http.StatusForbidden, r.response("blocked", "service_class_not_allowed", serviceClass))
|
||||
return
|
||||
}
|
||||
if r.Binder == nil {
|
||||
writeJSON(w, http.StatusNotImplemented, r.response("blocked", "fabric_service_channel_binding_not_implemented", serviceClass))
|
||||
return
|
||||
}
|
||||
scope := scopeForServiceClass(serviceClass, r.Config.Scope)
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, req.Body, 1<<20))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusRequestEntityTooLarge, r.response("blocked", "request_body_too_large", serviceClass))
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if r.Now != nil {
|
||||
now = r.Now().UTC()
|
||||
}
|
||||
fabricResponse, err := r.Binder.Forward(req.Context(), FabricRequest{
|
||||
SchemaVersion: "rap.web_ingress.fabric_request.v1",
|
||||
Method: req.Method,
|
||||
Path: req.URL.Path,
|
||||
Query: req.URL.RawQuery,
|
||||
Host: req.Host,
|
||||
ServiceType: strings.TrimSpace(r.Config.ServiceType),
|
||||
Scope: scope,
|
||||
ServiceClass: serviceClass,
|
||||
Headers: cloneSafeHeaders(req.Header),
|
||||
Body: body,
|
||||
ObservedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, r.response("blocked", "fabric_service_channel_forward_failed", serviceClass))
|
||||
return
|
||||
}
|
||||
writeFabricResponse(w, fabricResponse)
|
||||
})
|
||||
}
|
||||
|
||||
func (r Runtime) response(status, reason, serviceClass string) Response {
|
||||
now := time.Now().UTC()
|
||||
if r.Now != nil {
|
||||
now = r.Now().UTC()
|
||||
}
|
||||
return Response{
|
||||
SchemaVersion: "rap.web_ingress.runtime_response.v1",
|
||||
Status: status,
|
||||
Reason: reason,
|
||||
ServiceType: strings.TrimSpace(r.Config.ServiceType),
|
||||
Scope: strings.TrimSpace(r.Config.Scope),
|
||||
ServiceClass: serviceClass,
|
||||
Allowed: append([]string{}, r.Config.ServiceClasses...),
|
||||
ObservedAt: now.Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
|
||||
func scopeForServiceClass(serviceClass string, fallback string) string {
|
||||
switch strings.TrimSpace(serviceClass) {
|
||||
case "platform_admin":
|
||||
return "platform"
|
||||
case "cluster_admin":
|
||||
return "cluster"
|
||||
case "organization_portal":
|
||||
return "organization"
|
||||
case "user_portal":
|
||||
return "user"
|
||||
default:
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
}
|
||||
|
||||
func serviceClassFromPath(path string) string {
|
||||
path = strings.Trim(strings.ToLower(path), "/")
|
||||
switch {
|
||||
case strings.HasPrefix(path, "platform-admin"):
|
||||
return "platform_admin"
|
||||
case strings.HasPrefix(path, "cluster-admin"):
|
||||
return "cluster_admin"
|
||||
case strings.HasPrefix(path, "organizations/"):
|
||||
return "organization_portal"
|
||||
case strings.HasPrefix(path, "users/"):
|
||||
return "user_portal"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload Response) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func writeFabricResponse(w http.ResponseWriter, payload FabricResponse) {
|
||||
for key, values := range payload.Headers {
|
||||
if !safeResponseHeader(key) {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
w.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
status := payload.StatusCode
|
||||
if status < 100 || status > 599 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(payload.Body)
|
||||
}
|
||||
|
||||
func cloneSafeHeaders(headers http.Header) http.Header {
|
||||
out := http.Header{}
|
||||
for key, values := range headers {
|
||||
if !safeRequestHeader(key) {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
out.Add(key, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func safeRequestHeader(key string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||
case "authorization", "cookie", "set-cookie", "x-rap-service-channel-token":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func safeResponseHeader(key string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||
case "set-cookie", "transfer-encoding", "connection":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func contains(values []string, needle string) bool {
|
||||
for _, value := range values {
|
||||
if value == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package webingress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHTTPHandlerRedirectsToHTTPS(t *testing.T) {
|
||||
runtime := Runtime{Config: RuntimeConfig{ServiceType: "admin-ingress", Scope: "platform"}}
|
||||
req := httptest.NewRequest(http.MethodGet, "http://admin.example.test/cluster-admin/dashboard?x=1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
runtime.HTTPHandler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusPermanentRedirect {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
if rec.Header().Get("Location") != "https://admin.example.test/cluster-admin/dashboard?x=1" {
|
||||
t.Fatalf("Location = %q", rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSHandlerBlocksUnknownServiceClass(t *testing.T) {
|
||||
runtime := Runtime{
|
||||
Config: RuntimeConfig{
|
||||
ServiceType: "public-ingress",
|
||||
Scope: "organization",
|
||||
ServiceClasses: []string{"organization_portal", "user_portal"},
|
||||
},
|
||||
Now: fixedNow,
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "https://org.example.test/platform-admin/root", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
runtime.HTTPSHandler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var payload Response
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Reason != "service_class_not_allowed" || payload.ServiceClass != "platform_admin" || payload.Scope != "organization" {
|
||||
t.Fatalf("payload = %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSHandlerRequiresFabricServiceChannelBinding(t *testing.T) {
|
||||
runtime := Runtime{
|
||||
Config: RuntimeConfig{
|
||||
ServiceType: "admin-ingress",
|
||||
Scope: "platform",
|
||||
ServiceClasses: []string{"platform_admin", "cluster_admin"},
|
||||
},
|
||||
Now: fixedNow,
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "https://admin.example.test/platform-admin/root", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
runtime.HTTPSHandler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var payload Response
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Reason != "fabric_service_channel_binding_not_implemented" ||
|
||||
payload.ServiceClass != "platform_admin" ||
|
||||
payload.ObservedAt != "2026-05-17T00:00:00Z" {
|
||||
t.Fatalf("payload = %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSHandlerForwardsAllowedRequestToBinder(t *testing.T) {
|
||||
binder := &recordingBinder{
|
||||
response: FabricResponse{
|
||||
StatusCode: http.StatusAccepted,
|
||||
Headers: http.Header{"X-RAP-Result": []string{"accepted"}},
|
||||
Body: []byte(`{"ok":true}`),
|
||||
},
|
||||
}
|
||||
runtime := Runtime{
|
||||
Config: RuntimeConfig{
|
||||
ServiceType: "admin-ingress",
|
||||
Scope: "platform",
|
||||
ServiceClasses: []string{"platform_admin", "cluster_admin"},
|
||||
},
|
||||
Binder: binder,
|
||||
Now: fixedNow,
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "https://admin.example.test/platform-admin/root?tab=nodes", strings.NewReader(`{"hello":"world"}`))
|
||||
req.Header.Set("X-RAP-Service-Class", "platform_admin")
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
req.Header.Set("X-Trace-ID", "trace-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
runtime.HTTPSHandler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Header().Get("X-RAP-Result") != "accepted" || rec.Body.String() != `{"ok":true}` {
|
||||
t.Fatalf("unexpected response headers=%v body=%s", rec.Header(), rec.Body.String())
|
||||
}
|
||||
if binder.request.ServiceClass != "platform_admin" ||
|
||||
binder.request.Scope != "platform" ||
|
||||
binder.request.Path != "/platform-admin/root" ||
|
||||
binder.request.Query != "tab=nodes" ||
|
||||
string(binder.request.Body) != `{"hello":"world"}` {
|
||||
t.Fatalf("request = %+v", binder.request)
|
||||
}
|
||||
if binder.request.Headers.Get("Authorization") != "" || binder.request.Headers.Get("X-Trace-ID") != "trace-1" {
|
||||
t.Fatalf("headers = %#v", binder.request.Headers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSHandlerDerivesFabricScopeFromServiceClass(t *testing.T) {
|
||||
binder := &recordingBinder{response: FabricResponse{StatusCode: http.StatusOK}}
|
||||
runtime := Runtime{
|
||||
Config: RuntimeConfig{
|
||||
ServiceType: "admin-ingress",
|
||||
Scope: "platform",
|
||||
ServiceClasses: []string{"platform_admin", "cluster_admin"},
|
||||
},
|
||||
Binder: binder,
|
||||
Now: fixedNow,
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "https://admin.example.test/cluster-admin/ui-manifest", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
runtime.HTTPSHandler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if binder.request.ServiceClass != "cluster_admin" || binder.request.Scope != "cluster" {
|
||||
t.Fatalf("request = %+v", binder.request)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSHandlerReportsBinderFailure(t *testing.T) {
|
||||
runtime := Runtime{
|
||||
Config: RuntimeConfig{ServiceType: "admin-ingress", Scope: "platform", ServiceClasses: []string{"platform_admin"}},
|
||||
Binder: failingBinder{},
|
||||
Now: fixedNow,
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "https://admin.example.test/platform-admin/root", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
runtime.HTTPSHandler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var payload Response
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Reason != "fabric_service_channel_forward_failed" {
|
||||
t.Fatalf("payload = %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSHandlerHealth(t *testing.T) {
|
||||
runtime := Runtime{Config: RuntimeConfig{ServiceType: "admin-ingress", Scope: "platform"}, Now: fixedNow}
|
||||
req := httptest.NewRequest(http.MethodGet, "https://admin.example.test/healthz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
runtime.HTTPSHandler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func fixedNow() time.Time {
|
||||
return time.Date(2026, 5, 17, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
type recordingBinder struct {
|
||||
request FabricRequest
|
||||
response FabricResponse
|
||||
}
|
||||
|
||||
func (b *recordingBinder) Forward(_ context.Context, request FabricRequest) (FabricResponse, error) {
|
||||
b.request = request
|
||||
return b.response, nil
|
||||
}
|
||||
|
||||
type failingBinder struct{}
|
||||
|
||||
func (failingBinder) Forward(context.Context, FabricRequest) (FabricResponse, error) {
|
||||
return FabricResponse{}, errTestBinderFailure{}
|
||||
}
|
||||
|
||||
type errTestBinderFailure struct{}
|
||||
|
||||
func (errTestBinderFailure) Error() string { return "binder failed" }
|
||||
@@ -0,0 +1,95 @@
|
||||
package webingress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrFabricEnvelopeSigningKeyInvalid = errors.New("web ingress fabric envelope signing key invalid")
|
||||
|
||||
type Ed25519EnvelopeSigner struct {
|
||||
PrivateKey ed25519.PrivateKey
|
||||
KeyID string
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func NewEd25519EnvelopeSigner(privateKeyB64, keyID string) (Ed25519EnvelopeSigner, error) {
|
||||
privateKey, err := decodeEd25519PrivateKey(privateKeyB64)
|
||||
if err != nil {
|
||||
return Ed25519EnvelopeSigner{}, err
|
||||
}
|
||||
keyID = strings.TrimSpace(keyID)
|
||||
if keyID == "" {
|
||||
publicKey, ok := privateKey.Public().(ed25519.PublicKey)
|
||||
if !ok {
|
||||
return Ed25519EnvelopeSigner{}, ErrFabricEnvelopeSigningKeyInvalid
|
||||
}
|
||||
keyID = ed25519EnvelopeKeyID(publicKey)
|
||||
}
|
||||
return Ed25519EnvelopeSigner{PrivateKey: privateKey, KeyID: keyID}, nil
|
||||
}
|
||||
|
||||
func (s Ed25519EnvelopeSigner) Sign(_ context.Context, canonical []byte) (FabricEnvelopeSignature, error) {
|
||||
if len(s.PrivateKey) != ed25519.PrivateKeySize {
|
||||
return FabricEnvelopeSignature{}, ErrFabricEnvelopeSigningKeyInvalid
|
||||
}
|
||||
if len(canonical) == 0 {
|
||||
return FabricEnvelopeSignature{}, fmt.Errorf("%w: canonical envelope empty", ErrFabricEnvelopeSigningKeyInvalid)
|
||||
}
|
||||
keyID := strings.TrimSpace(s.KeyID)
|
||||
if keyID == "" {
|
||||
publicKey, ok := s.PrivateKey.Public().(ed25519.PublicKey)
|
||||
if !ok {
|
||||
return FabricEnvelopeSignature{}, ErrFabricEnvelopeSigningKeyInvalid
|
||||
}
|
||||
keyID = ed25519EnvelopeKeyID(publicKey)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if s.Now != nil {
|
||||
now = s.Now().UTC()
|
||||
}
|
||||
return FabricEnvelopeSignature{
|
||||
KeyID: keyID,
|
||||
Alg: "ed25519",
|
||||
Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(s.PrivateKey, canonical)),
|
||||
SignedAt: now.Format(time.RFC3339Nano),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeEd25519PrivateKey(value string) (ed25519.PrivateKey, error) {
|
||||
decoded, err := decodeEnvelopeBase64(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: private key must be base64 encoded", ErrFabricEnvelopeSigningKeyInvalid)
|
||||
}
|
||||
if len(decoded) != ed25519.PrivateKeySize {
|
||||
return nil, fmt.Errorf("%w: private key must decode to %d bytes", ErrFabricEnvelopeSigningKeyInvalid, ed25519.PrivateKeySize)
|
||||
}
|
||||
return ed25519.PrivateKey(decoded), nil
|
||||
}
|
||||
|
||||
func decodeEnvelopeBase64(value string) ([]byte, error) {
|
||||
if value == "" {
|
||||
return nil, errors.New("empty base64 value")
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(value)
|
||||
if err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
decoded, err = base64.RawStdEncoding.DecodeString(value)
|
||||
if err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
return base64.RawURLEncoding.DecodeString(value)
|
||||
}
|
||||
|
||||
func ed25519EnvelopeKeyID(publicKey ed25519.PublicKey) string {
|
||||
sum := sha256.Sum256(publicKey)
|
||||
return "rap-node-ed25519-" + hex.EncodeToString(sum[:16])
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package webingress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEd25519EnvelopeSignerSignsCanonicalEnvelope(t *testing.T) {
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
signer, err := NewEd25519EnvelopeSigner(base64.StdEncoding.EncodeToString(privateKey), "")
|
||||
if err != nil {
|
||||
t.Fatalf("new signer: %v", err)
|
||||
}
|
||||
signer.Now = fixedEnvelopeNow
|
||||
|
||||
signature, err := signer.Sign(context.Background(), []byte(`{"schema_version":"test"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(signature.Signature)
|
||||
if err != nil {
|
||||
t.Fatalf("decode signature: %v", err)
|
||||
}
|
||||
if !ed25519.Verify(publicKey, []byte(`{"schema_version":"test"}`), decoded) {
|
||||
t.Fatal("signature did not verify")
|
||||
}
|
||||
if signature.KeyID != ed25519EnvelopeKeyID(publicKey) ||
|
||||
signature.Alg != "ed25519" ||
|
||||
signature.SignedAt != "2026-05-17T00:00:01Z" {
|
||||
t.Fatalf("signature metadata = %+v", signature)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEd25519EnvelopeSignerUsesExplicitKeyID(t *testing.T) {
|
||||
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
signer, err := NewEd25519EnvelopeSigner(base64.RawStdEncoding.EncodeToString(privateKey), "node-explicit")
|
||||
if err != nil {
|
||||
t.Fatalf("new signer: %v", err)
|
||||
}
|
||||
signature, err := signer.Sign(context.Background(), []byte(`{}`))
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
if signature.KeyID != "node-explicit" {
|
||||
t.Fatalf("key id = %q", signature.KeyID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEd25519EnvelopeSignerRejectsInvalidKeyAndPayload(t *testing.T) {
|
||||
_, err := NewEd25519EnvelopeSigner("not-base64", "")
|
||||
if !errors.Is(err, ErrFabricEnvelopeSigningKeyInvalid) {
|
||||
t.Fatalf("invalid key error = %v", err)
|
||||
}
|
||||
|
||||
signer := Ed25519EnvelopeSigner{}
|
||||
_, err = signer.Sign(context.Background(), []byte(`{}`))
|
||||
if !errors.Is(err, ErrFabricEnvelopeSigningKeyInvalid) {
|
||||
t.Fatalf("missing key error = %v", err)
|
||||
}
|
||||
|
||||
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
signer = Ed25519EnvelopeSigner{PrivateKey: privateKey}
|
||||
_, err = signer.Sign(context.Background(), nil)
|
||||
if !errors.Is(err, ErrFabricEnvelopeSigningKeyInvalid) {
|
||||
t.Fatalf("empty canonical error = %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user