feat: edge gateway service

This commit is contained in:
Ilia Denisov
2026-04-02 19:18:42 +02:00
committed by GitHub
parent 8cde99936c
commit 436c97a38b
95 changed files with 20504 additions and 57 deletions
+1
View File
@@ -1,2 +1,3 @@
.codex
.vscode/
artifacts/
+36 -11
View File
@@ -13,7 +13,27 @@ It is the starting point for implementing the external edge layer, authenticatio
- Internal business services are **not reachable directly from outside**.
- Any external command, except public auth commands, must be authenticated before it is routed further.
- Gateway handles only edge concerns. Business validation and domain rules remain inside business services.
- Push / long-polling delivery is also handled by the gateway.
- Gateway owns external delivery channels; the v1 implementation uses
authenticated gRPC server-streaming push, while long-polling remains out of
scope.
```mermaid
flowchart LR
Client["Clients\n(native and browser)"]
Gateway["Edge Gateway\npublic REST + authenticated gRPC"]
Auth["Auth / Session Service"]
Business["Business Services"]
Redis["Redis\nsession cache + replay keys + event streams"]
Telemetry["Telemetry Backends\nPrometheus / OTLP"]
Client --> Gateway
Gateway --> Auth
Gateway --> Business
Gateway --> Redis
Gateway --> Telemetry
Auth --> Redis
Business --> Redis
```
## Main Components
@@ -33,7 +53,7 @@ Responsibilities:
- rate limiting and abuse protection
- command routing
- basic policy enforcement
- long-polling / push connection handling
- authenticated gRPC server-streaming push connection handling
- delivery of client-facing events from pub/sub
The gateway must not implement domain-specific business logic.
@@ -158,21 +178,26 @@ Flow:
7. gateway verifies anti-replay constraints
8. gateway applies rate limits and basic policy checks
9. gateway extracts authenticated context, including `user_id`
10. gateway routes the request to the target business service based on `command_type`
10. gateway routes the request to the target business service based on `message_type`
No business service should receive an unauthenticated external request.
### Push / Long-Polling Flow
### Push Flow
The gateway owns external push / long-polling connections.
The gateway owns external delivery connections.
The v1 gateway uses authenticated gRPC server-streaming push.
Long-polling remains out of scope for the implemented gateway.
Flow:
1. client opens authenticated push / long-polling connection through gateway
1. client opens authenticated push connection through gateway
2. gateway binds connection to `user_id` and `device_session_id`
3. gateway may send current server time for clock offset calculation
4. internal services publish client-facing events to pub/sub
5. gateway consumes those events and delivers them to the proper client connections
3. gateway starts the channel with a signed service event that includes the
current server time for clock offset calculation
4. internal services publish client-facing events to pub/sub targeted by
`user_id` and optionally by `device_session_id`
5. gateway consumes those events and delivers them to the proper client
connections
Gateway is a delivery layer, not the source of business events.
@@ -184,7 +209,7 @@ Typical internal authenticated context:
- `user_id`
- `device_session_id`
- `command_type`
- `message_type`
- verified payload bytes
- transport `request_id`
- optional command id / trace id
@@ -218,7 +243,7 @@ When a device session is revoked:
- auth/session service publishes revoke/invalidation event
- gateway updates or invalidates session cache
- gateway rejects further requests for that session
- gateway closes active push / long-polling connections bound to that session, if applicable
- gateway closes active authenticated push streams bound to that session, if applicable
## Non-Goals
+122 -7
View File
@@ -15,13 +15,34 @@ It is the starting point for implementing authenticated device sessions, signed
- Responses are authenticated by server-side signatures.
- Transport integrity and freshness are verified before payload is processed.
```mermaid
sequenceDiagram
participant Client
participant Gateway
participant SessionCache
participant ReplayStore
participant Business
Client->>Gateway: ExecuteCommand / SubscribeEvents\n(protocol_version, device_session_id,\nmessage_type, timestamp_ms, request_id,\npayload_hash, signature)
Gateway->>SessionCache: lookup(device_session_id)
SessionCache-->>Gateway: user_id, client_public_key, status
Gateway->>Gateway: verify payload_hash, signature,\nfreshness window
Gateway->>ReplayStore: reserve(device_session_id, request_id, ttl)
ReplayStore-->>Gateway: accepted / duplicate
Gateway->>Business: verified command context
Business-->>Gateway: response payload
Gateway-->>Client: signed response
Gateway-->>Client: signed push events on SubscribeEvents
```
## Device Session Model
After successful login through e-mail code:
1. client generates an asymmetric key pair
2. private key remains on the client device
3. public key is registered on the server
3. public key is registered on the server as the standard base64-encoded raw
32-byte Ed25519 public key
4. server creates a persistent `device_session`
5. client stores:
- `device_session_id`
@@ -31,7 +52,7 @@ The server stores at least:
- `device_session_id`
- `user_id`
- client public key
- base64-encoded raw 32-byte Ed25519 client public key
- session status
- revoke metadata
@@ -66,11 +87,18 @@ Minimal required fields:
- `request_id`
- `payload_hash`
The supported request `protocol_version` literal for the v1 gateway transport
is `v1`.
The v1 authenticated request signature scheme is Ed25519.
The stored client public key is the standard base64-encoded raw 32-byte
Ed25519 public key, and the request `signature` field carries the raw
64-byte Ed25519 signature bytes.
### Request Signing Input
The client signs canonical bytes built from:
- request domain marker, for example `myapp-request-v1`
- request domain marker `galaxy-request-v1`
- `protocol_version`
- `device_session_id`
- `message_type`
@@ -78,7 +106,16 @@ The client signs canonical bytes built from:
- `request_id`
- `payload_hash`
`payload_hash` should be computed from raw `payload_bytes`.
The canonical v1 request signing input uses this binary encoding:
- each `string` and `bytes` field is encoded as `uvarint(len(field_bytes))`
followed by raw bytes
- `timestamp_ms` is encoded as an 8-byte big-endian unsigned integer
- fields are appended in the exact order listed above
`payload_hash` is the raw 32-byte SHA-256 digest computed from raw
`payload_bytes`.
Empty payloads still use the SHA-256 digest of the empty byte slice.
The goal is to bind the signature to:
@@ -109,15 +146,80 @@ Minimal required fields:
The server signs canonical bytes built from:
- response domain marker, for example `myapp-response-v1`
- response domain marker `galaxy-response-v1`
- `protocol_version`
- `request_id`
- `timestamp_ms`
- `result_code`
- `payload_hash`
The current gateway v1 response signature scheme is Ed25519.
The canonical v1 response signing input uses this binary encoding:
- each `string` and `bytes` field is encoded as `uvarint(len(field_bytes))`
followed by raw bytes
- `timestamp_ms` is encoded as an 8-byte big-endian unsigned integer
- fields are appended in the exact order listed above
The gateway server loads the response signing key from a PKCS#8 PEM-encoded
Ed25519 private key.
The client verifies the signature using a trusted server public key.
## Event Structure
Each server push event logically contains:
- `payload_bytes`
- `event_envelope`
- `signature`
### Event Envelope
Minimal required fields:
- `event_type`
- `event_id`
- `timestamp_ms`
- `payload_hash`
Optional fields:
- `request_id`
- `trace_id`
The current gateway v1 stream-event signature scheme is Ed25519.
The gateway currently signs unary responses and stream events with the same
PKCS#8 PEM-encoded Ed25519 private key.
The bootstrap event implemented for `SubscribeEvents` uses
`event_type = gateway.server_time`, reuses the opening subscribe `request_id`
as `event_id`, and encodes `server_time_ms` in a FlatBuffers
`gateway.ServerTimeEvent` payload.
Later client-facing push events are sourced from internal pub/sub with target
metadata `user_id` and optional `device_session_id`, plus `event_type`,
`event_id`, `payload_bytes`, and optional `request_id` / `trace_id`.
The gateway derives `timestamp_ms`, recomputes `payload_hash`, signs the
event at delivery time, and only then forwards it to the matching active
streams.
### Event Signing Input
The server signs canonical bytes built from:
- event domain marker `galaxy-event-v1`
- `event_type`
- `event_id`
- `timestamp_ms`
- `request_id`
- `trace_id`
- `payload_hash`
The canonical v1 event signing input uses this binary encoding:
- each `string` and `bytes` field is encoded as `uvarint(len(field_bytes))`
followed by raw bytes
- `timestamp_ms` is encoded as an 8-byte big-endian unsigned integer
- fields are appended in the exact order listed above
## Verification Order on Server
Before processing payload, the server/gateway must:
@@ -140,6 +242,14 @@ Before accepting response payload, the client must:
4. verify timestamp freshness if applicable
5. only then accept the response payload
Before accepting push-event payload, the client must:
1. verify server event signature
2. verify `payload_hash`
3. verify `request_id` when the event is correlated to the opening request
4. verify timestamp freshness if applicable
5. only then accept the event payload
## Anti-Replay Model
Transport anti-replay uses:
@@ -148,7 +258,11 @@ Transport anti-replay uses:
- `request_id`
The server accepts requests only inside an allowed time window.
The current gateway v1 freshness window is symmetric `±5 minutes` around
server time.
Recently seen `request_id` values must be tracked for the corresponding session and rejected on reuse.
Replay reservations should remain active until `timestamp_ms + freshness_window`
so future-skewed but still valid requests stay protected after acceptance.
This protects transport freshness.
It does not replace business idempotency.
@@ -159,12 +273,13 @@ Clients use server time offset instead of trusting local clock directly.
Expected approach:
- client establishes authenticated long-polling / push connection
- client establishes an authenticated `SubscribeEvents` gRPC stream
- server provides current server time
- client computes local offset
- subsequent signed requests use adjusted time
No extra sync request is required if push / long-polling already exists.
No extra sync request is required when the authenticated push stream is already
open.
## TLS and MITM Considerations
+27
View File
@@ -0,0 +1,27 @@
# Required startup settings.
GATEWAY_SESSION_CACHE_REDIS_ADDR=127.0.0.1:6379
GATEWAY_SESSION_EVENTS_REDIS_STREAM=gateway:session-events
GATEWAY_CLIENT_EVENTS_REDIS_STREAM=gateway:client-events
GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH=./secrets/response-signer.pem
# Main listeners.
GATEWAY_PUBLIC_HTTP_ADDR=127.0.0.1:8080
GATEWAY_AUTHENTICATED_GRPC_ADDR=127.0.0.1:9090
# Optional admin listener.
# GATEWAY_ADMIN_HTTP_ADDR=127.0.0.1:9091
# Optional Redis tuning.
# GATEWAY_SESSION_CACHE_REDIS_DB=0
# GATEWAY_SESSION_CACHE_REDIS_KEY_PREFIX=gateway:session:
# GATEWAY_REPLAY_REDIS_KEY_PREFIX=gateway:replay:
# GATEWAY_SESSION_CACHE_REDIS_TLS_ENABLED=false
# Optional public-auth integration. Without an injected adapter the routes stay
# mounted and return 503 service_unavailable.
# GATEWAY_PUBLIC_AUTH_UPSTREAM_TIMEOUT=3s
# Optional shutdown and telemetry tuning.
# GATEWAY_SHUTDOWN_TIMEOUT=5s
# GATEWAY_LOG_LEVEL=info
# OTEL_TRACES_EXPORTER=none
+73 -20
View File
@@ -21,11 +21,19 @@ The intended v1 architecture is:
- `protocol_version` covers transport and envelope compatibility, not business
payload schema compatibility.
- FlatBuffers are used for business payload bytes only.
- Phase 3 public auth uses a challenge-token REST flow:
`send-email-code(email) -> challenge_id` and
`confirm-email-code(challenge_id, code, client_public_key) -> device_session_id`.
- Phase 3 uses a consumer-side `AuthServiceClient` inside `gateway`; the
default process wiring keeps public auth routes mounted and returns
`503 service_unavailable` until a concrete upstream adapter is added.
- Browser bootstrap and asset traffic are within gateway scope, even when backed
by a pluggable proxy or handler.
- Long-polling is out of scope for v1.
## Phase 1. Module Skeleton
## ~~Phase 1.~~ Module Skeleton
Status: implemented.
Goal: create the runnable gateway process skeleton.
@@ -49,7 +57,9 @@ Targeted tests:
- startup with valid config;
- shutdown without leaked goroutines.
## Phase 2. Public REST Server
## ~~Phase 2.~~ Public REST Server
Status: implemented.
Goal: add the unauthenticated HTTP server shell.
@@ -73,7 +83,9 @@ Targeted tests:
- health endpoint responses;
- request classification smoke tests.
## Phase 3. Public Auth REST Handlers
## ~~Phase 3.~~ Public Auth REST Handlers
Status: implemented.
Goal: expose unauthenticated auth commands through REST/JSON.
@@ -96,7 +108,9 @@ Targeted tests:
- success and validation errors for both routes;
- no session lookup on public auth paths.
## Phase 4. Public Traffic Classification
## ~~Phase 4.~~ Public Traffic Classification
Status: implemented.
Goal: isolate public traffic into stable anti-abuse classes.
@@ -118,7 +132,9 @@ Targeted tests:
- per-class routing tests;
- bucket isolation tests.
## Phase 5. Public REST Anti-Abuse
## ~~Phase 5.~~ Public REST Anti-Abuse
Status: implemented.
Goal: add coarse protection to unauthenticated REST traffic.
@@ -142,7 +158,9 @@ Targeted tests:
- bootstrap burst stays outside auth abuse counters;
- invalid methods and oversized bodies are rejected.
## Phase 6. gRPC Server and Public Contracts
## ~~Phase 6.~~ gRPC Server and Public Contracts
Status: implemented.
Goal: bring up authenticated transport over gRPC and HTTP/2.
@@ -165,7 +183,9 @@ Targeted tests:
- unary transport smoke test;
- stream transport smoke test.
## Phase 7. Envelope Parsing and Protocol Gate
## ~~Phase 7.~~ Envelope Parsing and Protocol Gate
Status: implemented.
Goal: validate the gRPC control envelope before security checks continue.
@@ -186,7 +206,9 @@ Targeted tests:
- missing field rejection;
- unsupported `protocol_version` rejection.
## Phase 8. Session Cache Lookup
## ~~Phase 8.~~ Session Cache Lookup
Status: implemented.
Goal: resolve authenticated identity from cache.
@@ -208,7 +230,9 @@ Targeted tests:
- cache miss reject;
- revoked session reject.
## Phase 9. Payload Hash and Signing Input
## ~~Phase 9.~~ Payload Hash and Signing Input
Status: implemented.
Goal: verify payload integrity before signature verification.
@@ -228,7 +252,9 @@ Targeted tests:
- payload hash mismatch reject;
- canonical bytes differ when signed fields change.
## Phase 10. Client Signature Verification
## ~~Phase 10.~~ Client Signature Verification
Status: implemented.
Goal: authenticate the request origin using the session public key.
@@ -249,7 +275,9 @@ Targeted tests:
- bad signature reject;
- wrong-key reject.
## Phase 11. Freshness and Anti-Replay
## ~~Phase 11.~~ Freshness and Anti-Replay
Status: implemented.
Goal: enforce transport freshness and replay protection.
@@ -271,7 +299,9 @@ Targeted tests:
- replay reject for same session and request ID;
- distinct sessions do not collide.
## Phase 12. Authenticated Rate Limits and Policy
## ~~Phase 12.~~ Authenticated Rate Limits and Policy
Status: implemented.
Goal: apply edge policy after transport authenticity is established.
@@ -291,7 +321,10 @@ Targeted tests:
- per-dimension throttling;
- bucket isolation from public traffic.
## Phase 13. Internal Authenticated Command and Routing
## ~~Phase 13.~~ Internal Authenticated Command and Routing
Status: implemented.
Note: delivered together with Phase 14 signed unary responses.
Goal: forward only verified context to downstream services.
@@ -313,7 +346,9 @@ Targeted tests:
- route selection by `message_type`;
- downstream receives the expected authenticated context.
## Phase 14. Signed Unary Responses
## ~~Phase 14.~~ Signed Unary Responses
Status: implemented as part of Phase 13 delivery.
Goal: return verifiable server responses to authenticated clients.
@@ -335,7 +370,9 @@ Targeted tests:
- response correlation test;
- server signature generation test.
## Phase 15. Session Update and Revocation Events
## ~~Phase 15.~~ Session Update and Revocation Events
Status: implemented.
Goal: keep gateway session state current without synchronous hot-path lookups.
@@ -357,7 +394,9 @@ Targeted tests:
- cache update from event;
- revocation event invalidates cached session.
## Phase 16. Authenticated Push Stream
## ~~Phase 16.~~ Authenticated Push Stream
Status: implemented.
Goal: open a verified server-streaming channel for client-facing delivery.
@@ -379,7 +418,9 @@ Targeted tests:
- rejected stream open for invalid session;
- first event contains server time.
## Phase 17. Event Fan-Out
## ~~Phase 17.~~ Event Fan-Out
Status: implemented.
Goal: deliver client-facing events from internal pub/sub to active streams.
@@ -401,7 +442,9 @@ Targeted tests:
- multi-device delivery for one user;
- unrelated sessions do not receive the event.
## Phase 18. Revocation-Driven Stream Teardown
## ~~Phase 18.~~ Revocation-Driven Stream Teardown
Status: implemented.
Goal: terminate active delivery channels when a session is revoked.
@@ -422,7 +465,12 @@ Targeted tests:
- revoke closes active stream;
- revoked session cannot reopen the stream.
## Phase 19. Observability and Shutdown Hardening
## ~~Phase 19.~~ Observability and Shutdown Hardening
Status: implemented.
Note: delivered with `zap` structured logging, OpenTelemetry tracing and
metrics, the optional private admin `/metrics` listener, timeout budgets, and
shutdown-driven push-stream teardown.
Goal: make the service operable in production.
@@ -446,7 +494,12 @@ Targeted tests:
- shutdown closes listeners and active streams;
- secret and signature values are not logged.
## Phase 20. Acceptance Pass
## ~~Phase 20.~~ Acceptance Pass
Status: implemented.
Note: acceptance pass reconciled README/OpenAPI/root architecture
documentation, fixed the documented public-auth projected-error contract, and
added focused regression coverage including OpenAPI validation.
Goal: reconcile implementation, documentation, and regression coverage.
+693 -18
View File
@@ -1,5 +1,46 @@
# Edge Gateway
## Run and Dependencies
`cmd/gateway` starts with built-in listener defaults, but it still requires:
- one reachable Redis deployment for session lookup, replay reservations, and
both internal event streams;
- one configured session event stream via `GATEWAY_SESSION_EVENTS_REDIS_STREAM`;
- one configured client event stream via `GATEWAY_CLIENT_EVENTS_REDIS_STREAM`;
- one PKCS#8 PEM-encoded Ed25519 response-signer key referenced by
`GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH`.
Required startup environment variables:
- `GATEWAY_SESSION_CACHE_REDIS_ADDR`
- `GATEWAY_SESSION_EVENTS_REDIS_STREAM`
- `GATEWAY_CLIENT_EVENTS_REDIS_STREAM`
- `GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH`
Optional integrations:
- `GATEWAY_ADMIN_HTTP_ADDR` enables the private `/metrics` listener;
- an injected `AuthServiceClient` enables real public auth handling;
- injected downstream routes are required for successful `ExecuteCommand`.
Operational caveats:
- public auth routes stay mounted and return `503 service_unavailable` until an
auth adapter is wired;
- authenticated gRPC starts without downstream routes, but `ExecuteCommand`
returns gRPC `UNIMPLEMENTED` until routing is configured.
Additional module docs:
- [Public REST contract](openapi.yaml)
- [Documentation index](docs/README.md)
- [Runtime and components](docs/runtime.md)
- [Request and push flows](docs/flows.md)
- [Operator runbook](docs/runbook.md)
- [Configuration and contract examples](docs/examples.md)
- [Example `.env`](.env.example)
## Purpose
`Edge Gateway` is the only public ingress for Galaxy Plus clients.
@@ -40,29 +81,97 @@ The gateway exposes two external transport classes.
| Transport | Audience | Authentication | Payload format | Primary use |
| --- | --- | --- | --- | --- |
| REST/JSON | Public, unauthenticated traffic | No device session auth | JSON | Public auth commands, health checks, browser/bootstrap traffic |
| REST/JSON | Public, unauthenticated traffic | No device session auth | JSON | Health checks, public auth commands, and browser/bootstrap traffic |
| gRPC over HTTP/2 | Authenticated clients only | Required | FlatBuffers payload inside protobuf control envelope | Verified commands and push delivery |
### Public REST Surface
The public REST surface is used for commands that must work before a device
session exists and for browser-originated traffic that may share the same edge.
It covers the probe endpoints, public auth routes, and coarse public
anti-abuse.
Stable public endpoints:
Currently implemented public endpoints:
- `POST /api/v1/public/auth/send-email-code`
- `POST /api/v1/public/auth/confirm-email-code`
- `GET /healthz`
- `GET /readyz`
- `POST /api/v1/public/auth/send-email-code`
- `POST /api/v1/public/auth/confirm-email-code`
The implemented REST contract is documented in [`openapi.yaml`](openapi.yaml).
The listener address is configured by `GATEWAY_PUBLIC_HTTP_ADDR`.
The public REST listener read budgets are configured by:
- `GATEWAY_PUBLIC_HTTP_READ_HEADER_TIMEOUT` with default `2s`;
- `GATEWAY_PUBLIC_HTTP_READ_TIMEOUT` with default `10s`;
- `GATEWAY_PUBLIC_HTTP_IDLE_TIMEOUT` with default `1m`.
The public auth JSON contract uses a challenge-token flow:
- `send-email-code` accepts `email` and returns `challenge_id`;
- `confirm-email-code` accepts `challenge_id`, `code`, and
`client_public_key`, then returns `device_session_id`.
`client_public_key` is the standard base64-encoded raw 32-byte Ed25519 public
key for the device session being created.
These routes remain unauthenticated and delegate only through an injected
`AuthServiceClient`.
The default wiring used by `cmd/gateway` keeps the routes mounted and returns
`503 service_unavailable` until a concrete upstream auth adapter is supplied.
Public auth adapter calls are wrapped in
`GATEWAY_PUBLIC_AUTH_UPSTREAM_TIMEOUT`, which defaults to `3s`.
When that timeout expires, the gateway preserves the public REST contract and
returns `503 service_unavailable`.
When an injected auth adapter returns `*AuthServiceError`, the gateway projects
that client-safe `4xx/5xx` status, `code`, and `message` back to the caller
after normalizing blank or invalid fields. Unexpected non-`AuthServiceError`
adapter failures fail closed as `500 internal_error`.
Public anti-abuse is process-local and in-memory.
Per-IP buckets are derived only from the TCP peer `RemoteAddr`.
Forwarded proxy headers such as `X-Forwarded-For` and `Forwarded` are
intentionally ignored.
Oversized public REST bodies are rejected with `413 request_too_large`.
Rate-limited requests are rejected with `429 rate_limited` and a
`Retry-After` header.
In addition to the fixed endpoints above, the gateway may front browser
bootstrap or asset traffic through a pluggable public handler or proxy.
That traffic belongs to dedicated public route classes and must not share rate
limit buckets or abuse counters with the public auth API.
### Operational Admin Surface
The gateway may expose one private operational HTTP listener used for metrics.
The admin listener is disabled by default and is enabled only when
`GATEWAY_ADMIN_HTTP_ADDR` is non-empty.
When enabled, it serves:
- `GET /metrics`
The admin listener read budgets are configured by:
- `GATEWAY_ADMIN_HTTP_READ_HEADER_TIMEOUT` with default `2s`;
- `GATEWAY_ADMIN_HTTP_READ_TIMEOUT` with default `10s`;
- `GATEWAY_ADMIN_HTTP_IDLE_TIMEOUT` with default `1m`.
`/metrics` is intentionally not mounted on the public REST ingress.
It is also intentionally excluded from [`openapi.yaml`](openapi.yaml), because
that specification covers only the public REST ingress.
The endpoint exposes metrics in the Prometheus text exposition format described
in the official Prometheus documentation:
<https://prometheus.io/docs/instrumenting/exposition_formats/>.
### Authenticated gRPC Surface
All authenticated client requests use HTTP/2 and gRPC.
The listener address is configured by `GATEWAY_AUTHENTICATED_GRPC_ADDR`.
Inbound authenticated gRPC connection setup is bounded by
`GATEWAY_AUTHENTICATED_GRPC_CONNECTION_TIMEOUT`, which defaults to `5s`.
The accepted client timestamp skew is configured by
`GATEWAY_AUTHENTICATED_GRPC_FRESHNESS_WINDOW` and defaults to `5m`.
The public gRPC service exposes two methods:
@@ -72,10 +181,133 @@ The public gRPC service exposes two methods:
`ExecuteCommand` is a generic unary RPC.
The gateway routes the request downstream by `message_type` after transport
verification succeeds.
Downstream unary execution is bounded by
`GATEWAY_AUTHENTICATED_DOWNSTREAM_TIMEOUT`, which defaults to `5s`.
When that timeout expires, the gateway preserves the authenticated gRPC
contract and returns gRPC `UNAVAILABLE` with message
`downstream service is unavailable`.
`SubscribeEvents` is an authenticated server-streaming RPC.
It binds the stream to `user_id` and `device_session_id` and starts by sending
a service event that includes the current server time in milliseconds.
a signed service event that includes the current server time in milliseconds.
The v1 protobuf contract lives in
`proto/galaxy/gateway/v1/edge_gateway.proto` under package
`galaxy.gateway.v1` and service `EdgeGateway`.
Generated Go bindings are committed under `proto/galaxy/gateway/v1/` and are
regenerated with:
```bash
buf generate
```
The gateway validates the request envelope, device-session
cache lookup, `payload_hash`, the client Ed25519 signature, timestamp
freshness, replay reservation, authenticated rate limits, and the
authenticated policy hook before any later routing or push step runs.
Malformed envelopes are rejected with gRPC `INVALID_ARGUMENT`.
Requests with a non-empty but unsupported `protocol_version` are rejected with
gRPC `FAILED_PRECONDITION`.
The supported request `protocol_version` literal is `v1`.
Requests with an unknown `device_session_id` are rejected with gRPC
`UNAUTHENTICATED`.
Requests for revoked sessions are rejected with gRPC `FAILED_PRECONDITION`.
SessionCache backend failures, including Redis lookup or record-decode
failures, are rejected with gRPC `UNAVAILABLE`.
Requests with a `payload_hash` that is not a 32-byte SHA-256 digest or does
not match `payload_bytes` are rejected with gRPC `INVALID_ARGUMENT`.
Requests with an invalid client signature or a signature created by a
different key are rejected with gRPC `UNAUTHENTICATED` and message
`invalid request signature`.
Requests with malformed cached `client_public_key` material fail closed as
gRPC `UNAVAILABLE`.
Requests with a `timestamp_ms` outside the symmetric freshness window around
current server time are rejected with gRPC `FAILED_PRECONDITION` and message
`request timestamp is outside the freshness window`.
Requests that reuse the same `request_id` for the same `device_session_id`
inside the active replay window are rejected with gRPC
`FAILED_PRECONDITION` and message `request replay detected`.
ReplayStore backend failures fail closed with gRPC `UNAVAILABLE` and message
`replay store is unavailable`.
Authenticated rate limits are enforced independently by transport peer IP,
authenticated `device_session_id`, authenticated `user_id`, and authenticated
message class. The gateway uses the full verified `message_type` literal as the
stable v1 message-class key because the transport does not yet define a
coarser authenticated class taxonomy. The peer IP is derived only from the
gRPC transport peer address; if it is missing or cannot be parsed, the
request falls back to the stable `unknown` IP bucket.
Requests that exceed any authenticated rate-limit bucket are rejected with
gRPC `RESOURCE_EXHAUSTED` and message
`authenticated request rate limit exceeded`.
The authenticated edge policy hook runs after those rate limits and defaults
to allow-all until a concrete policy evaluator is wired into the process.
`ExecuteCommand` builds an internal authenticated command context,
resolves one exact-match downstream route by the full verified `message_type`
literal, executes the downstream unary client, and signs the response before
it is returned to the caller. When no exact downstream route is registered,
`ExecuteCommand` is rejected with gRPC `UNIMPLEMENTED` and message
`message_type is not routed`. Downstream availability failures are rejected
with gRPC `UNAVAILABLE` and message `downstream service is unavailable`.
Unexpected downstream route-resolution or execution failures are rejected with
gRPC `INTERNAL`. Successful unary responses preserve the original
`request_id`, carry a SHA-256 `payload_hash` of the returned `payload_bytes`,
and are signed with the configured server Ed25519 response signer.
The default `cmd/gateway` wiring currently installs an empty static
downstream router, so verified `ExecuteCommand` requests still return gRPC
`UNIMPLEMENTED` until concrete downstream routes are injected.
`SubscribeEvents` applies the full authenticated ingress pipeline, binds
the stream to the verified `user_id` and `device_session_id`, sends one
signed `gateway.server_time` bootstrap event whose FlatBuffers payload carries
`server_time_ms`, registers the active stream in the in-memory `PushHub`, and
then forwards signed client-facing events consumed from the configured client
event Redis stream. User-targeted events fan out to every active stream for
that user. Session-targeted events fan out only to streams whose
`user_id` and `device_session_id` both match the event target. Each active
stream uses a bounded in-memory queue; when that queue overflows, only the
affected stream is closed with gRPC `RESOURCE_EXHAUSTED` and message
`push stream overflowed`. When the session lifecycle stream reports that the
same `device_session_id` was revoked, every active `SubscribeEvents` stream
bound to that exact session is closed with gRPC `FAILED_PRECONDITION` and
message `device session is revoked`. During gateway shutdown, the in-memory
push hub is closed before gRPC graceful stop, and every active
`SubscribeEvents` stream is terminated with gRPC `UNAVAILABLE` and message
`gateway is shutting down`.
Authenticated anti-abuse budgets are configured by the
`GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_*` environment variables.
Current authenticated gRPC defaults:
- per-IP: `120 requests / minute`, `burst=40`;
- per-session: `60 requests / minute`, `burst=20`;
- per-user: `120 requests / minute`, `burst=40`;
- per-message-class: `60 requests / minute`, `burst=20`.
Authenticated anti-abuse configuration surface:
- per-IP:
`GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_REQUESTS` default
`120`,
`GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_WINDOW` default `1m`,
`GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_BURST` default `40`;
- per-session:
`GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_SESSION_RATE_LIMIT_REQUESTS` default
`60`,
`GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_SESSION_RATE_LIMIT_WINDOW` default
`1m`,
`GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_SESSION_RATE_LIMIT_BURST` default
`20`;
- per-user:
`GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_USER_RATE_LIMIT_REQUESTS` default
`120`,
`GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_USER_RATE_LIMIT_WINDOW` default `1m`,
`GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_USER_RATE_LIMIT_BURST` default `40`;
- per-message-class:
`GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_MESSAGE_CLASS_RATE_LIMIT_REQUESTS`
default `60`,
`GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_MESSAGE_CLASS_RATE_LIMIT_WINDOW`
default `1m`,
`GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_MESSAGE_CLASS_RATE_LIMIT_BURST`
default `20`.
## Envelope and Payload Model
@@ -86,10 +318,25 @@ The authenticated transport uses a split contract:
- signatures are computed over canonical envelope fields and a hash of raw
FlatBuffers bytes.
The gateway treats `payload_bytes` as opaque business data.
The gateway treats authenticated request `payload_bytes` as opaque business
data.
It verifies integrity and forwards verified bytes downstream without rewriting
them.
The request envelope version literal is `v1`.
`payload_hash` is the raw 32-byte SHA-256 digest of `payload_bytes`.
`ExecuteCommand` hashes the raw FlatBuffers payload bytes exactly as sent,
while `SubscribeEvents` with an empty payload still requires
`sha256([]byte{})` rather than a special-case value.
The v1 request signature scheme is Ed25519.
`client_public_key` is the standard base64-encoded raw 32-byte Ed25519 public
key registered during `confirm-email-code`.
`signature` carries the raw 64-byte Ed25519 signature computed over the
canonical request signing input.
The v1 stream bootstrap payload uses the shared FlatBuffers schema
`pkg/schema/fbs/gateway.fbs` with root table `gateway.ServerTimeEvent`.
### ExecuteCommandRequest
Required fields:
@@ -119,6 +366,22 @@ Required fields:
- `payload_hash`
- `signature`
The v1 unary response signature scheme is Ed25519 with response
domain marker `galaxy-response-v1`.
The response signing input uses the same canonical binary encoding shape as
the request signer:
- each `string` and `bytes` field is encoded as `uvarint(len(field_bytes))`
followed by raw bytes;
- `timestamp_ms` is encoded as an 8-byte big-endian unsigned integer;
- the signed field order is `galaxy-response-v1`, `protocol_version`,
`request_id`, `timestamp_ms`, `result_code`, `payload_hash`.
`cmd/gateway` loads the unary response signer from
`GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH`, which must point to a PKCS#8
PEM-encoded Ed25519 private key. Startup fails when the file is absent,
unreadable, not strict PEM, not PKCS#8, or not Ed25519.
### SubscribeEventsRequest
The stream open request reuses the authenticated request model.
@@ -158,6 +421,33 @@ Optional fields:
- `request_id`
- `trace_id`
The v1 stream-event signature scheme is Ed25519 with event domain
marker `galaxy-event-v1`.
The event signing input uses the same canonical binary encoding shape as the
request and unary response signers:
- each `string` and `bytes` field is encoded as `uvarint(len(field_bytes))`
followed by raw bytes;
- `timestamp_ms` is encoded as an 8-byte big-endian unsigned integer;
- the signed field order is `galaxy-event-v1`, `event_type`, `event_id`,
`timestamp_ms`, `request_id`, `trace_id`, `payload_hash`.
The bootstrap event uses:
- `event_type = "gateway.server_time"`;
- `event_id = request_id` from the opening `SubscribeEvents` request;
- `payload_bytes` encoded as FlatBuffers `gateway.ServerTimeEvent` with
`server_time_ms`;
- the same loaded Ed25519 signer configured by
`GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH`.
Client-facing fan-out events are sourced from the internal client
event stream. Internal publishers provide the event target and business
payload only: `user_id`, optional `device_session_id`, `event_type`,
`event_id`, `payload_bytes`, and optional `request_id` / `trace_id`. The
gateway derives `timestamp_ms`, recomputes `payload_hash`, signs the event,
and only then forwards it to the matching `SubscribeEvents` streams.
## Verification and Routing Pipeline
The gateway applies the same strict verification order for authenticated gRPC
@@ -178,6 +468,38 @@ ingress.
No downstream business service should receive a request that has not passed
this full verification pipeline.
`ExecuteCommand` enforces steps 1 through 11 and
signs the successful unary response afterward. `SubscribeEvents` enforces
steps 1 through 9, binds the verified stream identity, sends the initial
signed server-time bootstrap event, and then keeps the stream open for push
delivery.
Malformed envelopes fail with gRPC `INVALID_ARGUMENT`.
Unsupported non-empty `protocol_version` values fail with gRPC
`FAILED_PRECONDITION`.
Unknown sessions fail with gRPC `UNAUTHENTICATED`.
Revoked sessions fail with gRPC `FAILED_PRECONDITION`.
SessionCache backend failures fail with gRPC `UNAVAILABLE`.
`payload_hash` values that are not raw 32-byte SHA-256 digests fail with gRPC
`INVALID_ARGUMENT` and message `payload_hash must be a 32-byte SHA-256 digest`.
`payload_hash` values that do not match `payload_bytes` fail with gRPC
`INVALID_ARGUMENT` and message `payload_hash does not match payload_bytes`.
Invalid request signatures fail with gRPC `UNAUTHENTICATED` and message
`invalid request signature`.
Malformed cached `client_public_key` values fail closed with gRPC
`UNAVAILABLE` and message `session cache is unavailable`.
Requests with a `timestamp_ms` outside the accepted freshness window fail with
gRPC `FAILED_PRECONDITION` and message
`request timestamp is outside the freshness window`.
Requests that reuse the same `request_id` for the same `device_session_id`
inside the active replay window fail with gRPC `FAILED_PRECONDITION` and
message `request replay detected`.
ReplayStore backend failures fail with gRPC `UNAVAILABLE` and message
`replay store is unavailable`.
Unrouted exact-match `message_type` values fail with gRPC `UNIMPLEMENTED` and
message `message_type is not routed`.
Downstream availability failures fail with gRPC `UNAVAILABLE` and message
`downstream service is unavailable`.
## Internal Authenticated Contract
Downstream services should receive an internal authenticated command rather than
@@ -206,7 +528,7 @@ Expected session fields available to the gateway:
- `device_session_id`
- `user_id`
- client public key
- base64-encoded raw 32-byte Ed25519 client public key
- session status
- revoke metadata
- optional client metadata
@@ -217,12 +539,189 @@ Expected session fields available to the gateway:
- session existence checks;
- `device_session_id -> user_id`;
- access to the client public key used for signature verification;
- access to the base64-encoded raw Ed25519 client public key used for
signature verification;
- revoked versus active status checks.
Cache updates are event-driven.
TTL is allowed only as a safety net and must not replace invalidation events.
The gateway keeps a process-local in-memory snapshot
cache in front of the Redis fallback backend. Authenticated requests read the
local snapshot first. A local miss performs one bounded Redis lookup and seeds
the local snapshot so later requests for the same session avoid another Redis
round-trip unless a later session event changes the cached state.
The local snapshot cache intentionally has no TTL and no size-based
eviction policy. Session lifecycle events are the authoritative mechanism for
keeping the hot path current, while Redis fallback remains the safety net for
cold misses and process restarts.
The Redis fallback implementation uses `go-redis/v9`.
`cmd/gateway` requires the Redis fallback backend during startup, issues a
bounded `PING`, and refuses to start when Redis is misconfigured or
unavailable.
Required environment variable:
- `GATEWAY_SESSION_CACHE_REDIS_ADDR`
Optional environment variables:
- `GATEWAY_SESSION_CACHE_REDIS_USERNAME`
- `GATEWAY_SESSION_CACHE_REDIS_PASSWORD`
- `GATEWAY_SESSION_CACHE_REDIS_DB` with default `0`
- `GATEWAY_SESSION_CACHE_REDIS_KEY_PREFIX` with default `gateway:session:`
- `GATEWAY_SESSION_CACHE_REDIS_LOOKUP_TIMEOUT` with default `250ms`
- `GATEWAY_SESSION_CACHE_REDIS_TLS_ENABLED` with default `false`
The Redis key format is:
- `<key_prefix><device_session_id>`
The Redis value is one strict JSON object:
- `device_session_id`
- `user_id`
- `client_public_key`
- `status`
- optional `revoked_at_ms`
`client_public_key` stores the standard base64-encoded raw 32-byte Ed25519
public key registered for the device session.
Malformed JSON, missing required fields, unsupported `status`, or a
`device_session_id` mismatch between the Redis value and the lookup key are
treated as SessionCache backend failures rather than as valid session states.
### Session Event Stream
The gateway keeps the process-local session snapshot cache synchronized from one
Redis Stream consumed through `go-redis/v9`.
`cmd/gateway` requires the session event stream configuration during startup,
issues a bounded `PING` against the same Redis deployment used for
`SessionCache`, and refuses to start when that Redis backend is unavailable.
Required environment variable:
- `GATEWAY_SESSION_EVENTS_REDIS_STREAM`
Optional environment variable:
- `GATEWAY_SESSION_EVENTS_REDIS_READ_BLOCK_TIMEOUT` with default `1s`
The subscriber reuses the same Redis address, ACL credentials, logical
database, timeout, and TLS settings configured for `SessionCache`.
Each gateway replica keeps its own in-memory last-seen stream ID and consumes
the stream with plain `XREAD`, not a shared consumer group.
On startup the replica resolves the current stream tail and begins from that
point, which preserves the same fresh-process semantics as Redis `$` while
avoiding a race before the first blocking read.
The session event payload is one strict full snapshot with these
fields:
- `device_session_id`
- `user_id`
- `client_public_key`
- `status`
- optional `revoked_at_ms`
Valid active and revoked snapshots upsert or replace the local session state.
Later stream entries win.
Malformed events are skipped without stopping the subscriber; when
`device_session_id` can still be extracted, the gateway evicts the local
snapshot for that session so it cannot continue using stale state.
Session event publishers must keep the stream bounded by using
`XADD ... MAXLEN ~ <limit>` or an equivalent retention policy.
The gateway intentionally does not trim the stream from the consumer side,
because consumer-side trimming could drop updates that another gateway replica
has not read yet.
### Client Event Stream
The gateway delivers client-facing push events from one dedicated Redis Stream
consumed through `go-redis/v9`.
`cmd/gateway` requires the client event stream configuration during startup,
issues a bounded `PING` against the same Redis deployment used for
`SessionCache`, and refuses to start when that Redis backend is unavailable.
Required environment variable:
- `GATEWAY_CLIENT_EVENTS_REDIS_STREAM`
Optional environment variable:
- `GATEWAY_CLIENT_EVENTS_REDIS_READ_BLOCK_TIMEOUT` with default `1s`
The subscriber reuses the same Redis address, ACL credentials, logical
database, timeout, and TLS settings configured for `SessionCache`.
Each gateway replica keeps its own in-memory last-seen stream ID and consumes
the stream with plain `XREAD`, not a shared consumer group.
On startup the replica resolves the current stream tail and begins from that
point, which preserves the same fresh-process semantics as Redis `$` while
avoiding a race before the first blocking read.
The client event payload is one strict target-plus-payload entry with
these fields:
- `user_id`
- optional `device_session_id`
- `event_type`
- `event_id`
- `payload_bytes`
- optional `request_id`
- optional `trace_id`
`payload_bytes` carries the raw binary-safe business payload bytes for the
outbound client event.
When `device_session_id` is absent or blank, the gateway fans the event out to
every active stream for `user_id`.
When `device_session_id` is present, the gateway fans the event out only to
active streams whose `user_id` and `device_session_id` both match.
Malformed client event entries are skipped without stopping the subscriber or
delivering partial data to clients.
Client event publishers must keep the stream bounded by using
`XADD ... MAXLEN ~ <limit>` or an equivalent retention policy.
The gateway intentionally does not trim the stream from the consumer side,
because consumer-side trimming could drop updates that another gateway replica
has not read yet.
### Replay Store
`ReplayStore` provides the hot-path anti-replay reservation for:
- duplicate detection by `device_session_id + request_id`;
- bounded replay protection for the authenticated freshness window.
The ReplayStore uses Redis through `go-redis/v9`.
`cmd/gateway` requires the ReplayStore backend during startup, issues a
bounded `PING`, and refuses to start when Redis is misconfigured or
unavailable.
The ReplayStore reuses the same Redis deployment settings as `SessionCache`
and adds two replay-specific environment variables:
- `GATEWAY_REPLAY_REDIS_KEY_PREFIX` with default `gateway:replay:`
- `GATEWAY_REPLAY_REDIS_RESERVE_TIMEOUT` with default `250ms`
Replay keys use this format:
- `<key_prefix><base64url(device_session_id)>:<base64url(request_id)>`
For each accepted request, the replay reservation TTL is computed as:
- `timestamp_ms + freshness_window - now`
The TTL is clamped to a minimum positive duration so requests accepted exactly
on the freshness boundary still reserve their replay key.
### Revocation Behavior
When a device session is revoked:
@@ -231,7 +730,9 @@ When a device session is revoked:
2. it publishes a session update or revoke event;
3. the gateway invalidates or updates `SessionCache`;
4. new unary gRPC requests for that session are rejected;
5. active `SubscribeEvents` streams for that session are closed.
5. active `SubscribeEvents` streams for that exact `device_session_id` are
closed with gRPC `FAILED_PRECONDITION` and message
`device session is revoked`.
## Public Anti-Abuse Model
@@ -245,9 +746,15 @@ The gateway uses these public route classes:
- `browser_asset`
- `public_misc`
Any classifier result outside this fixed set is normalized to `public_misc`
before the class is stored in request context or used for policy derivation.
The canonical base bucket namespace for public REST policy is
`public_rest/class=<class>`.
### Public Auth
`public_auth` includes `send-email-code` and `confirm-email-code`.
`public_auth` is the stable route class for `send-email-code` and
`confirm-email-code`.
This class uses stricter limits and abuse scoring because it directly touches
account and session creation flows.
@@ -259,6 +766,36 @@ Controls include:
- malformed request counters;
- elevated logging and security telemetry for repeated failures.
Current defaults:
- per-IP: `30 requests / minute`, `burst=10`;
- `send-email-code` identity buckets: `3 requests / 10 minutes`, `burst=1`,
keyed by normalized `email`;
- `confirm-email-code` identity buckets: `6 requests / 10 minutes`,
`burst=2`, keyed by normalized `challenge_id`;
- maximum request body size: `8192` bytes;
- only `POST` is accepted for public auth routes.
Configuration surface:
- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_MAX_BODY_BYTES` default `8192`;
- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_REQUESTS` default
`30`;
- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_WINDOW` default `1m`;
- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_BURST` default `10`;
- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS`
default `3`;
- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_WINDOW`
default `10m`;
- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST`
default `1`;
- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS`
default `6`;
- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_WINDOW`
default `10m`;
- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST`
default `2`.
### Browser Bootstrap and Asset Traffic
`browser_bootstrap` and `browser_asset` use separate coarse-grained budgets.
@@ -275,6 +812,40 @@ This traffic is still constrained by:
The gateway must not merge these buckets or counters with `public_auth`.
Current defaults:
- `browser_bootstrap`: `60 requests / minute`, `burst=20`, `GET` and `HEAD`
only, and no request body;
- `browser_asset`: `300 requests / minute`, `burst=80`, `GET` and `HEAD`
only, and no request body;
- `public_misc`: `30 requests / minute`, `burst=10`, and no request body.
Configuration surface:
- `browser_bootstrap`:
`GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_BOOTSTRAP_MAX_BODY_BYTES` default
`0`,
`GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_BOOTSTRAP_RATE_LIMIT_REQUESTS`
default `60`,
`GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_BOOTSTRAP_RATE_LIMIT_WINDOW` default
`1m`,
`GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_BOOTSTRAP_RATE_LIMIT_BURST` default
`20`;
- `browser_asset`:
`GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_ASSET_MAX_BODY_BYTES` default `0`,
`GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_ASSET_RATE_LIMIT_REQUESTS` default
`300`,
`GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_ASSET_RATE_LIMIT_WINDOW` default
`1m`,
`GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_ASSET_RATE_LIMIT_BURST` default
`80`;
- `public_misc`:
`GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_MAX_BODY_BYTES` default `0`,
`GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_RATE_LIMIT_REQUESTS` default
`30`,
`GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_RATE_LIMIT_WINDOW` default `1m`,
`GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_RATE_LIMIT_BURST` default `10`.
## Push Delivery Model
The v1 push channel is a gRPC server stream.
@@ -285,15 +856,34 @@ Expected stream behavior:
1. the client opens `SubscribeEvents`;
2. the gateway applies the full authenticated ingress verification pipeline;
3. the stream is bound to `user_id` and `device_session_id`;
4. the first service event includes `server_time_ms`;
5. client-facing events from internal pub/sub are fanned out to matching active
streams;
6. revoke events close affected streams.
4. the first signed service event is `gateway.server_time` and its
FlatBuffers payload includes `server_time_ms`;
5. after that bootstrap event, the stream is registered in `PushHub` and
remains open until client cancellation, server shutdown, queue overflow,
session revoke for the same `device_session_id`, or a later send failure;
6. internal pub/sub may target all active streams for one `user_id` or only
one `device_session_id` within that user;
7. the current per-stream in-memory queue capacity is `64` events and
overflow closes only the affected stream;
8. session revoke closes only streams bound to the same exact
`device_session_id` and returns gRPC `FAILED_PRECONDITION` with message
`device session is revoked`.
## Lifecycle and Shutdown
Gateway process shutdown is coordinated across the public REST listener,
authenticated gRPC listener, optional admin listener, internal Redis
subscribers, and telemetry runtime.
`GATEWAY_SHUTDOWN_TIMEOUT` configures the per-component graceful shutdown
budget and defaults to `5s`.
During authenticated gRPC shutdown, the in-memory `PushHub` closes active
streams before gRPC graceful stop, so active `SubscribeEvents` calls terminate
with gRPC `UNAVAILABLE` and message `gateway is shutting down`.
## Recommended Package Layout
The initial package layout should keep transport, policy, and downstream
adapters separate:
The package layout keeps transport, policy, and downstream adapters separate:
- `cmd/gateway`
- `internal/app`
@@ -317,11 +907,17 @@ The gateway should be built around explicit consumer-side interfaces.
Provides cached session lookup by `device_session_id`.
Returns enough data to verify signatures and identify the authenticated user.
The current production implementation is a process-local read-through cache in
front of a Redis fallback adapter that uses strict JSON records under a
configurable key prefix.
### ReplayStore
Tracks recently seen `request_id` values per device session and rejects replayed
requests inside the accepted freshness window.
The current production adapter is Redis-backed, uses a dedicated configurable
key prefix, and reserves keys with a TTL derived from
`timestamp_ms + freshness_window - now`.
### RateLimiter
@@ -333,24 +929,44 @@ Applies independent policies for:
- authenticated gRPC requests by user;
- authenticated gRPC requests by message class.
The current rate limiter is process-local and in-memory.
Public REST keys stay under the `public_rest/...` namespace, while
authenticated gRPC keys stay under `authenticated_grpc/...`, so both traffic
surfaces keep independent buckets even when they share the same limiter
backend.
### PublicTrafficClassifier
Maps incoming public REST requests to one of the public route classes so that
limits and anti-abuse counters remain isolated.
The gateway normalizes any unsupported or empty classifier output to
`public_misc`, and public policy code derives the base bucket namespace from
the normalized class as `public_rest/class=<class>`.
### AuthServiceClient
Handles public auth commands and session-related updates exchanged with the
Auth / Session Service.
The gateway contract is:
- `SendEmailCode(email) -> challenge_id`
- `ConfirmEmailCode(challenge_id, code, client_public_key) -> device_session_id`
When no concrete implementation is wired, the gateway keeps the public routes
available and returns a stable `503 service_unavailable` response instead of
failing process startup.
### DownstreamRouter
Resolves the target downstream service or adapter by `message_type`.
Resolves the target downstream service or adapter by the full exact-match
`message_type` literal.
### DownstreamClient
Executes a verified authenticated command against a downstream internal service
and returns response payload bytes plus a stable result code.
and returns response payload bytes plus a stable opaque result code.
An empty or whitespace-only result code is treated as an internal downstream
contract violation.
### EventSubscriber
@@ -360,15 +976,25 @@ Subscribes to internal pub/sub topics used for:
- revocations;
- client-facing event delivery.
The implementation consumes two Redis Streams with replica-safe plain
`XREAD`: one strict full-session snapshot stream for the process-local session
cache and one client-facing event stream for live push fan-out.
### PushHub
Tracks active `SubscribeEvents` streams, binds them to authenticated identities,
and delivers events to the correct connections.
The implementation uses one bounded in-memory queue per stream with a
default capacity of `64` events; overflowing one queue closes only that stream
and leaves the remaining streams active.
### ResponseSigner
Signs unary responses and stream events so clients can verify server-originated
messages.
The implementation uses one Ed25519 signer loaded from
`GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH`, which must reference a PKCS#8
PEM-encoded private key.
### Clock
@@ -382,6 +1008,7 @@ internal implementation details.
Minimum error categories:
- malformed request;
- request too large;
- unsupported protocol;
- unknown session;
- revoked session;
@@ -389,7 +1016,10 @@ Minimum error categories:
- stale request;
- replay detected;
- rate limited;
- policy denied;
- downstream unavailable;
- backend unavailable;
- gateway shutting down;
- internal error.
Observability requirements:
@@ -400,6 +1030,51 @@ Observability requirements:
- metrics keyed by route class, message type, result code, and reject reason;
- no logging of secrets, raw private material, or raw signatures.
The service uses:
- `go.uber.org/zap` for structured JSON logs;
- `otelgin` for the public REST listener;
- `otelgrpc` for the authenticated gRPC listener;
- OpenTelemetry metrics exported through Prometheus on the optional admin
`/metrics` listener.
Current custom metric families:
- `gateway.public_http.requests`
- `gateway.public_http.duration`
- `gateway.authenticated_grpc.requests`
- `gateway.authenticated_grpc.duration`
- `gateway.push.active_streams`
- `gateway.push.stream_closures`
- `gateway.internal_event_drops`
The process-wide log level is configured by `GATEWAY_LOG_LEVEL` and
defaults to `info`.
The default OpenTelemetry resource uses `service.name=galaxy-edge-gateway`
when `OTEL_SERVICE_NAME` is unset.
If `OTEL_TRACES_EXPORTER` is unset or set to `none`, the gateway keeps tracing
runtime enabled but installs no external trace exporter.
If `OTEL_TRACES_EXPORTER=otlp`, the gateway uses the standard
`OTEL_EXPORTER_OTLP_*` environment variables to configure the OTLP trace
exporter protocol and endpoint.
The protocol selection specifically honors
`OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` first and falls back to
`OTEL_EXPORTER_OTLP_PROTOCOL` when the trace-specific variable is unset.
Supported values are `http/protobuf` and `grpc`; when both variables are
unset, the gateway defaults to `http/protobuf`.
Structured logs intentionally omit:
- public auth e-mail addresses, login codes, and challenge IDs;
- client public keys;
- raw payload bytes and payload hashes;
- raw request or response signatures;
- response-signer private key material and Redis credentials.
Malformed internal session and client-event stream entries are no longer
silently dropped: the gateway logs the drop and increments
`gateway.internal_event_drops`.
## Non-Goals
The gateway is not a business authorization layer and must not grow into a
+11
View File
@@ -0,0 +1,11 @@
version: v2
plugins:
- remote: buf.build/protocolbuffers/go:v1.36.11
out: proto
opt:
- paths=source_relative
- remote: buf.build/grpc/go:v1.6.1
out: proto
opt:
- paths=source_relative
+6
View File
@@ -0,0 +1,6 @@
# Generated by buf. DO NOT EDIT.
version: v2
deps:
- name: buf.build/bufbuild/protovalidate
commit: 80ab13bee0bf4272b6161a72bf7034e0
digest: b5:1aa6a965be5d02d64e1d81954fa2e78ef9d1e33a0c30f92bc2626039006a94deb3a5b05f14ed8893f5c3ffce444ac008f7e968188ad225c4c29c813aa5f2daa1
+15
View File
@@ -0,0 +1,15 @@
version: v2
modules:
- path: proto
deps:
- buf.build/bufbuild/protovalidate
lint:
use:
- STANDARD
breaking:
use:
- FILE
+209
View File
@@ -0,0 +1,209 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"syscall"
"galaxy/gateway/internal/adminapi"
"galaxy/gateway/internal/app"
"galaxy/gateway/internal/authn"
"galaxy/gateway/internal/config"
"galaxy/gateway/internal/downstream"
"galaxy/gateway/internal/events"
"galaxy/gateway/internal/grpcapi"
"galaxy/gateway/internal/logging"
"galaxy/gateway/internal/push"
"galaxy/gateway/internal/replay"
"galaxy/gateway/internal/restapi"
"galaxy/gateway/internal/session"
"galaxy/gateway/internal/telemetry"
"go.uber.org/zap"
)
// main loads the gateway configuration, runs the process lifecycle, and exits
// with a non-zero status when startup or runtime fails.
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
if err := run(ctx); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(ctx context.Context) (err error) {
cfg, err := config.LoadFromEnv()
if err != nil {
return err
}
logger, err := logging.New(cfg.Logging)
if err != nil {
return fmt.Errorf("build gateway logger: %w", err)
}
telemetryRuntime, err := telemetry.New(ctx, logger)
if err != nil {
return fmt.Errorf("build gateway telemetry: %w", err)
}
grpcDeps, components, cleanup, err := newAuthenticatedGRPCDependencies(ctx, cfg, logger, telemetryRuntime)
if err != nil {
return err
}
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
err = errors.Join(
err,
cleanup(),
telemetryRuntime.Shutdown(shutdownCtx),
logging.Sync(logger),
)
}()
restServer := restapi.NewServer(cfg.PublicHTTP, restapi.ServerDependencies{
Logger: logger,
Telemetry: telemetryRuntime,
})
grpcServer := grpcapi.NewServer(cfg.AuthenticatedGRPC, grpcDeps)
applicationComponents := []app.Component{
restServer,
grpcServer,
}
if adminServer := adminapi.NewServer(cfg.AdminHTTP, telemetryRuntime.Handler(), logger); adminServer.Enabled() {
applicationComponents = append(applicationComponents, adminServer)
}
applicationComponents = append(applicationComponents, components...)
logger.Info("gateway application starting",
zap.String("public_http_addr", cfg.PublicHTTP.Addr),
zap.String("authenticated_grpc_addr", cfg.AuthenticatedGRPC.Addr),
zap.String("admin_http_addr", cfg.AdminHTTP.Addr),
)
application := app.New(cfg, applicationComponents...)
err = application.Run(ctx)
return err
}
func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, logger *zap.Logger, telemetryRuntime *telemetry.Runtime) (grpcapi.ServerDependencies, []app.Component, func() error, error) {
responseSigner, err := authn.LoadEd25519ResponseSignerFromPEMFile(cfg.ResponseSigner.PrivateKeyPEMPath)
if err != nil {
return grpcapi.ServerDependencies{}, nil, nil, fmt.Errorf("build authenticated grpc dependencies: load response signer: %w", err)
}
fallbackSessionCache, err := session.NewRedisCache(cfg.SessionCacheRedis)
if err != nil {
return grpcapi.ServerDependencies{}, nil, nil, fmt.Errorf("build authenticated grpc dependencies: %w", err)
}
replayStore, err := replay.NewRedisStore(cfg.SessionCacheRedis, cfg.ReplayRedis)
if err != nil {
closeErr := fallbackSessionCache.Close()
return grpcapi.ServerDependencies{}, nil, nil, errors.Join(
fmt.Errorf("build authenticated grpc dependencies: %w", err),
closeErr,
)
}
localSessionCache := session.NewMemoryCache()
sessionCache, err := session.NewReadThroughCache(localSessionCache, fallbackSessionCache)
if err != nil {
closeErr := errors.Join(
fallbackSessionCache.Close(),
replayStore.Close(),
)
return grpcapi.ServerDependencies{}, nil, nil, errors.Join(
fmt.Errorf("build authenticated grpc dependencies: %w", err),
closeErr,
)
}
pushHub := push.NewHubWithObserver(0, telemetry.NewPushObserver(telemetryRuntime))
sessionSubscriber, err := events.NewRedisSessionSubscriberWithObservability(cfg.SessionCacheRedis, cfg.SessionEventsRedis, localSessionCache, pushHub, logger, telemetryRuntime)
if err != nil {
closeErr := errors.Join(
fallbackSessionCache.Close(),
replayStore.Close(),
)
return grpcapi.ServerDependencies{}, nil, nil, errors.Join(
fmt.Errorf("build authenticated grpc dependencies: %w", err),
closeErr,
)
}
clientEventSubscriber, err := events.NewRedisClientEventSubscriberWithObservability(cfg.SessionCacheRedis, cfg.ClientEventsRedis, pushHub, logger, telemetryRuntime)
if err != nil {
closeErr := errors.Join(
fallbackSessionCache.Close(),
replayStore.Close(),
sessionSubscriber.Close(),
)
return grpcapi.ServerDependencies{}, nil, nil, errors.Join(
fmt.Errorf("build authenticated grpc dependencies: %w", err),
closeErr,
)
}
cleanup := func() error {
return errors.Join(
fallbackSessionCache.Close(),
replayStore.Close(),
sessionSubscriber.Close(),
clientEventSubscriber.Close(),
)
}
if err := fallbackSessionCache.Ping(ctx); err != nil {
closeErr := cleanup()
return grpcapi.ServerDependencies{}, nil, nil, errors.Join(
fmt.Errorf("build authenticated grpc dependencies: %w", err),
closeErr,
)
}
if err := replayStore.Ping(ctx); err != nil {
closeErr := cleanup()
return grpcapi.ServerDependencies{}, nil, nil, errors.Join(
fmt.Errorf("build authenticated grpc dependencies: %w", err),
closeErr,
)
}
if err := sessionSubscriber.Ping(ctx); err != nil {
closeErr := cleanup()
return grpcapi.ServerDependencies{}, nil, nil, errors.Join(
fmt.Errorf("build authenticated grpc dependencies: %w", err),
closeErr,
)
}
if err := clientEventSubscriber.Ping(ctx); err != nil {
closeErr := cleanup()
return grpcapi.ServerDependencies{}, nil, nil, errors.Join(
fmt.Errorf("build authenticated grpc dependencies: %w", err),
closeErr,
)
}
return grpcapi.ServerDependencies{
Service: grpcapi.NewFanOutPushStreamService(pushHub, responseSigner, nil, logger),
Router: downstream.NewStaticRouter(nil),
ResponseSigner: responseSigner,
SessionCache: sessionCache,
ReplayStore: replayStore,
Logger: logger,
Telemetry: telemetryRuntime,
PushHub: pushHub,
}, []app.Component{sessionSubscriber, clientEventSubscriber}, cleanup, nil
}
+275
View File
@@ -0,0 +1,275 @@
package main
import (
"context"
"crypto/ed25519"
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"net"
"os"
"path/filepath"
"testing"
"time"
"galaxy/gateway/internal/config"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestNewAuthenticatedGRPCDependencies(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
responseSignerPEMPath := writeTestResponseSignerPEMFile(t)
tests := []struct {
name string
cfg config.Config
wantErr string
}{
{
name: "success",
cfg: config.Config{
SessionCacheRedis: config.SessionCacheRedisConfig{
Addr: server.Addr(),
KeyPrefix: "gateway:session:",
LookupTimeout: 250 * time.Millisecond,
},
ReplayRedis: config.ReplayRedisConfig{
KeyPrefix: "gateway:replay:",
ReserveTimeout: 250 * time.Millisecond,
},
SessionEventsRedis: config.SessionEventsRedisConfig{
Stream: "gateway:session_events",
ReadBlockTimeout: time.Second,
},
ClientEventsRedis: config.ClientEventsRedisConfig{
Stream: "gateway:client_events",
ReadBlockTimeout: time.Second,
},
ResponseSigner: config.ResponseSignerConfig{
PrivateKeyPEMPath: responseSignerPEMPath,
},
},
},
{
name: "invalid redis config",
cfg: config.Config{
SessionCacheRedis: config.SessionCacheRedisConfig{
LookupTimeout: 250 * time.Millisecond,
},
ReplayRedis: config.ReplayRedisConfig{
KeyPrefix: "gateway:replay:",
ReserveTimeout: 250 * time.Millisecond,
},
SessionEventsRedis: config.SessionEventsRedisConfig{
Stream: "gateway:session_events",
ReadBlockTimeout: time.Second,
},
ClientEventsRedis: config.ClientEventsRedisConfig{
Stream: "gateway:client_events",
ReadBlockTimeout: time.Second,
},
ResponseSigner: config.ResponseSignerConfig{
PrivateKeyPEMPath: responseSignerPEMPath,
},
},
wantErr: "redis addr must not be empty",
},
{
name: "startup ping failure",
cfg: config.Config{
SessionCacheRedis: config.SessionCacheRedisConfig{
Addr: unusedTCPAddr(t),
KeyPrefix: "gateway:session:",
LookupTimeout: 100 * time.Millisecond,
},
ReplayRedis: config.ReplayRedisConfig{
KeyPrefix: "gateway:replay:",
ReserveTimeout: 100 * time.Millisecond,
},
SessionEventsRedis: config.SessionEventsRedisConfig{
Stream: "gateway:session_events",
ReadBlockTimeout: time.Second,
},
ClientEventsRedis: config.ClientEventsRedisConfig{
Stream: "gateway:client_events",
ReadBlockTimeout: time.Second,
},
ResponseSigner: config.ResponseSignerConfig{
PrivateKeyPEMPath: responseSignerPEMPath,
},
},
wantErr: "ping redis session cache",
},
{
name: "invalid replay config",
cfg: config.Config{
SessionCacheRedis: config.SessionCacheRedisConfig{
Addr: server.Addr(),
KeyPrefix: "gateway:session:",
LookupTimeout: 250 * time.Millisecond,
},
ReplayRedis: config.ReplayRedisConfig{
ReserveTimeout: 250 * time.Millisecond,
},
SessionEventsRedis: config.SessionEventsRedisConfig{
Stream: "gateway:session_events",
ReadBlockTimeout: time.Second,
},
ClientEventsRedis: config.ClientEventsRedisConfig{
Stream: "gateway:client_events",
ReadBlockTimeout: time.Second,
},
ResponseSigner: config.ResponseSignerConfig{
PrivateKeyPEMPath: responseSignerPEMPath,
},
},
wantErr: "replay key prefix must not be empty",
},
{
name: "invalid client event config",
cfg: config.Config{
SessionCacheRedis: config.SessionCacheRedisConfig{
Addr: server.Addr(),
KeyPrefix: "gateway:session:",
LookupTimeout: 250 * time.Millisecond,
},
ReplayRedis: config.ReplayRedisConfig{
KeyPrefix: "gateway:replay:",
ReserveTimeout: 250 * time.Millisecond,
},
SessionEventsRedis: config.SessionEventsRedisConfig{
Stream: "gateway:session_events",
ReadBlockTimeout: time.Second,
},
ClientEventsRedis: config.ClientEventsRedisConfig{
ReadBlockTimeout: time.Second,
},
ResponseSigner: config.ResponseSignerConfig{
PrivateKeyPEMPath: responseSignerPEMPath,
},
},
wantErr: "client event subscriber: stream must not be empty",
},
{
name: "missing response signer path",
cfg: config.Config{
SessionCacheRedis: config.SessionCacheRedisConfig{
Addr: server.Addr(),
KeyPrefix: "gateway:session:",
LookupTimeout: 250 * time.Millisecond,
},
ReplayRedis: config.ReplayRedisConfig{
KeyPrefix: "gateway:replay:",
ReserveTimeout: 250 * time.Millisecond,
},
SessionEventsRedis: config.SessionEventsRedisConfig{
Stream: "gateway:session_events",
ReadBlockTimeout: time.Second,
},
ClientEventsRedis: config.ClientEventsRedisConfig{
Stream: "gateway:client_events",
ReadBlockTimeout: time.Second,
},
},
wantErr: "load response signer",
},
{
name: "invalid response signer pem",
cfg: config.Config{
SessionCacheRedis: config.SessionCacheRedisConfig{
Addr: server.Addr(),
KeyPrefix: "gateway:session:",
LookupTimeout: 250 * time.Millisecond,
},
ReplayRedis: config.ReplayRedisConfig{
KeyPrefix: "gateway:replay:",
ReserveTimeout: 250 * time.Millisecond,
},
SessionEventsRedis: config.SessionEventsRedisConfig{
Stream: "gateway:session_events",
ReadBlockTimeout: time.Second,
},
ClientEventsRedis: config.ClientEventsRedisConfig{
Stream: "gateway:client_events",
ReadBlockTimeout: time.Second,
},
ResponseSigner: config.ResponseSignerConfig{
PrivateKeyPEMPath: writeInvalidPEMFile(t),
},
},
wantErr: "response signer private key",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
deps, components, cleanup, err := newAuthenticatedGRPCDependencies(context.Background(), tt.cfg, zap.NewNop(), nil)
if tt.wantErr != "" {
require.Error(t, err)
assert.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
require.NotNil(t, deps.SessionCache)
require.NotNil(t, deps.ReplayStore)
require.NotNil(t, deps.ResponseSigner)
require.NotNil(t, deps.Router)
require.NotNil(t, deps.Service)
require.Len(t, components, 2)
require.NotNil(t, cleanup)
assert.NoError(t, cleanup())
})
}
}
func unusedTCPAddr(t *testing.T) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
addr := listener.Addr().String()
require.NoError(t, listener.Close())
return addr
}
func writeTestResponseSignerPEMFile(t *testing.T) string {
t.Helper()
seed := sha256.Sum256([]byte("gateway-main-test-response-signer"))
privateKey := ed25519.NewKeyFromSeed(seed[:])
encodedPrivateKey, err := x509.MarshalPKCS8PrivateKey(privateKey)
require.NoError(t, err)
path := filepath.Join(t.TempDir(), "response-signer.pem")
err = os.WriteFile(path, pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: encodedPrivateKey,
}), 0o600)
require.NoError(t, err)
return path
}
func writeInvalidPEMFile(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "invalid-response-signer.pem")
err := os.WriteFile(path, []byte("not a valid pem"), 0o600)
require.NoError(t, err)
return path
}
+20
View File
@@ -0,0 +1,20 @@
# Edge Gateway Docs
This directory keeps service-local documentation that is too detailed for the
root architecture documents and too diagram-heavy for the module README.
Sections:
- [Runtime and components](runtime.md)
- [Public auth, command, and push flows](flows.md)
- [Operator runbook](runbook.md)
- [Configuration and contract examples](examples.md)
- [Example `.env`](../.env.example)
Primary references:
- [`../README.md`](../README.md) for service scope, contracts, configuration,
and operational behavior
- [`../openapi.yaml`](../openapi.yaml) for the public REST contract
- [`../../README.md`](../../README.md) for workspace-level architecture
- [`../../SECURITY.md`](../../SECURITY.md) for the transport security model
+179
View File
@@ -0,0 +1,179 @@
# Configuration And Contract Examples
The examples below are illustrative. Values such as signatures, payload hashes,
and FlatBuffers payload bytes are placeholders unless explicitly stated
otherwise.
## Example `.env`
The repository also includes a ready-to-copy sample file:
- [`../.env.example`](../.env.example)
The sample keeps all secrets blank and shows only the settings needed to boot
the process and expose the main listeners.
## Public Auth HTTP Examples
Start an e-mail challenge:
```bash
curl -X POST http://127.0.0.1:8080/api/v1/public/auth/send-email-code \
-H 'Content-Type: application/json' \
-d '{"email":"pilot@example.com"}'
```
Example response:
```json
{
"challenge_id": "challenge-123"
}
```
Confirm the challenge and register the device public key:
```bash
curl -X POST http://127.0.0.1:8080/api/v1/public/auth/confirm-email-code \
-H 'Content-Type: application/json' \
-d '{
"challenge_id": "challenge-123",
"code": "123456",
"client_public_key": "11qYAYdk8v3K6Yw8QK6ZlQ2nP4Wm8Cq5g1H0K8vT9no="
}'
```
Example response:
```json
{
"device_session_id": "device-session-123"
}
```
## Authenticated gRPC Envelope Examples
The authenticated transport is gRPC/protobuf, not JSON over HTTP. The examples
below use protobuf-style JSON only to make the logical envelope readable.
`bytes` fields are shown as base64 strings, matching the standard protobuf JSON
mapping.
Example `ExecuteCommandRequest`:
```json
{
"protocolVersion": "v1",
"deviceSessionId": "device-session-123",
"messageType": "fleet.move",
"timestampMs": "1775121600000",
"requestId": "request-123",
"payloadBytes": "RkxBVEJVRkZFUlNfUEFZTE9BRA==",
"payloadHash": "5fY6Q8V9mK8x2B7v6v0V0m0i1rQ2QF0rQ8V1Yt1r8Ys=",
"signature": "3o4v8f3h0Y6I0x1bS7zY+8m0bV1Lk4D3yq8J2n8F1rD7yK9v8M1Q0w2s4a6f8d0Q0m3L6y8R1t5w7x9z0a2cA==",
"traceId": "trace-123"
}
```
Example `ExecuteCommandResponse`:
```json
{
"protocolVersion": "v1",
"requestId": "request-123",
"timestampMs": "1775121600123",
"resultCode": "ok",
"payloadBytes": "RkxBVEJVRkZFUlNfUkVTUE9OU0U=",
"payloadHash": "wL4n8H1aR2x3M4b5C6d7E8f9G0h1J2k3L4m5N6o7P8Q=",
"signature": "2Xb7l9m0n1p2q3r4s5t6u7v8w9x0y1z2A3B4C5D6E7F8G9H0J1K2L3M4N5O6P7Q8R9S0T1U2V3W4X5Y6Z7a8b9cQ=="
}
```
Example bootstrap `GatewayEvent` sent after `SubscribeEvents` opens:
```json
{
"eventType": "gateway.server_time",
"eventId": "request-123",
"timestampMs": "1775121600456",
"payloadBytes": "RkxBVEJVRkZFUlNfU0VSVkVSX1RJTUU=",
"payloadHash": "2b1U3m4N5p6Q7r8S9t0U1v2W3x4Y5z6A7b8C9d0E1f2=",
"signature": "4Nf8k2p6s0w4y8A2d6g0j4m8p2t6w0z4C8F2I6L0O4R8U2X6a0d4g8j2m6p0s4v8yA2d6g0j4m8p2t6w0z4C8F2I6A==",
"requestId": "request-123",
"traceId": "trace-123"
}
```
## Redis Examples
### Session Cache Record
Example Redis key and JSON value used by the fallback session cache:
```text
gateway:session:device-session-123
```
```json
{
"device_session_id": "device-session-123",
"user_id": "user-123",
"client_public_key": "11qYAYdk8v3K6Yw8QK6ZlQ2nP4Wm8Cq5g1H0K8vT9no=",
"status": "active"
}
```
### Session Event Stream Entry
Example session snapshot entry:
```bash
redis-cli XADD gateway:session-events '*' \
device_session_id device-session-123 \
user_id user-123 \
client_public_key 11qYAYdk8v3K6Yw8QK6ZlQ2nP4Wm8Cq5g1H0K8vT9no= \
status active
```
Revocation entry:
```bash
redis-cli XADD gateway:session-events '*' \
device_session_id device-session-123 \
user_id user-123 \
client_public_key 11qYAYdk8v3K6Yw8QK6ZlQ2nP4Wm8Cq5g1H0K8vT9no= \
status revoked \
revoked_at_ms 1775121700000
```
### Client Event Stream Entry
User-wide event:
```bash
redis-cli XADD gateway:client-events '*' \
user_id user-123 \
event_type fleet.updated \
event_id event-123 \
payload_bytes payload-v1
```
Session-targeted event with correlation:
```bash
redis-cli XADD gateway:client-events '*' \
user_id user-123 \
device_session_id device-session-123 \
event_type fleet.updated \
event_id event-124 \
payload_bytes payload-v2 \
request_id request-123 \
trace_id trace-123
```
Notes:
- `payload_bytes` in Redis Stream entries must be binary-safe payload data;
- the gateway derives `timestamp_ms`, recomputes `payload_hash`, and signs the
outgoing event at delivery time;
- each gateway replica consumes streams with plain `XREAD`, so publishers must
keep retention bounded with `MAXLEN`.
+86
View File
@@ -0,0 +1,86 @@
# Request and Push Flows
## Public Auth Flow
```mermaid
sequenceDiagram
participant Client
participant Gateway
participant Limiter as Public anti-abuse
participant Auth as AuthServiceClient
Client->>Gateway: POST /api/v1/public/auth/send-email-code
Gateway->>Limiter: classify + rate-limit + body checks
Limiter-->>Gateway: allowed
Gateway->>Auth: SendEmailCode(email)
Auth-->>Gateway: challenge_id
Gateway-->>Client: 200 {challenge_id}
Client->>Gateway: POST /api/v1/public/auth/confirm-email-code
Gateway->>Limiter: classify + rate-limit + body checks
Limiter-->>Gateway: allowed
Gateway->>Auth: ConfirmEmailCode(challenge_id, code, client_public_key)
Auth-->>Gateway: device_session_id
Gateway-->>Client: 200 {device_session_id}
```
## Authenticated ExecuteCommand Flow
```mermaid
sequenceDiagram
participant Client
participant Gateway
participant Cache as SessionCache
participant Replay as ReplayStore
participant Policy as Rate limit / policy
participant Downstream
Client->>Gateway: ExecuteCommand(envelope, payload_bytes, signature)
Gateway->>Gateway: validate envelope + protocol_version
Gateway->>Cache: lookup(device_session_id)
Cache-->>Gateway: session record
Gateway->>Gateway: verify payload_hash
Gateway->>Gateway: verify Ed25519 signature
Gateway->>Gateway: verify freshness window
Gateway->>Replay: reserve(device_session_id, request_id, ttl)
Replay-->>Gateway: accepted
Gateway->>Policy: apply IP/session/user/message_type budgets
Policy-->>Gateway: allowed
Gateway->>Downstream: verified authenticated command
Downstream-->>Gateway: result_code + payload_bytes
Gateway->>Gateway: hash payload + sign response
Gateway-->>Client: ExecuteCommandResponse + signature
```
## SubscribeEvents Lifecycle
```mermaid
sequenceDiagram
participant Client
participant Gateway
participant Cache as SessionCache
participant Replay as ReplayStore
participant Hub as PushHub
participant Stream as Client event stream
participant Sess as Session event stream
Client->>Gateway: SubscribeEvents(envelope, signature)
Gateway->>Gateway: validate envelope + verify request
Gateway->>Cache: lookup(device_session_id)
Cache-->>Gateway: session record
Gateway->>Replay: reserve(device_session_id, request_id, ttl)
Replay-->>Gateway: accepted
Gateway->>Client: gateway.server_time event
Gateway->>Hub: register(user_id, device_session_id)
Stream-->>Gateway: client-facing event for user_id / device_session_id
Gateway->>Hub: publish signed event
Hub-->>Client: matching event delivery
Sess-->>Gateway: revoked session snapshot
Gateway->>Hub: revoke(device_session_id)
Hub-->>Client: stream closes with FAILED_PRECONDITION
Note over Gateway,Hub: During shutdown the gateway closes PushHub before gRPC graceful stop.
Hub-->>Client: stream closes with UNAVAILABLE
```
+143
View File
@@ -0,0 +1,143 @@
# Operator Runbook
This runbook covers the checks that matter most during startup, steady-state
readiness, shutdown, and push or revoke incidents.
## Startup Checks
Before starting the process, confirm:
- `GATEWAY_SESSION_CACHE_REDIS_ADDR` points to the Redis deployment used for
session lookup and both internal event streams.
- `GATEWAY_SESSION_EVENTS_REDIS_STREAM` and
`GATEWAY_CLIENT_EVENTS_REDIS_STREAM` reference existing Redis Stream keys or
the names publishers will use.
- `GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH` points to a readable PKCS#8
PEM-encoded Ed25519 private key.
- the configured Redis ACL, DB, TLS, and key-prefix settings match the target
environment.
At startup the process performs bounded `PING` checks for:
- the Redis-backed session cache adapter;
- the replay store;
- the session event subscriber;
- the client event subscriber.
Startup fails fast if any of those checks fail or if the signer key cannot be
loaded.
Expected listener state after a healthy start:
- public HTTP is enabled on `GATEWAY_PUBLIC_HTTP_ADDR` or its default `:8080`;
- authenticated gRPC is enabled on
`GATEWAY_AUTHENTICATED_GRPC_ADDR` or its default `:9090`;
- admin HTTP is enabled only when `GATEWAY_ADMIN_HTTP_ADDR` is non-empty.
Known startup caveats:
- public auth routes stay mounted without an upstream adapter and return
`503 service_unavailable`;
- authenticated gRPC starts with an empty static router, so `ExecuteCommand`
returns gRPC `UNIMPLEMENTED` until downstream routes are injected.
## Readiness
Use the probes according to what they actually guarantee:
- `GET /healthz` confirms that the public HTTP listener is alive;
- `GET /readyz` confirms that the current process is ready to serve public HTTP
traffic;
- `GET /metrics` is available only on the optional admin listener.
`/readyz` is process-local. It does not confirm:
- downstream business-service reachability;
- auth upstream adapter reachability;
- Redis health after startup;
- push fan-out health.
For a practical readiness check in production:
1. confirm the process emitted startup logs for the public and authenticated
listeners;
2. check `GET /healthz`;
3. check `GET /readyz`;
4. if admin HTTP is enabled, scrape `GET /metrics`;
5. verify the expected Redis deployment and stream names from config.
## Shutdown
The process handles `SIGINT` and `SIGTERM`.
Shutdown behavior:
- the per-component shutdown budget is controlled by
`GATEWAY_SHUTDOWN_TIMEOUT`;
- internal subscribers are stopped as part of application shutdown;
- the in-memory `PushHub` is closed before gRPC graceful stop;
- active `SubscribeEvents` streams terminate with gRPC `UNAVAILABLE` and
message `gateway is shutting down`.
During planned restarts:
1. send `SIGTERM`;
2. wait for listener shutdown and component-stop logs;
3. expect connected clients to reconnect after the gateway closes the stream;
4. investigate only if shutdown exceeds `GATEWAY_SHUTDOWN_TIMEOUT` or streams
remain open unexpectedly.
## Revoke And Push Failure Triage
### Revocation Does Not Take Effect
If a revoked session still sends traffic or keeps an active stream:
1. verify that the auth/session side published a session snapshot with the
same `device_session_id` and `status=revoked`;
2. verify that the event was written to
`GATEWAY_SESSION_EVENTS_REDIS_STREAM`;
3. verify the gateway is connected to the same Redis address, DB, and stream;
4. confirm the snapshot fields are complete and well-formed;
5. check that a later active snapshot did not overwrite the revoked one.
Expected gateway behavior after the revoke snapshot is consumed:
- new authenticated requests for that `device_session_id` fail with gRPC
`FAILED_PRECONDITION`;
- active `SubscribeEvents` streams for that exact `device_session_id` close
with the same status.
### Push Events Are Not Delivered
If a client reports missing push events:
1. confirm that the client successfully opened `SubscribeEvents`;
2. confirm the stream received the initial `gateway.server_time` bootstrap
event;
3. confirm the gateway consumed the expected entry from
`GATEWAY_CLIENT_EVENTS_REDIS_STREAM`;
4. verify `user_id` and optional `device_session_id` in the stream entry match
the intended target;
5. confirm the event payload fields are well-formed and not dropped as
malformed;
6. check whether the stream was closed earlier because of revoke, shutdown, or
overflow.
### Stream Closed Unexpectedly
Use the terminal gRPC status first:
- `FAILED_PRECONDITION` with `device session is revoked` means the session was
revoked;
- `RESOURCE_EXHAUSTED` with `push stream overflowed` means that stream stopped
consuming fast enough and its in-memory queue overflowed;
- `UNAVAILABLE` with `gateway is shutting down` means normal process shutdown;
- client-side cancellation or transport errors should be investigated on the
client or network side.
For overflow incidents:
- treat the issue as stream-local, not a global push outage;
- inspect client receive behavior and reconnect logic;
- look at push metrics and logs around the affected user/session.
+59
View File
@@ -0,0 +1,59 @@
# Runtime and Components
The diagram below focuses on the deployed `galaxy/gateway` process and its
runtime dependencies.
```mermaid
flowchart LR
subgraph Clients
Public["Public REST clients"]
Authd["Authenticated gRPC clients"]
end
subgraph Gateway["Edge Gateway process"]
PublicHTTP["Public HTTP listener\n/healthz /readyz /api/v1/public/auth/*"]
AuthGRPC["Authenticated gRPC listener\nExecuteCommand / SubscribeEvents"]
AdminHTTP["Optional admin HTTP listener\n/metrics"]
SessionSnap["In-memory session snapshot cache"]
Replay["Replay reservation client"]
PushHub["PushHub"]
SessSub["Session event subscriber"]
ClientSub["Client event subscriber"]
Telemetry["Logs, traces, metrics"]
end
Public --> PublicHTTP
Authd --> AuthGRPC
AuthGRPC --> SessionSnap
AuthGRPC --> Replay
AuthGRPC --> PushHub
SessSub --> SessionSnap
SessSub --> PushHub
ClientSub --> PushHub
PublicHTTP --> Telemetry
AuthGRPC --> Telemetry
AdminHTTP --> Telemetry
Redis["Redis\nsession records + replay keys + streams"]
AuthSvc["Auth / Session Service"]
Downstream["Downstream business services"]
Metrics["Prometheus / OTLP collectors"]
PublicHTTP -. public auth adapter .-> AuthSvc
SessionSnap --> Redis
Replay --> Redis
SessSub --> Redis
ClientSub --> Redis
AuthGRPC --> Downstream
Telemetry --> Metrics
```
Notes:
- `cmd/gateway` refuses startup when Redis connectivity or the response signer
is misconfigured.
- The admin listener is optional and serves only Prometheus text metrics.
- Public auth routing stays available without an upstream adapter, but returns
`503 service_unavailable`.
- Authenticated gRPC starts with an empty static router; `ExecuteCommand`
remains `UNIMPLEMENTED` until downstream routes are injected.
+95
View File
@@ -1,3 +1,98 @@
module galaxy/gateway
go 1.26.0
require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1
buf.build/go/protovalidate v1.1.3
github.com/alicebob/miniredis/v2 v2.37.0
github.com/getkin/kin-openapi v0.134.0
github.com/gin-gonic/gin v1.12.0
github.com/google/flatbuffers v25.12.19+incompatible
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.18.0
github.com/stretchr/testify v1.11.1
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
go.opentelemetry.io/otel v1.42.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0
go.opentelemetry.io/otel/exporters/prometheus v0.64.0
go.opentelemetry.io/otel/metric v1.42.0
go.opentelemetry.io/otel/sdk v1.42.0
go.opentelemetry.io/otel/sdk/metric v1.42.0
go.opentelemetry.io/otel/trace v1.42.0
go.uber.org/zap v1.27.1
golang.org/x/time v0.15.0
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
)
require (
cel.dev/expr v0.25.1 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/cel-go v0.27.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c // indirect
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/woodsbury/decimal128 v1.3.0 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/arch v0.24.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+236
View File
@@ -0,0 +1,236 @@
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 h1:PMmTMyvHScV9Mn8wc6ASge9uRcHy0jtqPd+fM35LmsQ=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
buf.build/go/protovalidate v1.1.3 h1:m2GVEgQWd7rk+vIoAZ+f0ygGjvQTuqPQapBBdcpWVPE=
buf.build/go/protovalidate v1.1.3/go.mod h1:9XIuohWz+kj+9JVn3WQneHA5LZP50mjvneZMnbLkiIE=
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/getkin/kin-openapi v0.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU=
github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo=
github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw=
github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c h1:7ACFcSaQsrWtrH4WHHfUqE1C+f8r2uv8KGaW0jTNjus=
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c/go.mod h1:JKox4Gszkxt57kj27u7rvi7IFoIULvCZHUsBTUmQM/s=
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b h1:vivRhVUAa9t1q0Db4ZmezBP8pWQWnXHFokZj0AOea2g=
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8=
github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0 h1:E7DmskpIO7ZR6QI6zKSEKIDNUYoKw9oHXP23gzbCdU0=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0/go.mod h1:WB2cS9y+AwqqKhoo9gw6/ZxlSjFBUQGZ8BQOaD3FVXM=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
go.opentelemetry.io/contrib/propagators/b3 v1.42.0 h1:B2Pew5ufEtgkjLF+tSkXjgYZXQr9m7aCm1wLKB0URbU=
go.opentelemetry.io/contrib/propagators/b3 v1.42.0/go.mod h1:iPgUcSEF5DORW6+yNbdw/YevUy+QqJ508ncjhrRSCjc=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc=
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs=
go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+133
View File
@@ -0,0 +1,133 @@
// Package adminapi exposes the optional private admin HTTP listener used for
// operational endpoints such as Prometheus metrics.
package adminapi
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"sync"
"galaxy/gateway/internal/config"
"go.uber.org/zap"
)
// Server owns the optional admin HTTP listener exposed by the gateway.
type Server struct {
cfg config.AdminHTTPConfig
handler http.Handler
logger *zap.Logger
stateMu sync.RWMutex
server *http.Server
listener net.Listener
}
// NewServer constructs an admin HTTP server for cfg and handler.
func NewServer(cfg config.AdminHTTPConfig, handler http.Handler, logger *zap.Logger) *Server {
if handler == nil {
handler = http.NotFoundHandler()
}
if logger == nil {
logger = zap.NewNop()
}
return &Server{
cfg: cfg,
handler: handler,
logger: logger.Named("admin_http"),
}
}
// Enabled reports whether the admin listener should run.
func (s *Server) Enabled() bool {
return s != nil && s.cfg.Addr != ""
}
// Run binds the configured listener and serves the admin HTTP surface until
// Shutdown closes the server. A disabled admin server returns when ctx is
// canceled.
func (s *Server) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run admin HTTP server: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
if !s.Enabled() {
<-ctx.Done()
return nil
}
listener, err := net.Listen("tcp", s.cfg.Addr)
if err != nil {
return fmt.Errorf("run admin HTTP server: listen on %q: %w", s.cfg.Addr, err)
}
server := &http.Server{
Handler: s.handler,
ReadHeaderTimeout: s.cfg.ReadHeaderTimeout,
ReadTimeout: s.cfg.ReadTimeout,
IdleTimeout: s.cfg.IdleTimeout,
}
s.stateMu.Lock()
s.server = server
s.listener = listener
s.stateMu.Unlock()
s.logger.Info("admin HTTP server started", zap.String("addr", listener.Addr().String()))
defer func() {
s.stateMu.Lock()
s.server = nil
s.listener = nil
s.stateMu.Unlock()
}()
err = server.Serve(listener)
switch {
case err == nil:
return nil
case errors.Is(err, http.ErrServerClosed):
s.logger.Info("admin HTTP server stopped")
return nil
default:
return fmt.Errorf("run admin HTTP server: serve on %q: %w", s.cfg.Addr, err)
}
}
// Shutdown gracefully stops the admin HTTP server within ctx.
func (s *Server) Shutdown(ctx context.Context) error {
if ctx == nil {
return errors.New("shutdown admin HTTP server: nil context")
}
s.stateMu.RLock()
server := s.server
s.stateMu.RUnlock()
if server == nil {
return nil
}
if err := server.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("shutdown admin HTTP server: %w", err)
}
return nil
}
func (s *Server) listenAddr() string {
s.stateMu.RLock()
defer s.stateMu.RUnlock()
if s.listener == nil {
return ""
}
return s.listener.Addr().String()
}
+102
View File
@@ -0,0 +1,102 @@
package adminapi
import (
"context"
"net"
"net/http"
"testing"
"time"
"galaxy/gateway/internal/app"
"galaxy/gateway/internal/config"
"galaxy/gateway/internal/restapi"
"galaxy/gateway/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMetricsAreReachableOnlyOnAdminListener(t *testing.T) {
t.Parallel()
logger, _ := testutil.NewObservedLogger(t)
telemetryRuntime := testutil.NewTelemetryRuntime(t, logger)
publicAddr := unusedTCPAddr(t)
adminAddr := unusedTCPAddr(t)
publicCfg := config.DefaultPublicHTTPConfig()
publicCfg.Addr = publicAddr
adminCfg := config.DefaultAdminHTTPConfig()
adminCfg.Addr = adminAddr
restServer := restapi.NewServer(publicCfg, restapi.ServerDependencies{
Logger: logger,
Telemetry: telemetryRuntime,
})
adminServer := NewServer(adminCfg, telemetryRuntime.Handler(), logger)
application := app.New(
config.Config{
ShutdownTimeout: time.Second,
PublicHTTP: publicCfg,
AdminHTTP: adminCfg,
AuthenticatedGRPC: config.DefaultAuthenticatedGRPCConfig(),
},
restServer,
adminServer,
)
ctx, cancel := context.WithCancel(context.Background())
resultCh := make(chan error, 1)
go func() {
resultCh <- application.Run(ctx)
}()
defer func() {
cancel()
select {
case err := <-resultCh:
require.NoError(t, err)
case <-time.After(time.Second):
require.FailNow(t, "application did not stop")
}
}()
waitForHTTPStatus(t, "http://"+publicAddr+"/healthz", http.StatusOK)
waitForHTTPStatus(t, "http://"+adminAddr+"/metrics", http.StatusOK)
publicMetricsResp, err := http.Get("http://" + publicAddr + "/metrics")
require.NoError(t, err)
defer func() {
require.NoError(t, publicMetricsResp.Body.Close())
}()
assert.Equal(t, http.StatusNotFound, publicMetricsResp.StatusCode)
}
func waitForHTTPStatus(t *testing.T, rawURL string, wantStatus int) {
t.Helper()
require.Eventually(t, func() bool {
resp, err := http.Get(rawURL)
if err != nil {
return false
}
defer func() {
_ = resp.Body.Close()
}()
return resp.StatusCode == wantStatus
}, time.Second, 10*time.Millisecond)
}
func unusedTCPAddr(t *testing.T) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
addr := listener.Addr().String()
require.NoError(t, listener.Close())
return addr
}
+178
View File
@@ -0,0 +1,178 @@
// Package app wires the gateway process lifecycle and coordinates component
// startup and graceful shutdown.
package app
import (
"context"
"errors"
"fmt"
"sync"
"galaxy/gateway/internal/config"
)
// Component is a long-lived gateway subsystem that participates in coordinated
// startup and graceful shutdown.
type Component interface {
// Run starts the component and blocks until it stops.
Run(context.Context) error
// Shutdown stops the component within the provided timeout-bounded context.
Shutdown(context.Context) error
}
// App owns the process-level lifecycle of the gateway and its registered
// components.
type App struct {
cfg config.Config
components []Component
}
// New constructs an App with a defensive copy of the supplied components.
func New(cfg config.Config, components ...Component) *App {
clonedComponents := append([]Component(nil), components...)
return &App{
cfg: cfg,
components: clonedComponents,
}
}
// Run starts all configured components, waits for cancellation or the first
// component failure, and then executes best-effort graceful shutdown for every
// component.
func (a *App) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run gateway app: nil context")
}
if err := a.validate(); err != nil {
return err
}
if len(a.components) == 0 {
<-ctx.Done()
return nil
}
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
results := make(chan componentResult, len(a.components))
var runWG sync.WaitGroup
for idx, component := range a.components {
runWG.Add(1)
go func(index int, component Component) {
defer runWG.Done()
results <- componentResult{
index: index,
err: component.Run(runCtx),
}
}(idx, component)
}
var runErr error
select {
case <-ctx.Done():
case result := <-results:
runErr = classifyComponentResult(ctx, result)
}
cancel()
shutdownErr := a.shutdownComponents()
waitErr := a.waitForComponents(&runWG)
return errors.Join(runErr, shutdownErr, waitErr)
}
// componentResult captures the first observed exit from a running component.
type componentResult struct {
index int
err error
}
// validate confirms that the App has a safe shutdown budget and no nil
// components before goroutines are started.
func (a *App) validate() error {
if a.cfg.ShutdownTimeout <= 0 {
return fmt.Errorf("run gateway app: shutdown timeout must be positive, got %s", a.cfg.ShutdownTimeout)
}
for idx, component := range a.components {
if component == nil {
return fmt.Errorf("run gateway app: component %d is nil", idx)
}
}
return nil
}
// classifyComponentResult maps the first component exit into the error that
// should control the application result.
func classifyComponentResult(parentCtx context.Context, result componentResult) error {
switch {
case result.err == nil:
if parentCtx.Err() != nil {
return nil
}
return fmt.Errorf("run gateway app: component %d exited without error before shutdown", result.index)
case errors.Is(result.err, context.Canceled) && parentCtx.Err() != nil:
return nil
default:
return fmt.Errorf("run gateway app: component %d: %w", result.index, result.err)
}
}
// shutdownComponents calls Shutdown on every registered component using a fresh
// timeout-bounded context per component and joins any shutdown failures.
func (a *App) shutdownComponents() error {
var shutdownWG sync.WaitGroup
errs := make(chan error, len(a.components))
for idx, component := range a.components {
shutdownWG.Add(1)
go func(index int, component Component) {
defer shutdownWG.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), a.cfg.ShutdownTimeout)
defer cancel()
if err := component.Shutdown(shutdownCtx); err != nil {
errs <- fmt.Errorf("shutdown gateway component %d: %w", index, err)
}
}(idx, component)
}
shutdownWG.Wait()
close(errs)
var joined error
for err := range errs {
joined = errors.Join(joined, err)
}
return joined
}
// waitForComponents waits for running components to return after shutdown and
// reports when they outlive the configured shutdown budget.
func (a *App) waitForComponents(runWG *sync.WaitGroup) error {
done := make(chan struct{})
go func() {
runWG.Wait()
close(done)
}()
waitCtx, cancel := context.WithTimeout(context.Background(), a.cfg.ShutdownTimeout)
defer cancel()
select {
case <-done:
return nil
case <-waitCtx.Done():
return fmt.Errorf("wait for gateway components: %w", waitCtx.Err())
}
}
+268
View File
@@ -0,0 +1,268 @@
package app
import (
"context"
"errors"
"sync"
"testing"
"time"
"galaxy/gateway/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAppRunWaitsForCancellationWithoutComponents(t *testing.T) {
t.Parallel()
application := New(config.Config{ShutdownTimeout: 50 * time.Millisecond})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
resultCh := make(chan error, 1)
go func() {
resultCh <- application.Run(ctx)
}()
select {
case err := <-resultCh:
require.FailNowf(t, "Run() returned early", "error=%v", err)
case <-time.After(50 * time.Millisecond):
}
cancel()
select {
case err := <-resultCh:
require.NoError(t, err)
case <-time.After(time.Second):
require.FailNow(t, "Run() did not return after cancellation")
}
}
func TestAppRunCancelsComponentsAndCallsShutdownOnce(t *testing.T) {
t.Parallel()
first := newLifecycleComponent()
second := newLifecycleComponent()
application := New(
config.Config{ShutdownTimeout: time.Second},
first,
second,
)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
resultCh := make(chan error, 1)
go func() {
resultCh <- application.Run(ctx)
}()
first.waitStarted(t)
second.waitStarted(t)
cancel()
select {
case err := <-resultCh:
require.NoError(t, err)
case <-time.After(time.Second):
require.FailNow(t, "Run() did not return after cancellation")
}
first.waitRunExited(t)
second.waitRunExited(t)
assert.Equal(t, 1, first.shutdownCalls())
assert.Equal(t, 1, second.shutdownCalls())
}
func TestAppRunReturnsComponentErrorAndStillShutsDown(t *testing.T) {
t.Parallel()
runErr := errors.New("boom")
failing := newFailingComponent(runErr)
blocking := newLifecycleComponent()
application := New(
config.Config{ShutdownTimeout: time.Second},
failing,
blocking,
)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
resultCh := make(chan error, 1)
go func() {
resultCh <- application.Run(ctx)
}()
failing.waitStarted(t)
blocking.waitStarted(t)
failing.releaseRun()
select {
case err := <-resultCh:
require.Error(t, err)
assert.ErrorIs(t, err, runErr)
case <-time.After(time.Second):
require.FailNow(t, "Run() did not return after component failure")
}
failing.waitRunExited(t)
blocking.waitRunExited(t)
assert.Equal(t, 1, failing.shutdownCalls())
assert.Equal(t, 1, blocking.shutdownCalls())
}
// lifecycleComponent blocks in Run until the application calls Shutdown.
type lifecycleComponent struct {
startedCh chan struct{}
runDoneCh chan struct{}
stopCh chan struct{}
shutdownMu sync.Mutex
shutdownCnt int
}
// newLifecycleComponent builds a component that exits Run only after Shutdown
// signals its stop channel.
func newLifecycleComponent() *lifecycleComponent {
return &lifecycleComponent{
startedCh: make(chan struct{}),
runDoneCh: make(chan struct{}),
stopCh: make(chan struct{}),
}
}
// Run marks the component as started, waits for cancellation, and then blocks
// until Shutdown releases the stop channel.
func (c *lifecycleComponent) Run(ctx context.Context) error {
close(c.startedCh)
defer close(c.runDoneCh)
<-ctx.Done()
<-c.stopCh
return nil
}
// Shutdown records the call and releases the run loop.
func (c *lifecycleComponent) Shutdown(context.Context) error {
c.shutdownMu.Lock()
defer c.shutdownMu.Unlock()
c.shutdownCnt++
if c.shutdownCnt == 1 {
close(c.stopCh)
}
return nil
}
// waitStarted blocks until Run has started or fails the test on timeout.
func (c *lifecycleComponent) waitStarted(t *testing.T) {
t.Helper()
select {
case <-c.startedCh:
case <-time.After(time.Second):
require.FailNow(t, "component did not start")
}
}
// waitRunExited blocks until Run exits or fails the test on timeout.
func (c *lifecycleComponent) waitRunExited(t *testing.T) {
t.Helper()
select {
case <-c.runDoneCh:
case <-time.After(time.Second):
require.FailNow(t, "component run did not exit")
}
}
// shutdownCalls returns the number of observed Shutdown invocations.
func (c *lifecycleComponent) shutdownCalls() int {
c.shutdownMu.Lock()
defer c.shutdownMu.Unlock()
return c.shutdownCnt
}
// failingComponent returns a predefined error once released by the test and
// still tracks shutdown calls.
type failingComponent struct {
startedCh chan struct{}
releaseCh chan struct{}
runDoneCh chan struct{}
shutdownMu sync.Mutex
shutdownCnt int
err error
}
// newFailingComponent builds a component whose Run returns err after release.
func newFailingComponent(err error) *failingComponent {
return &failingComponent{
startedCh: make(chan struct{}),
releaseCh: make(chan struct{}),
runDoneCh: make(chan struct{}),
err: err,
}
}
// Run waits until the test releases it and then returns the configured error.
func (c *failingComponent) Run(context.Context) error {
close(c.startedCh)
defer close(c.runDoneCh)
<-c.releaseCh
return c.err
}
// Shutdown records that the application attempted graceful shutdown.
func (c *failingComponent) Shutdown(context.Context) error {
c.shutdownMu.Lock()
defer c.shutdownMu.Unlock()
c.shutdownCnt++
return nil
}
// waitStarted blocks until Run has started or fails the test on timeout.
func (c *failingComponent) waitStarted(t *testing.T) {
t.Helper()
select {
case <-c.startedCh:
case <-time.After(time.Second):
require.FailNow(t, "failing component did not start")
}
}
// releaseRun allows Run to return its configured error.
func (c *failingComponent) releaseRun() {
close(c.releaseCh)
}
// waitRunExited blocks until Run exits or fails the test on timeout.
func (c *failingComponent) waitRunExited(t *testing.T) {
t.Helper()
select {
case <-c.runDoneCh:
case <-time.After(time.Second):
require.FailNow(t, "failing component run did not exit")
}
}
// shutdownCalls returns the number of observed Shutdown invocations.
func (c *failingComponent) shutdownCalls() int {
c.shutdownMu.Lock()
defer c.shutdownMu.Unlock()
return c.shutdownCnt
}
+80
View File
@@ -0,0 +1,80 @@
package authn
import (
"crypto/ed25519"
"encoding/binary"
"errors"
)
const (
// EventDomainMarkerV1 binds the v1 server event signature to the Galaxy
// gateway transport contract.
EventDomainMarkerV1 = "galaxy-event-v1"
)
var (
// ErrInvalidEventSignature reports that a gateway stream event signature is
// not a raw Ed25519 signature for the canonical event signing input.
ErrInvalidEventSignature = errors.New("invalid event signature")
)
// EventSigningFields contains the canonical v1 stream-event fields that are
// bound into the server signing input.
type EventSigningFields struct {
// EventType identifies the stable client-facing event category.
EventType string
// EventID is the stable event correlation identifier.
EventID string
// TimestampMS carries the server event timestamp in milliseconds.
TimestampMS int64
// RequestID optionally correlates the event to the opening client request.
RequestID string
// TraceID optionally carries the client-supplied tracing correlation value.
TraceID string
// PayloadHash is the raw SHA-256 digest of event payload bytes.
PayloadHash []byte
}
// BuildEventSigningInput returns the canonical byte sequence the v1 gateway
// stream-event signature covers. String and byte fields are length-prefixed
// with uvarint(len(field)) followed by raw bytes, while TimestampMS is
// appended as an 8-byte big-endian uint64.
func BuildEventSigningInput(fields EventSigningFields) []byte {
size := len(EventDomainMarkerV1) +
len(fields.EventType) +
len(fields.EventID) +
len(fields.RequestID) +
len(fields.TraceID) +
len(fields.PayloadHash) +
(6 * binary.MaxVarintLen64) +
8
buf := make([]byte, 0, size)
buf = appendLengthPrefixedString(buf, EventDomainMarkerV1)
buf = appendLengthPrefixedString(buf, fields.EventType)
buf = appendLengthPrefixedString(buf, fields.EventID)
buf = binary.BigEndian.AppendUint64(buf, uint64(fields.TimestampMS))
buf = appendLengthPrefixedString(buf, fields.RequestID)
buf = appendLengthPrefixedString(buf, fields.TraceID)
buf = appendLengthPrefixedBytes(buf, fields.PayloadHash)
return buf
}
// VerifyEventSignature verifies that signature authenticates fields under
// publicKey using the canonical v1 event signing input.
func VerifyEventSignature(publicKey ed25519.PublicKey, signature []byte, fields EventSigningFields) error {
if len(publicKey) != ed25519.PublicKeySize || len(signature) != ed25519.SignatureSize {
return ErrInvalidEventSignature
}
if !ed25519.Verify(publicKey, BuildEventSigningInput(fields), signature) {
return ErrInvalidEventSignature
}
return nil
}
+111
View File
@@ -0,0 +1,111 @@
package authn
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildEventSigningInputChangesWhenSignedFieldChanges(t *testing.T) {
t.Parallel()
base := EventSigningFields{
EventType: "gateway.server_time",
EventID: "request-123",
TimestampMS: 123456789,
RequestID: "request-123",
TraceID: "trace-123",
PayloadHash: mustSHA256([]byte("payload")),
}
baseInput := BuildEventSigningInput(base)
tests := []struct {
name string
mutate func(EventSigningFields) EventSigningFields
}{
{
name: "event type",
mutate: func(fields EventSigningFields) EventSigningFields {
fields.EventType = "gateway.other"
return fields
},
},
{
name: "event id",
mutate: func(fields EventSigningFields) EventSigningFields {
fields.EventID = "request-456"
return fields
},
},
{
name: "timestamp",
mutate: func(fields EventSigningFields) EventSigningFields {
fields.TimestampMS++
return fields
},
},
{
name: "request id",
mutate: func(fields EventSigningFields) EventSigningFields {
fields.RequestID = "request-456"
return fields
},
},
{
name: "trace id",
mutate: func(fields EventSigningFields) EventSigningFields {
fields.TraceID = "trace-456"
return fields
},
},
{
name: "payload hash",
mutate: func(fields EventSigningFields) EventSigningFields {
fields.PayloadHash = mustSHA256([]byte("other"))
return fields
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
mutated := BuildEventSigningInput(tt.mutate(base))
assert.False(t, bytes.Equal(baseInput, mutated))
})
}
}
func TestSignAndVerifyEventSignature(t *testing.T) {
t.Parallel()
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
signer, err := NewEd25519ResponseSigner(privateKey)
require.NoError(t, err)
fields := EventSigningFields{
EventType: "gateway.server_time",
EventID: "request-123",
TimestampMS: 123456789,
RequestID: "request-123",
TraceID: "trace-123",
PayloadHash: mustSHA256([]byte("payload")),
}
signature, err := signer.SignEvent(fields)
require.NoError(t, err)
require.NoError(t, VerifyEventSignature(signer.PublicKey(), signature, fields))
fields.TraceID = "changed"
require.ErrorIs(t, VerifyEventSignature(signer.PublicKey(), signature, fields), ErrInvalidEventSignature)
}
+101
View File
@@ -0,0 +1,101 @@
// Package authn defines authenticated transport helpers shared by the gateway
// edge verification pipeline.
package authn
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"errors"
)
const (
// RequestDomainMarkerV1 binds the v1 client request signature to the Galaxy
// gateway transport contract.
RequestDomainMarkerV1 = "galaxy-request-v1"
)
var (
// ErrInvalidPayloadHash reports that payloadHash is not a raw SHA-256 digest.
ErrInvalidPayloadHash = errors.New("payload_hash must be a 32-byte SHA-256 digest")
// ErrPayloadHashMismatch reports that payloadHash does not match payloadBytes.
ErrPayloadHashMismatch = errors.New("payload_hash does not match payload_bytes")
)
// RequestSigningFields contains the canonical v1 request fields that are bound
// into the client signing input after the gateway validates and normalizes the
// request envelope.
type RequestSigningFields struct {
// ProtocolVersion identifies the transport envelope version.
ProtocolVersion string
// DeviceSessionID identifies the authenticated device session bound to the
// request.
DeviceSessionID string
// MessageType is the stable downstream routing key.
MessageType string
// TimestampMS carries the client request timestamp in milliseconds.
TimestampMS int64
// RequestID is the transport correlation and anti-replay identifier.
RequestID string
// PayloadHash is the raw SHA-256 digest of payload bytes.
PayloadHash []byte
}
// BuildRequestSigningInput returns the canonical byte sequence the v1 client
// request signature covers. String and byte fields are length-prefixed with
// uvarint(len(field)) followed by raw bytes, while TimestampMS is appended as
// an 8-byte big-endian uint64. The caller is expected to pass fields that have
// already passed earlier envelope validation.
func BuildRequestSigningInput(fields RequestSigningFields) []byte {
size := len(RequestDomainMarkerV1) +
len(fields.ProtocolVersion) +
len(fields.DeviceSessionID) +
len(fields.MessageType) +
len(fields.RequestID) +
len(fields.PayloadHash) +
(6 * binary.MaxVarintLen64) +
8
buf := make([]byte, 0, size)
buf = appendLengthPrefixedString(buf, RequestDomainMarkerV1)
buf = appendLengthPrefixedString(buf, fields.ProtocolVersion)
buf = appendLengthPrefixedString(buf, fields.DeviceSessionID)
buf = appendLengthPrefixedString(buf, fields.MessageType)
buf = binary.BigEndian.AppendUint64(buf, uint64(fields.TimestampMS))
buf = appendLengthPrefixedString(buf, fields.RequestID)
buf = appendLengthPrefixedBytes(buf, fields.PayloadHash)
return buf
}
// VerifyPayloadHash checks that payloadHash is the raw SHA-256 digest of
// payloadBytes. Empty payloadBytes are valid and must use sha256.Sum256(nil).
func VerifyPayloadHash(payloadBytes, payloadHash []byte) error {
if len(payloadHash) != sha256.Size {
return ErrInvalidPayloadHash
}
sum := sha256.Sum256(payloadBytes)
if !bytes.Equal(sum[:], payloadHash) {
return ErrPayloadHashMismatch
}
return nil
}
func appendLengthPrefixedString(dst []byte, value string) []byte {
return appendLengthPrefixedBytes(dst, []byte(value))
}
func appendLengthPrefixedBytes(dst []byte, value []byte) []byte {
dst = binary.AppendUvarint(dst, uint64(len(value)))
dst = append(dst, value...)
return dst
}
+163
View File
@@ -0,0 +1,163 @@
package authn
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestVerifyPayloadHash(t *testing.T) {
t.Parallel()
payloadSum := sha256.Sum256([]byte("payload"))
emptySum := sha256.Sum256(nil)
otherSum := sha256.Sum256([]byte("other"))
tests := []struct {
name string
payload []byte
payloadHash []byte
wantErr error
}{
{
name: "matches non-empty payload",
payload: []byte("payload"),
payloadHash: payloadSum[:],
},
{
name: "matches empty payload",
payload: nil,
payloadHash: emptySum[:],
},
{
name: "rejects digest with invalid length",
payload: []byte("payload"),
payloadHash: []byte("short"),
wantErr: ErrInvalidPayloadHash,
},
{
name: "rejects digest mismatch",
payload: []byte("payload"),
payloadHash: otherSum[:],
wantErr: ErrPayloadHashMismatch,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := VerifyPayloadHash(tt.payload, tt.payloadHash)
if tt.wantErr == nil {
require.NoError(t, err)
return
}
require.ErrorIs(t, err, tt.wantErr)
})
}
}
func TestBuildRequestSigningInput(t *testing.T) {
t.Parallel()
fields := RequestSigningFields{
ProtocolVersion: "v1",
DeviceSessionID: "device-session-123",
MessageType: "fleet.move",
TimestampMS: 123456789,
RequestID: "request-123",
PayloadHash: mustSHA256([]byte("payload")),
}
got := BuildRequestSigningInput(fields)
want, err := hex.DecodeString("1167616c6178792d726571756573742d7631027631126465766963652d73657373696f6e2d3132330a666c6565742e6d6f766500000000075bcd150b726571756573742d31323320239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5")
require.NoError(t, err)
assert.Equal(t, want, got)
}
func TestBuildRequestSigningInputChangesWhenSignedFieldChanges(t *testing.T) {
t.Parallel()
base := RequestSigningFields{
ProtocolVersion: "v1",
DeviceSessionID: "device-session-123",
MessageType: "fleet.move",
TimestampMS: 123456789,
RequestID: "request-123",
PayloadHash: mustSHA256([]byte("payload")),
}
baseInput := BuildRequestSigningInput(base)
tests := []struct {
name string
mutate func(RequestSigningFields) RequestSigningFields
}{
{
name: "protocol version",
mutate: func(fields RequestSigningFields) RequestSigningFields {
fields.ProtocolVersion = "v2"
return fields
},
},
{
name: "device session id",
mutate: func(fields RequestSigningFields) RequestSigningFields {
fields.DeviceSessionID = "device-session-456"
return fields
},
},
{
name: "message type",
mutate: func(fields RequestSigningFields) RequestSigningFields {
fields.MessageType = "fleet.attack"
return fields
},
},
{
name: "timestamp",
mutate: func(fields RequestSigningFields) RequestSigningFields {
fields.TimestampMS++
return fields
},
},
{
name: "request id",
mutate: func(fields RequestSigningFields) RequestSigningFields {
fields.RequestID = "request-456"
return fields
},
},
{
name: "payload hash",
mutate: func(fields RequestSigningFields) RequestSigningFields {
fields.PayloadHash = mustSHA256([]byte("other"))
return fields
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
mutated := BuildRequestSigningInput(tt.mutate(base))
assert.False(t, bytes.Equal(baseInput, mutated))
})
}
}
func mustSHA256(payload []byte) []byte {
sum := sha256.Sum256(payload)
return sum[:]
}
+189
View File
@@ -0,0 +1,189 @@
package authn
import (
"bytes"
"crypto/ed25519"
"crypto/x509"
"encoding/binary"
"encoding/pem"
"errors"
"fmt"
"os"
)
const (
// ResponseDomainMarkerV1 binds the v1 server response signature to the
// Galaxy gateway transport contract.
ResponseDomainMarkerV1 = "galaxy-response-v1"
)
var (
// ErrInvalidResponsePrivateKeyPEM reports that the configured response
// signer private key is not a strict PKCS#8 PEM-encoded private key.
ErrInvalidResponsePrivateKeyPEM = errors.New("response signer private key is not a valid PKCS#8 PEM block")
// ErrInvalidResponsePrivateKey reports that the configured response signer
// private key is not an Ed25519 private key.
ErrInvalidResponsePrivateKey = errors.New("response signer private key must be an Ed25519 PKCS#8 private key")
// ErrInvalidResponseSignature reports that a server response signature is
// not a raw Ed25519 signature for the canonical response signing input.
ErrInvalidResponseSignature = errors.New("invalid response signature")
)
// ResponseSigningFields contains the canonical v1 response fields that are
// bound into the server signing input.
type ResponseSigningFields struct {
// ProtocolVersion identifies the transport envelope version.
ProtocolVersion string
// RequestID is the transport correlation identifier copied from the
// authenticated request.
RequestID string
// TimestampMS carries the server response timestamp in milliseconds.
TimestampMS int64
// ResultCode is the opaque downstream result code returned to the client.
ResultCode string
// PayloadHash is the raw SHA-256 digest of response payload bytes.
PayloadHash []byte
}
// ResponseSigner signs authenticated unary responses and client-facing stream
// events with one server-side key.
type ResponseSigner interface {
// SignResponse returns the raw Ed25519 signature for the canonical response
// signing input built from fields.
SignResponse(fields ResponseSigningFields) ([]byte, error)
// SignEvent returns the raw Ed25519 signature for the canonical event
// signing input built from fields.
SignEvent(fields EventSigningFields) ([]byte, error)
}
// Ed25519ResponseSigner signs authenticated responses with one Ed25519 private
// key loaded during process startup.
type Ed25519ResponseSigner struct {
privateKey ed25519.PrivateKey
}
// NewEd25519ResponseSigner validates privateKey and constructs a signer using
// a defensive key copy.
func NewEd25519ResponseSigner(privateKey ed25519.PrivateKey) (*Ed25519ResponseSigner, error) {
if len(privateKey) != ed25519.PrivateKeySize {
return nil, ErrInvalidResponsePrivateKey
}
return &Ed25519ResponseSigner{
privateKey: bytes.Clone(privateKey),
}, nil
}
// LoadEd25519ResponseSignerFromPEMFile loads a strict PKCS#8 PEM-encoded
// Ed25519 private key from path and constructs a signer.
func LoadEd25519ResponseSignerFromPEMFile(path string) (*Ed25519ResponseSigner, error) {
pemBytes, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read response signer private key PEM: %w", err)
}
signer, err := ParseEd25519ResponseSignerPEM(pemBytes)
if err != nil {
return nil, err
}
return signer, nil
}
// ParseEd25519ResponseSignerPEM parses one strict PKCS#8 PEM-encoded Ed25519
// private key and constructs a signer from it.
func ParseEd25519ResponseSignerPEM(pemBytes []byte) (*Ed25519ResponseSigner, error) {
block, rest := pem.Decode(pemBytes)
if block == nil || block.Type != "PRIVATE KEY" || len(bytes.TrimSpace(rest)) > 0 {
return nil, ErrInvalidResponsePrivateKeyPEM
}
parsedKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, ErrInvalidResponsePrivateKeyPEM
}
privateKey, ok := parsedKey.(ed25519.PrivateKey)
if !ok {
return nil, ErrInvalidResponsePrivateKey
}
return NewEd25519ResponseSigner(privateKey)
}
// PublicKey returns the Ed25519 public key that corresponds to the configured
// response signer private key.
func (s *Ed25519ResponseSigner) PublicKey() ed25519.PublicKey {
if s == nil {
return nil
}
publicKey, _ := s.privateKey.Public().(ed25519.PublicKey)
return bytes.Clone(publicKey)
}
// SignResponse signs the canonical v1 response signing input built from
// fields.
func (s *Ed25519ResponseSigner) SignResponse(fields ResponseSigningFields) ([]byte, error) {
if s == nil || len(s.privateKey) != ed25519.PrivateKeySize {
return nil, ErrInvalidResponsePrivateKey
}
signature := ed25519.Sign(s.privateKey, BuildResponseSigningInput(fields))
return bytes.Clone(signature), nil
}
// SignEvent signs the canonical v1 stream-event signing input built from
// fields.
func (s *Ed25519ResponseSigner) SignEvent(fields EventSigningFields) ([]byte, error) {
if s == nil || len(s.privateKey) != ed25519.PrivateKeySize {
return nil, ErrInvalidResponsePrivateKey
}
signature := ed25519.Sign(s.privateKey, BuildEventSigningInput(fields))
return bytes.Clone(signature), nil
}
// BuildResponseSigningInput returns the canonical byte sequence the v1 server
// response signature covers. String and byte fields are length-prefixed with
// uvarint(len(field)) followed by raw bytes, while TimestampMS is appended as
// an 8-byte big-endian uint64.
func BuildResponseSigningInput(fields ResponseSigningFields) []byte {
size := len(ResponseDomainMarkerV1) +
len(fields.ProtocolVersion) +
len(fields.RequestID) +
len(fields.ResultCode) +
len(fields.PayloadHash) +
(5 * binary.MaxVarintLen64) +
8
buf := make([]byte, 0, size)
buf = appendLengthPrefixedString(buf, ResponseDomainMarkerV1)
buf = appendLengthPrefixedString(buf, fields.ProtocolVersion)
buf = appendLengthPrefixedString(buf, fields.RequestID)
buf = binary.BigEndian.AppendUint64(buf, uint64(fields.TimestampMS))
buf = appendLengthPrefixedString(buf, fields.ResultCode)
buf = appendLengthPrefixedBytes(buf, fields.PayloadHash)
return buf
}
// VerifyResponseSignature verifies that signature authenticates fields under
// publicKey using the canonical v1 response signing input.
func VerifyResponseSignature(publicKey ed25519.PublicKey, signature []byte, fields ResponseSigningFields) error {
if len(publicKey) != ed25519.PublicKeySize || len(signature) != ed25519.SignatureSize {
return ErrInvalidResponseSignature
}
if !ed25519.Verify(publicKey, BuildResponseSigningInput(fields), signature) {
return ErrInvalidResponseSignature
}
return nil
}
+146
View File
@@ -0,0 +1,146 @@
package authn
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildResponseSigningInputChangesWhenSignedFieldChanges(t *testing.T) {
t.Parallel()
base := ResponseSigningFields{
ProtocolVersion: "v1",
RequestID: "request-123",
TimestampMS: 123456789,
ResultCode: "ok",
PayloadHash: mustSHA256([]byte("payload")),
}
baseInput := BuildResponseSigningInput(base)
tests := []struct {
name string
mutate func(ResponseSigningFields) ResponseSigningFields
}{
{
name: "protocol version",
mutate: func(fields ResponseSigningFields) ResponseSigningFields {
fields.ProtocolVersion = "v2"
return fields
},
},
{
name: "request id",
mutate: func(fields ResponseSigningFields) ResponseSigningFields {
fields.RequestID = "request-456"
return fields
},
},
{
name: "timestamp",
mutate: func(fields ResponseSigningFields) ResponseSigningFields {
fields.TimestampMS++
return fields
},
},
{
name: "result code",
mutate: func(fields ResponseSigningFields) ResponseSigningFields {
fields.ResultCode = "denied"
return fields
},
},
{
name: "payload hash",
mutate: func(fields ResponseSigningFields) ResponseSigningFields {
fields.PayloadHash = mustSHA256([]byte("other"))
return fields
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
mutated := BuildResponseSigningInput(tt.mutate(base))
assert.False(t, bytes.Equal(baseInput, mutated))
})
}
}
func TestParseEd25519ResponseSignerPEMRejectsMalformedPEM(t *testing.T) {
t.Parallel()
_, err := ParseEd25519ResponseSignerPEM([]byte("not-pem"))
require.ErrorIs(t, err, ErrInvalidResponsePrivateKeyPEM)
}
func TestParseEd25519ResponseSignerPEMRejectsNonPKCS8PEM(t *testing.T) {
t.Parallel()
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
pemBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
require.NoError(t, err)
block := pem.Block{
Type: "ED25519 PRIVATE KEY",
Bytes: pemBytes,
}
_, err = ParseEd25519ResponseSignerPEM(pem.EncodeToMemory(&block))
require.ErrorIs(t, err, ErrInvalidResponsePrivateKeyPEM)
}
func TestParseEd25519ResponseSignerPEMRejectsNonEd25519Key(t *testing.T) {
t.Parallel()
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err)
pemBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
require.NoError(t, err)
_, err = ParseEd25519ResponseSignerPEM(pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: pemBytes,
}))
require.ErrorIs(t, err, ErrInvalidResponsePrivateKey)
}
func TestSignAndVerifyResponseSignature(t *testing.T) {
t.Parallel()
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
signer, err := NewEd25519ResponseSigner(privateKey)
require.NoError(t, err)
fields := ResponseSigningFields{
ProtocolVersion: "v1",
RequestID: "request-123",
TimestampMS: 123456789,
ResultCode: "ok",
PayloadHash: mustSHA256([]byte("payload")),
}
signature, err := signer.SignResponse(fields)
require.NoError(t, err)
require.NoError(t, VerifyResponseSignature(signer.PublicKey(), signature, fields))
fields.ResultCode = "changed"
require.ErrorIs(t, VerifyResponseSignature(signer.PublicKey(), signature, fields), ErrInvalidResponseSignature)
}
+47
View File
@@ -0,0 +1,47 @@
package authn
import (
"crypto/ed25519"
"encoding/base64"
"errors"
)
var (
// ErrInvalidClientPublicKey reports that cached client public key material
// is not a base64-encoded raw Ed25519 public key.
ErrInvalidClientPublicKey = errors.New("client_public_key is not a valid base64-encoded Ed25519 public key")
// ErrInvalidRequestSignature reports that a request signature is not a raw
// Ed25519 signature for the canonical request signing input.
ErrInvalidRequestSignature = errors.New("invalid request signature")
)
// VerifyRequestSignature validates the base64-encoded raw Ed25519 public key
// from session cache, builds the canonical v1 signing input from fields, and
// verifies that signature authenticates the request.
func VerifyRequestSignature(clientPublicKey string, signature []byte, fields RequestSigningFields) error {
publicKey, err := decodeClientPublicKey(clientPublicKey)
if err != nil {
return err
}
if len(signature) != ed25519.SignatureSize {
return ErrInvalidRequestSignature
}
if !ed25519.Verify(publicKey, BuildRequestSigningInput(fields), signature) {
return ErrInvalidRequestSignature
}
return nil
}
func decodeClientPublicKey(value string) (ed25519.PublicKey, error) {
decoded, err := base64.StdEncoding.Strict().DecodeString(value)
if err != nil {
return nil, ErrInvalidClientPublicKey
}
if len(decoded) != ed25519.PublicKeySize {
return nil, ErrInvalidClientPublicKey
}
return ed25519.PublicKey(decoded), nil
}
+137
View File
@@ -0,0 +1,137 @@
package authn
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"testing"
"github.com/stretchr/testify/require"
)
func TestVerifyRequestSignature(t *testing.T) {
t.Parallel()
clientPrivateKey := newTestPrivateKey("primary")
clientPublicKey := clientPrivateKey.Public().(ed25519.PublicKey)
otherPrivateKey := newTestPrivateKey("other")
fields := RequestSigningFields{
ProtocolVersion: "v1",
DeviceSessionID: "device-session-123",
MessageType: "fleet.move",
TimestampMS: 123456789,
RequestID: "request-123",
PayloadHash: mustSHA256([]byte("payload")),
}
signature := ed25519.Sign(clientPrivateKey, BuildRequestSigningInput(fields))
tests := []struct {
name string
clientPublicKey string
signature []byte
fields RequestSigningFields
wantErr error
}{
{
name: "valid signature",
clientPublicKey: base64.StdEncoding.EncodeToString(clientPublicKey),
signature: signature,
fields: fields,
},
{
name: "message type change rejects signature",
clientPublicKey: base64.StdEncoding.EncodeToString(clientPublicKey),
signature: signature,
fields: func() RequestSigningFields {
mutated := fields
mutated.MessageType = "fleet.attack"
return mutated
}(),
wantErr: ErrInvalidRequestSignature,
},
{
name: "request id change rejects signature",
clientPublicKey: base64.StdEncoding.EncodeToString(clientPublicKey),
signature: signature,
fields: func() RequestSigningFields {
mutated := fields
mutated.RequestID = "request-456"
return mutated
}(),
wantErr: ErrInvalidRequestSignature,
},
{
name: "payload hash change rejects signature",
clientPublicKey: base64.StdEncoding.EncodeToString(clientPublicKey),
signature: signature,
fields: func() RequestSigningFields {
mutated := fields
mutated.PayloadHash = mustSHA256([]byte("other"))
return mutated
}(),
wantErr: ErrInvalidRequestSignature,
},
{
name: "wrong key rejects signature",
clientPublicKey: base64.StdEncoding.EncodeToString(otherPrivateKey.Public().(ed25519.PublicKey)),
signature: signature,
fields: fields,
wantErr: ErrInvalidRequestSignature,
},
{
name: "bit flipped signature rejects",
clientPublicKey: base64.StdEncoding.EncodeToString(clientPublicKey),
signature: func() []byte {
corrupted := append([]byte(nil), signature...)
corrupted[0] ^= 0xff
return corrupted
}(),
fields: fields,
wantErr: ErrInvalidRequestSignature,
},
{
name: "invalid signature length rejects",
clientPublicKey: base64.StdEncoding.EncodeToString(clientPublicKey),
signature: signature[:len(signature)-1],
fields: fields,
wantErr: ErrInvalidRequestSignature,
},
{
name: "invalid base64 public key rejects",
clientPublicKey: "%%%not-base64%%%",
signature: signature,
fields: fields,
wantErr: ErrInvalidClientPublicKey,
},
{
name: "invalid public key length rejects",
clientPublicKey: base64.StdEncoding.EncodeToString([]byte("short")),
signature: signature,
fields: fields,
wantErr: ErrInvalidClientPublicKey,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := VerifyRequestSignature(tt.clientPublicKey, tt.signature, tt.fields)
if tt.wantErr == nil {
require.NoError(t, err)
return
}
require.ErrorIs(t, err, tt.wantErr)
})
}
}
func newTestPrivateKey(label string) ed25519.PrivateKey {
seed := sha256.Sum256([]byte("gateway-authn-signature-test-" + label))
return ed25519.NewKeyFromSeed(seed[:])
}
+20
View File
@@ -0,0 +1,20 @@
// Package clock provides the gateway time source abstraction used by
// authenticated transport checks.
package clock
import "time"
// Clock returns current server time for freshness checks and time-dependent
// transport behavior.
type Clock interface {
// Now returns the current server time.
Now() time.Time
}
// System returns the current process time using the local system clock.
type System struct{}
// Now returns the current UTC time from the system clock.
func (System) Now() time.Time {
return time.Now().UTC()
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+108
View File
@@ -0,0 +1,108 @@
// Package downstream defines the verified internal command contract used by the
// gateway after the authenticated edge pipeline succeeds.
package downstream
import (
"context"
"errors"
)
var (
// ErrRouteNotFound reports that Router does not have an exact-match handler
// for the supplied authenticated message type.
ErrRouteNotFound = errors.New("downstream route not found")
// ErrDownstreamUnavailable reports that the resolved downstream dependency is
// temporarily unavailable.
ErrDownstreamUnavailable = errors.New("downstream service is unavailable")
)
// AuthenticatedCommand is the minimum verified unary command context the
// gateway may forward to downstream business services.
type AuthenticatedCommand struct {
// ProtocolVersion is the authenticated transport protocol version accepted
// by the gateway.
ProtocolVersion string
// UserID is the authenticated user identity resolved from SessionCache.
UserID string
// DeviceSessionID is the authenticated device session that originated the
// command.
DeviceSessionID string
// MessageType is the stable exact-match downstream routing key.
MessageType string
// TimestampMS is the client-supplied request timestamp that already passed
// freshness verification.
TimestampMS int64
// RequestID is the transport correlation and anti-replay identifier.
RequestID string
// TraceID is the optional client-supplied correlation identifier.
TraceID string
// PayloadBytes carries the verified opaque business payload bytes.
PayloadBytes []byte
}
// UnaryResult is the minimum downstream unary result the gateway needs in
// order to build a signed authenticated client response.
type UnaryResult struct {
// ResultCode is the stable opaque downstream result code returned to the
// client without business reinterpretation by the gateway.
ResultCode string
// PayloadBytes carries the opaque downstream response payload bytes.
PayloadBytes []byte
}
// Client executes a verified authenticated unary command against one concrete
// downstream service or adapter.
type Client interface {
// ExecuteCommand executes command and returns the downstream unary result.
ExecuteCommand(ctx context.Context, command AuthenticatedCommand) (UnaryResult, error)
}
// Router resolves the downstream unary client for one exact authenticated
// message_type value.
type Router interface {
// Route returns the downstream client for messageType. Implementations must
// wrap ErrRouteNotFound when the route table does not contain messageType.
Route(messageType string) (Client, error)
}
// StaticRouter resolves exact message_type literals from an immutable route
// map supplied at construction time.
type StaticRouter struct {
routes map[string]Client
}
// NewStaticRouter constructs a StaticRouter with a defensive copy of routes.
func NewStaticRouter(routes map[string]Client) *StaticRouter {
clonedRoutes := make(map[string]Client, len(routes))
for messageType, client := range routes {
if client == nil {
continue
}
clonedRoutes[messageType] = client
}
return &StaticRouter{routes: clonedRoutes}
}
// Route returns the exact-match client for messageType.
func (r *StaticRouter) Route(messageType string) (Client, error) {
if r == nil {
return nil, ErrRouteNotFound
}
client, ok := r.routes[messageType]
if !ok || client == nil {
return nil, ErrRouteNotFound
}
return client, nil
}
@@ -0,0 +1,39 @@
package downstream
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStaticRouterRoutesExactMessageType(t *testing.T) {
t.Parallel()
want := &stubClient{}
router := NewStaticRouter(map[string]Client{
"fleet.move": want,
})
got, err := router.Route("fleet.move")
require.NoError(t, err)
assert.Same(t, want, got)
}
func TestStaticRouterRejectsUnknownMessageType(t *testing.T) {
t.Parallel()
router := NewStaticRouter(map[string]Client{
"fleet.move": &stubClient{},
})
_, err := router.Route("fleet.rename")
require.ErrorIs(t, err, ErrRouteNotFound)
}
type stubClient struct{}
func (*stubClient) ExecuteCommand(context.Context, AuthenticatedCommand) (UnaryResult, error) {
return UnaryResult{}, nil
}
@@ -0,0 +1,341 @@
package events
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"strings"
"sync"
"time"
"galaxy/gateway/internal/config"
"galaxy/gateway/internal/push"
"galaxy/gateway/internal/telemetry"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/otel/attribute"
"go.uber.org/zap"
)
const clientEventReadCount int64 = 128
// ClientEventPublisher accepts decoded client-facing events from the internal
// event subscriber.
type ClientEventPublisher interface {
// Publish fans out event to the currently active push streams.
Publish(event push.Event)
}
// RedisClientEventSubscriber consumes client-facing events from one Redis
// Stream and forwards them to the configured publisher.
type RedisClientEventSubscriber struct {
client *redis.Client
stream string
pingTimeout time.Duration
readBlockTimeout time.Duration
publisher ClientEventPublisher
logger *zap.Logger
metrics *telemetry.Runtime
closeOnce sync.Once
startedOnce sync.Once
started chan struct{}
}
// NewRedisClientEventSubscriber constructs a Redis Stream subscriber that
// reuses the SessionCache Redis connection settings and forwards decoded
// client-facing events to publisher.
func NewRedisClientEventSubscriber(sessionCfg config.SessionCacheRedisConfig, eventsCfg config.ClientEventsRedisConfig, publisher ClientEventPublisher) (*RedisClientEventSubscriber, error) {
return NewRedisClientEventSubscriberWithObservability(sessionCfg, eventsCfg, publisher, nil, nil)
}
// NewRedisClientEventSubscriberWithObservability constructs a Redis Stream
// subscriber that also records malformed or dropped internal events.
func NewRedisClientEventSubscriberWithObservability(sessionCfg config.SessionCacheRedisConfig, eventsCfg config.ClientEventsRedisConfig, publisher ClientEventPublisher, logger *zap.Logger, metrics *telemetry.Runtime) (*RedisClientEventSubscriber, error) {
if strings.TrimSpace(sessionCfg.Addr) == "" {
return nil, errors.New("new redis client event subscriber: redis addr must not be empty")
}
if sessionCfg.DB < 0 {
return nil, errors.New("new redis client event subscriber: redis db must not be negative")
}
if sessionCfg.LookupTimeout <= 0 {
return nil, errors.New("new redis client event subscriber: lookup timeout must be positive")
}
if strings.TrimSpace(eventsCfg.Stream) == "" {
return nil, errors.New("new redis client event subscriber: stream must not be empty")
}
if eventsCfg.ReadBlockTimeout <= 0 {
return nil, errors.New("new redis client event subscriber: read block timeout must be positive")
}
if publisher == nil {
return nil, errors.New("new redis client event subscriber: nil publisher")
}
options := &redis.Options{
Addr: sessionCfg.Addr,
Username: sessionCfg.Username,
Password: sessionCfg.Password,
DB: sessionCfg.DB,
Protocol: 2,
DisableIdentity: true,
}
if sessionCfg.TLSEnabled {
options.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
}
if logger == nil {
logger = zap.NewNop()
}
return &RedisClientEventSubscriber{
client: redis.NewClient(options),
stream: eventsCfg.Stream,
pingTimeout: sessionCfg.LookupTimeout,
readBlockTimeout: eventsCfg.ReadBlockTimeout,
publisher: publisher,
logger: logger.Named("client_event_subscriber"),
metrics: metrics,
started: make(chan struct{}),
}, nil
}
// Ping verifies that the Redis backend used for client-facing event fan-out is
// reachable within the configured timeout budget.
func (s *RedisClientEventSubscriber) Ping(ctx context.Context) error {
if s == nil || s.client == nil {
return errors.New("ping redis client event subscriber: nil subscriber")
}
if ctx == nil {
return errors.New("ping redis client event subscriber: nil context")
}
pingCtx, cancel := context.WithTimeout(ctx, s.pingTimeout)
defer cancel()
if err := s.client.Ping(pingCtx).Err(); err != nil {
return fmt.Errorf("ping redis client event subscriber: %w", err)
}
return nil
}
// Run consumes client-facing events until ctx is canceled or Redis returns an
// unexpected error.
func (s *RedisClientEventSubscriber) Run(ctx context.Context) error {
if s == nil || s.client == nil {
return errors.New("run redis client event subscriber: nil subscriber")
}
if ctx == nil {
return errors.New("run redis client event subscriber: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
lastID, err := s.resolveStartID(ctx)
if err != nil {
return err
}
s.signalStarted()
for {
streams, err := s.client.XRead(ctx, &redis.XReadArgs{
Streams: []string{s.stream, lastID},
Count: clientEventReadCount,
Block: s.readBlockTimeout,
}).Result()
switch {
case err == nil:
for _, stream := range streams {
for _, message := range stream.Messages {
s.publishMessage(message)
lastID = message.ID
}
}
continue
case errors.Is(err, redis.Nil):
continue
case ctx.Err() != nil && (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, redis.ErrClosed)):
return ctx.Err()
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded), errors.Is(err, redis.ErrClosed):
return fmt.Errorf("run redis client event subscriber: %w", err)
default:
return fmt.Errorf("run redis client event subscriber: %w", err)
}
}
}
func (s *RedisClientEventSubscriber) resolveStartID(ctx context.Context) (string, error) {
messages, err := s.client.XRevRangeN(ctx, s.stream, "+", "-", 1).Result()
switch {
case err == nil:
case errors.Is(err, redis.Nil):
return "0-0", nil
default:
return "", fmt.Errorf("run redis client event subscriber: resolve stream tail: %w", err)
}
if len(messages) == 0 {
return "0-0", nil
}
return messages[0].ID, nil
}
// Shutdown closes the Redis client so a blocking stream read can terminate
// promptly during gateway shutdown.
func (s *RedisClientEventSubscriber) Shutdown(ctx context.Context) error {
if ctx == nil {
return errors.New("shutdown redis client event subscriber: nil context")
}
return s.Close()
}
// Close releases the underlying Redis client resources.
func (s *RedisClientEventSubscriber) Close() error {
if s == nil || s.client == nil {
return nil
}
var err error
s.closeOnce.Do(func() {
err = s.client.Close()
})
return err
}
func (s *RedisClientEventSubscriber) signalStarted() {
s.startedOnce.Do(func() {
close(s.started)
})
}
func (s *RedisClientEventSubscriber) publishMessage(message redis.XMessage) {
event, err := decodeClientEvent(message.Values)
if err != nil {
s.logger.Warn("dropped malformed client event",
zap.String("stream", s.stream),
zap.String("message_id", message.ID),
zap.Error(err),
)
s.metrics.RecordInternalEventDrop(context.Background(),
attribute.String("component", "client_event_subscriber"),
attribute.String("reason", "malformed_event"),
)
return
}
s.publisher.Publish(event)
}
func decodeClientEvent(values map[string]any) (push.Event, error) {
requiredKeys := map[string]struct{}{
"user_id": {},
"event_type": {},
"event_id": {},
"payload_bytes": {},
}
optionalKeys := map[string]struct{}{
"device_session_id": {},
"request_id": {},
"trace_id": {},
}
for key := range values {
if _, ok := requiredKeys[key]; ok {
continue
}
if _, ok := optionalKeys[key]; ok {
continue
}
return push.Event{}, fmt.Errorf("decode client event: unsupported field %q", key)
}
userID, err := requiredStringField(values, "user_id")
if err != nil {
return push.Event{}, err
}
eventType, err := requiredStringField(values, "event_type")
if err != nil {
return push.Event{}, err
}
eventID, err := requiredStringField(values, "event_id")
if err != nil {
return push.Event{}, err
}
payloadBytes, err := requiredBytesField(values, "payload_bytes")
if err != nil {
return push.Event{}, err
}
event := push.Event{
UserID: userID,
EventType: eventType,
EventID: eventID,
PayloadBytes: payloadBytes,
}
if deviceSessionID, ok, err := optionalStringField(values, "device_session_id"); err != nil {
return push.Event{}, err
} else if ok {
event.DeviceSessionID = strings.TrimSpace(deviceSessionID)
}
if requestID, ok, err := optionalStringField(values, "request_id"); err != nil {
return push.Event{}, err
} else if ok {
event.RequestID = requestID
}
if traceID, ok, err := optionalStringField(values, "trace_id"); err != nil {
return push.Event{}, err
} else if ok {
event.TraceID = traceID
}
return event, nil
}
func requiredBytesField(values map[string]any, field string) ([]byte, error) {
value, ok := values[field]
if !ok {
return nil, fmt.Errorf("decode client event: missing %s", field)
}
byteValue, err := coerceBytes(value)
if err != nil {
return nil, fmt.Errorf("decode client event: %s: %w", field, err)
}
return byteValue, nil
}
func optionalStringField(values map[string]any, field string) (string, bool, error) {
value, ok := values[field]
if !ok {
return "", false, nil
}
stringValue, err := coerceString(value)
if err != nil {
return "", false, fmt.Errorf("decode client event: %s: %w", field, err)
}
return stringValue, true, nil
}
func coerceBytes(value any) ([]byte, error) {
switch typed := value.(type) {
case string:
return []byte(typed), nil
case []byte:
return bytes.Clone(typed), nil
default:
return nil, fmt.Errorf("unsupported type %T", value)
}
}
@@ -0,0 +1,294 @@
package events
import (
"context"
"strings"
"sync"
"testing"
"time"
"galaxy/gateway/internal/config"
"galaxy/gateway/internal/push"
"galaxy/gateway/internal/testutil"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRedisClientEventSubscriberPublishesValidEvent(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
publisher := &recordingClientEventPublisher{}
subscriber := newTestRedisClientEventSubscriber(t, server, publisher)
running := runTestClientEventSubscriber(t, subscriber)
defer running.stop(t)
addClientEvent(t, server, "gateway:client_events", map[string]any{
"user_id": "user-123",
"device_session_id": "device-session-123",
"event_type": "fleet.updated",
"event_id": "event-123",
"payload_bytes": []byte("payload-123"),
"request_id": "request-123",
"trace_id": "trace-123",
})
require.Eventually(t, func() bool {
return len(publisher.events()) == 1
}, time.Second, 10*time.Millisecond)
assert.Equal(t, []push.Event{{
UserID: "user-123",
DeviceSessionID: "device-session-123",
EventType: "fleet.updated",
EventID: "event-123",
PayloadBytes: []byte("payload-123"),
RequestID: "request-123",
TraceID: "trace-123",
}}, publisher.events())
}
func TestRedisClientEventSubscriberSkipsMalformedEventAndContinues(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
publisher := &recordingClientEventPublisher{}
subscriber := newTestRedisClientEventSubscriber(t, server, publisher)
running := runTestClientEventSubscriber(t, subscriber)
defer running.stop(t)
addClientEvent(t, server, "gateway:client_events", map[string]any{
"user_id": "user-123",
"event_type": "fleet.updated",
"event_id": "event-bad",
"payload_bytes": []byte("payload-bad"),
"unexpected": "boom",
})
addClientEvent(t, server, "gateway:client_events", map[string]any{
"user_id": "user-123",
"event_type": "fleet.updated",
"event_id": "event-good",
"payload_bytes": []byte("payload-good"),
})
require.Eventually(t, func() bool {
events := publisher.events()
return len(events) == 1 && events[0].EventID == "event-good"
}, time.Second, 10*time.Millisecond)
}
func TestRedisClientEventSubscriberStartsFromCurrentTail(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
publisher := &recordingClientEventPublisher{}
addClientEvent(t, server, "gateway:client_events", map[string]any{
"user_id": "user-123",
"event_type": "fleet.updated",
"event_id": "event-old",
"payload_bytes": []byte("payload-old"),
})
subscriber := newTestRedisClientEventSubscriber(t, server, publisher)
running := runTestClientEventSubscriber(t, subscriber)
defer running.stop(t)
assert.Never(t, func() bool {
return len(publisher.events()) > 0
}, 100*time.Millisecond, 10*time.Millisecond)
addClientEvent(t, server, "gateway:client_events", map[string]any{
"user_id": "user-123",
"event_type": "fleet.updated",
"event_id": "event-new",
"payload_bytes": []byte("payload-new"),
})
require.Eventually(t, func() bool {
events := publisher.events()
return len(events) == 1 && events[0].EventID == "event-new"
}, time.Second, 10*time.Millisecond)
}
func TestRedisClientEventSubscriberShutdownInterruptsBlockingRead(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
publisher := &recordingClientEventPublisher{}
subscriber := newTestRedisClientEventSubscriber(t, server, publisher)
ctx, cancel := context.WithCancel(context.Background())
resultCh := make(chan error, 1)
go func() {
resultCh <- subscriber.Run(ctx)
}()
select {
case <-subscriber.started:
case <-time.After(time.Second):
require.FailNow(t, "subscriber did not start")
}
cancel()
require.NoError(t, subscriber.Shutdown(context.Background()))
select {
case err := <-resultCh:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(time.Second):
require.FailNow(t, "subscriber did not stop after shutdown")
}
}
func TestRedisClientEventSubscriberLogsAndCountsMalformedEvents(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
publisher := &recordingClientEventPublisher{}
logger, logBuffer := testutil.NewObservedLogger(t)
telemetryRuntime := testutil.NewTelemetryRuntime(t, logger)
subscriber, err := NewRedisClientEventSubscriberWithObservability(
config.SessionCacheRedisConfig{
Addr: server.Addr(),
LookupTimeout: 250 * time.Millisecond,
},
config.ClientEventsRedisConfig{
Stream: "gateway:client_events",
ReadBlockTimeout: 25 * time.Millisecond,
},
publisher,
logger,
telemetryRuntime,
)
require.NoError(t, err)
t.Cleanup(func() {
assert.NoError(t, subscriber.Close())
})
running := runTestClientEventSubscriber(t, subscriber)
defer running.stop(t)
addClientEvent(t, server, "gateway:client_events", map[string]any{
"user_id": "user-123",
"event_type": "fleet.updated",
"event_id": "event-bad",
"payload_bytes": []byte("payload-bad"),
"unexpected": "boom",
})
require.Eventually(t, func() bool {
return strings.Contains(logBuffer.String(), "dropped malformed client event")
}, time.Second, 10*time.Millisecond)
metricsText := testutil.ScrapeMetrics(t, telemetryRuntime.Handler())
assert.Contains(t, metricsText, `gateway_internal_event_drops_total`)
assert.Contains(t, metricsText, `component="client_event_subscriber"`)
assert.Contains(t, metricsText, `reason="malformed_event"`)
}
func newTestRedisClientEventSubscriber(t *testing.T, server *miniredis.Miniredis, publisher ClientEventPublisher) *RedisClientEventSubscriber {
t.Helper()
subscriber, err := NewRedisClientEventSubscriber(
config.SessionCacheRedisConfig{
Addr: server.Addr(),
LookupTimeout: 250 * time.Millisecond,
},
config.ClientEventsRedisConfig{
Stream: "gateway:client_events",
ReadBlockTimeout: 25 * time.Millisecond,
},
publisher,
)
require.NoError(t, err)
t.Cleanup(func() {
assert.NoError(t, subscriber.Close())
})
return subscriber
}
func addClientEvent(t *testing.T, server *miniredis.Miniredis, stream string, values map[string]any) {
t.Helper()
client := redis.NewClient(&redis.Options{
Addr: server.Addr(),
Protocol: 2,
DisableIdentity: true,
})
defer func() {
assert.NoError(t, client.Close())
}()
err := client.XAdd(context.Background(), &redis.XAddArgs{
Stream: stream,
Values: values,
}).Err()
require.NoError(t, err)
}
type runningClientEventSubscriber struct {
cancel context.CancelFunc
resultCh chan error
}
func runTestClientEventSubscriber(t *testing.T, subscriber *RedisClientEventSubscriber) runningClientEventSubscriber {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
resultCh := make(chan error, 1)
go func() {
resultCh <- subscriber.Run(ctx)
}()
select {
case <-subscriber.started:
case <-time.After(time.Second):
require.FailNow(t, "subscriber did not start")
}
return runningClientEventSubscriber{
cancel: cancel,
resultCh: resultCh,
}
}
func (r runningClientEventSubscriber) stop(t *testing.T) {
t.Helper()
r.cancel()
select {
case err := <-r.resultCh:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(time.Second):
require.FailNow(t, "subscriber did not stop")
}
}
type recordingClientEventPublisher struct {
mu sync.Mutex
records []push.Event
}
func (p *recordingClientEventPublisher) Publish(event push.Event) {
p.mu.Lock()
defer p.mu.Unlock()
p.records = append(p.records, event)
}
func (p *recordingClientEventPublisher) events() []push.Event {
p.mu.Lock()
defer p.mu.Unlock()
cloned := make([]push.Event, len(p.records))
copy(cloned, p.records)
return cloned
}
@@ -0,0 +1,385 @@
package events
import (
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"errors"
"net"
"sync"
"testing"
"time"
"galaxy/gateway/internal/app"
"galaxy/gateway/internal/authn"
"galaxy/gateway/internal/clock"
"galaxy/gateway/internal/config"
"galaxy/gateway/internal/downstream"
"galaxy/gateway/internal/grpcapi"
"galaxy/gateway/internal/replay"
"galaxy/gateway/internal/session"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
)
var testNow = time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC)
func TestAuthenticatedGatewayWarmsLocalSessionCache(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
local := session.NewMemoryCache()
fallback := &countingSessionCache{
records: map[string]session.Record{
"device-session-123": newActiveSessionRecord("user-123"),
},
}
readThrough, err := session.NewReadThroughCache(local, fallback)
require.NoError(t, err)
subscriber := newTestRedisSessionSubscriber(t, server, local)
downstreamClient := &recordingDownstreamClient{}
addr, running := runAuthenticatedGateway(t, readThrough, subscriber, downstreamClient)
defer running.stop(t)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err = client.ExecuteCommand(context.Background(), newExecuteCommandRequest("request-1"))
require.NoError(t, err)
assert.Equal(t, 1, fallback.lookupCalls())
_, err = client.ExecuteCommand(context.Background(), newExecuteCommandRequest("request-2"))
require.NoError(t, err)
assert.Equal(t, 1, fallback.lookupCalls())
assert.Len(t, downstreamClient.commands(), 2)
}
func TestAuthenticatedGatewayUsesSessionUpdateEventWithoutFallbackLookup(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
local := session.NewMemoryCache()
fallback := &countingSessionCache{
records: map[string]session.Record{
"device-session-123": newActiveSessionRecord("user-123"),
},
}
readThrough, err := session.NewReadThroughCache(local, fallback)
require.NoError(t, err)
subscriber := newTestRedisSessionSubscriber(t, server, local)
downstreamClient := &recordingDownstreamClient{}
addr, running := runAuthenticatedGateway(t, readThrough, subscriber, downstreamClient)
defer running.stop(t)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err = client.ExecuteCommand(context.Background(), newExecuteCommandRequest("request-1"))
require.NoError(t, err)
assert.Equal(t, 1, fallback.lookupCalls())
addSessionEvent(t, server, "gateway:session_events", map[string]string{
"device_session_id": "device-session-123",
"user_id": "user-456",
"client_public_key": testClientPublicKeyBase64(),
"status": string(session.StatusActive),
})
require.Eventually(t, func() bool {
record, lookupErr := local.Lookup(context.Background(), "device-session-123")
return lookupErr == nil && record.UserID == "user-456"
}, time.Second, 10*time.Millisecond)
_, err = client.ExecuteCommand(context.Background(), newExecuteCommandRequest("request-2"))
require.NoError(t, err)
assert.Equal(t, 1, fallback.lookupCalls())
commands := downstreamClient.commands()
require.Len(t, commands, 2)
assert.Equal(t, "user-456", commands[1].UserID)
}
func TestAuthenticatedGatewayRejectsRevokedSessionAfterEventWithoutFallbackLookup(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
local := session.NewMemoryCache()
fallback := &countingSessionCache{
records: map[string]session.Record{
"device-session-123": newActiveSessionRecord("user-123"),
},
}
readThrough, err := session.NewReadThroughCache(local, fallback)
require.NoError(t, err)
subscriber := newTestRedisSessionSubscriber(t, server, local)
downstreamClient := &recordingDownstreamClient{}
addr, running := runAuthenticatedGateway(t, readThrough, subscriber, downstreamClient)
defer running.stop(t)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err = client.ExecuteCommand(context.Background(), newExecuteCommandRequest("request-1"))
require.NoError(t, err)
assert.Equal(t, 1, fallback.lookupCalls())
addSessionEvent(t, server, "gateway:session_events", map[string]string{
"device_session_id": "device-session-123",
"user_id": "user-123",
"client_public_key": testClientPublicKeyBase64(),
"status": string(session.StatusRevoked),
"revoked_at_ms": "123456789",
})
require.Eventually(t, func() bool {
record, lookupErr := local.Lookup(context.Background(), "device-session-123")
return lookupErr == nil && record.Status == session.StatusRevoked
}, time.Second, 10*time.Millisecond)
_, err = client.ExecuteCommand(context.Background(), newExecuteCommandRequest("request-2"))
require.Error(t, err)
assert.Equal(t, codes.FailedPrecondition, status.Code(err))
assert.Equal(t, "device session is revoked", status.Convert(err).Message())
assert.Equal(t, 1, fallback.lookupCalls())
}
type runningAuthenticatedGateway struct {
cancel context.CancelFunc
resultCh chan error
}
func runAuthenticatedGateway(t *testing.T, sessionCache session.Cache, subscriber *RedisSessionSubscriber, downstreamClient downstream.Client) (string, runningAuthenticatedGateway) {
t.Helper()
addr := unusedTCPAddr(t)
grpcCfg := config.DefaultAuthenticatedGRPCConfig()
grpcCfg.Addr = addr
grpcCfg.FreshnessWindow = 5 * time.Minute
router := downstream.NewStaticRouter(map[string]downstream.Client{
"fleet.move": downstreamClient,
})
gateway := grpcapi.NewServer(grpcCfg, grpcapi.ServerDependencies{
Router: router,
ResponseSigner: newTestResponseSigner(t),
SessionCache: sessionCache,
ReplayStore: staticReplayStore{},
Clock: fixedClock{now: testNow},
})
application := app.New(
config.Config{
ShutdownTimeout: time.Second,
AuthenticatedGRPC: grpcCfg,
},
gateway,
subscriber,
)
ctx, cancel := context.WithCancel(context.Background())
resultCh := make(chan error, 1)
go func() {
resultCh <- application.Run(ctx)
}()
select {
case <-subscriber.started:
case <-time.After(time.Second):
require.FailNow(t, "session subscriber did not start")
}
return addr, runningAuthenticatedGateway{
cancel: cancel,
resultCh: resultCh,
}
}
func (g runningAuthenticatedGateway) stop(t *testing.T) {
t.Helper()
g.cancel()
select {
case err := <-g.resultCh:
require.NoError(t, err)
case <-time.After(2 * time.Second):
require.FailNow(t, "gateway did not stop after cancellation")
}
}
func dialGatewayClient(t *testing.T, addr string) *grpc.ClientConn {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
conn, err := grpc.DialContext(
ctx,
addr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
require.NoError(t, err)
return conn
}
func unusedTCPAddr(t *testing.T) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
addr := listener.Addr().String()
require.NoError(t, listener.Close())
return addr
}
func newExecuteCommandRequest(requestID string) *gatewayv1.ExecuteCommandRequest {
payloadBytes := []byte("payload")
payloadHash := sha256.Sum256(payloadBytes)
req := &gatewayv1.ExecuteCommandRequest{
ProtocolVersion: "v1",
DeviceSessionId: "device-session-123",
MessageType: "fleet.move",
TimestampMs: testNow.UnixMilli(),
RequestId: requestID,
PayloadBytes: payloadBytes,
PayloadHash: payloadHash[:],
TraceId: "trace-123",
}
req.Signature = ed25519.Sign(testClientPrivateKey(), authn.BuildRequestSigningInput(authn.RequestSigningFields{
ProtocolVersion: req.GetProtocolVersion(),
DeviceSessionID: req.GetDeviceSessionId(),
MessageType: req.GetMessageType(),
TimestampMS: req.GetTimestampMs(),
RequestID: req.GetRequestId(),
PayloadHash: req.GetPayloadHash(),
}))
return req
}
func newActiveSessionRecord(userID string) session.Record {
return session.Record{
DeviceSessionID: "device-session-123",
UserID: userID,
ClientPublicKey: testClientPublicKeyBase64(),
Status: session.StatusActive,
}
}
func testClientPrivateKey() ed25519.PrivateKey {
seed := sha256.Sum256([]byte("gateway-events-grpc-test-client"))
return ed25519.NewKeyFromSeed(seed[:])
}
func testClientPublicKeyBase64() string {
return base64.StdEncoding.EncodeToString(testClientPrivateKey().Public().(ed25519.PublicKey))
}
func newTestResponseSigner(t *testing.T) authn.ResponseSigner {
t.Helper()
seed := sha256.Sum256([]byte("gateway-events-grpc-test-response"))
signer, err := authn.NewEd25519ResponseSigner(ed25519.NewKeyFromSeed(seed[:]))
require.NoError(t, err)
return signer
}
type fixedClock struct {
now time.Time
}
func (c fixedClock) Now() time.Time {
return c.now
}
var _ clock.Clock = fixedClock{}
type staticReplayStore struct{}
func (staticReplayStore) Reserve(context.Context, string, string, time.Duration) error {
return nil
}
var _ replay.Store = staticReplayStore{}
type countingSessionCache struct {
mu sync.Mutex
records map[string]session.Record
lookupCount int
}
func (c *countingSessionCache) Lookup(context.Context, string) (session.Record, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.lookupCount++
record, ok := c.records["device-session-123"]
if !ok {
return session.Record{}, errors.New("lookup session from counting cache: session cache record not found")
}
return record, nil
}
func (c *countingSessionCache) lookupCalls() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.lookupCount
}
type recordingDownstreamClient struct {
mu sync.Mutex
captured []downstream.AuthenticatedCommand
}
func (c *recordingDownstreamClient) ExecuteCommand(_ context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
c.mu.Lock()
c.captured = append(c.captured, command)
c.mu.Unlock()
return downstream.UnaryResult{
ResultCode: "ok",
PayloadBytes: []byte("response"),
}, nil
}
func (c *recordingDownstreamClient) commands() []downstream.AuthenticatedCommand {
c.mu.Lock()
defer c.mu.Unlock()
cloned := make([]downstream.AuthenticatedCommand, len(c.captured))
copy(cloned, c.captured)
return cloned
}
@@ -0,0 +1,416 @@
package events
import (
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"testing"
"time"
"galaxy/gateway/internal/app"
"galaxy/gateway/internal/authn"
"galaxy/gateway/internal/config"
"galaxy/gateway/internal/grpcapi"
"galaxy/gateway/internal/push"
"galaxy/gateway/internal/session"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestSubscribeEventsFanOutsUserTargetedEventToAllUserSessions(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
sessionCache := session.NewMemoryCache()
require.NoError(t, sessionCache.Upsert(newPushActiveSessionRecord("device-session-1", "user-123")))
require.NoError(t, sessionCache.Upsert(newPushActiveSessionRecord("device-session-2", "user-123")))
require.NoError(t, sessionCache.Upsert(newPushActiveSessionRecord("device-session-3", "user-999")))
pushHub := push.NewHub(4)
clientSubscriber := newTestRedisClientEventSubscriber(t, server, pushHub)
addr, running := runPushGateway(t, sessionCache, pushHub, clientSubscriber)
defer running.stop(t)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
targetOneCtx, cancelTargetOne := context.WithCancel(context.Background())
defer cancelTargetOne()
targetOne, err := client.SubscribeEvents(targetOneCtx, newPushSubscribeEventsRequest("device-session-1", "request-1"))
require.NoError(t, err)
assertPushBootstrapEvent(t, recvPushEvent(t, targetOne), "request-1", "trace-device-session-1")
targetTwoCtx, cancelTargetTwo := context.WithCancel(context.Background())
defer cancelTargetTwo()
targetTwo, err := client.SubscribeEvents(targetTwoCtx, newPushSubscribeEventsRequest("device-session-2", "request-2"))
require.NoError(t, err)
assertPushBootstrapEvent(t, recvPushEvent(t, targetTwo), "request-2", "trace-device-session-2")
unrelatedCtx, cancelUnrelated := context.WithCancel(context.Background())
defer cancelUnrelated()
unrelated, err := client.SubscribeEvents(unrelatedCtx, newPushSubscribeEventsRequest("device-session-3", "request-3"))
require.NoError(t, err)
assertPushBootstrapEvent(t, recvPushEvent(t, unrelated), "request-3", "trace-device-session-3")
addClientEvent(t, server, "gateway:client_events", map[string]any{
"user_id": "user-123",
"event_type": "fleet.updated",
"event_id": "event-123",
"payload_bytes": []byte("payload-123"),
"request_id": "request-123",
"trace_id": "trace-123",
})
assertSignedPushEvent(t, recvPushEvent(t, targetOne), push.Event{
UserID: "user-123",
EventType: "fleet.updated",
EventID: "event-123",
PayloadBytes: []byte("payload-123"),
RequestID: "request-123",
TraceID: "trace-123",
})
assertSignedPushEvent(t, recvPushEvent(t, targetTwo), push.Event{
UserID: "user-123",
EventType: "fleet.updated",
EventID: "event-123",
PayloadBytes: []byte("payload-123"),
RequestID: "request-123",
TraceID: "trace-123",
})
assertNoPushEvent(t, unrelated, cancelUnrelated)
}
func TestSubscribeEventsFanOutsSessionTargetedEventOnlyToMatchingSession(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
sessionCache := session.NewMemoryCache()
require.NoError(t, sessionCache.Upsert(newPushActiveSessionRecord("device-session-1", "user-123")))
require.NoError(t, sessionCache.Upsert(newPushActiveSessionRecord("device-session-2", "user-123")))
pushHub := push.NewHub(4)
clientSubscriber := newTestRedisClientEventSubscriber(t, server, pushHub)
addr, running := runPushGateway(t, sessionCache, pushHub, clientSubscriber)
defer running.stop(t)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
otherCtx, cancelOther := context.WithCancel(context.Background())
defer cancelOther()
otherStream, err := client.SubscribeEvents(otherCtx, newPushSubscribeEventsRequest("device-session-1", "request-1"))
require.NoError(t, err)
assertPushBootstrapEvent(t, recvPushEvent(t, otherStream), "request-1", "trace-device-session-1")
targetCtx, cancelTarget := context.WithCancel(context.Background())
defer cancelTarget()
targetStream, err := client.SubscribeEvents(targetCtx, newPushSubscribeEventsRequest("device-session-2", "request-2"))
require.NoError(t, err)
assertPushBootstrapEvent(t, recvPushEvent(t, targetStream), "request-2", "trace-device-session-2")
addClientEvent(t, server, "gateway:client_events", map[string]any{
"user_id": "user-123",
"device_session_id": "device-session-2",
"event_type": "fleet.updated",
"event_id": "event-456",
"payload_bytes": []byte("payload-456"),
})
assertSignedPushEvent(t, recvPushEvent(t, targetStream), push.Event{
UserID: "user-123",
DeviceSessionID: "device-session-2",
EventType: "fleet.updated",
EventID: "event-456",
PayloadBytes: []byte("payload-456"),
})
assertNoPushEvent(t, otherStream, cancelOther)
}
func TestSubscribeEventsClosesRevokedSessionStreamAndRejectsReopen(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
sessionCache := session.NewMemoryCache()
require.NoError(t, sessionCache.Upsert(newPushActiveSessionRecord("device-session-1", "user-123")))
pushHub := push.NewHub(4)
clientSubscriber := newTestRedisClientEventSubscriber(t, server, pushHub)
sessionSubscriber := newTestRedisSessionSubscriberWithRevocationHandler(t, server, sessionCache, pushHub)
addr, running := runPushGateway(t, sessionCache, pushHub, clientSubscriber, sessionSubscriber)
defer running.stop(t)
select {
case <-sessionSubscriber.started:
case <-time.After(time.Second):
require.FailNow(t, "session subscriber did not start")
}
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
streamCtx, cancelStream := context.WithCancel(context.Background())
defer cancelStream()
stream, err := client.SubscribeEvents(streamCtx, newPushSubscribeEventsRequest("device-session-1", "request-1"))
require.NoError(t, err)
assertPushBootstrapEvent(t, recvPushEvent(t, stream), "request-1", "trace-device-session-1")
addSessionEvent(t, server, "gateway:session_events", map[string]string{
"device_session_id": "device-session-1",
"user_id": "user-123",
"client_public_key": pushClientPublicKeyBase64(),
"status": string(session.StatusRevoked),
"revoked_at_ms": "123456789",
})
require.Eventually(t, func() bool {
record, lookupErr := sessionCache.Lookup(context.Background(), "device-session-1")
return lookupErr == nil && record.Status == session.StatusRevoked
}, time.Second, 10*time.Millisecond)
recvErrCh := make(chan error, 1)
go func() {
_, recvErr := stream.Recv()
recvErrCh <- recvErr
}()
select {
case recvErr := <-recvErrCh:
require.Error(t, recvErr)
assert.Equal(t, codes.FailedPrecondition, status.Code(recvErr))
assert.Equal(t, "device session is revoked", status.Convert(recvErr).Message())
case <-time.After(time.Second):
require.FailNow(t, "stream did not close after revoke")
}
reopened, err := client.SubscribeEvents(context.Background(), newPushSubscribeEventsRequest("device-session-1", "request-2"))
if err == nil {
_, err = reopened.Recv()
}
require.Error(t, err)
assert.Equal(t, codes.FailedPrecondition, status.Code(err))
assert.Equal(t, "device session is revoked", status.Convert(err).Message())
}
func TestSubscribeEventsClosesActiveStreamWhenGatewayShutsDown(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
sessionCache := session.NewMemoryCache()
require.NoError(t, sessionCache.Upsert(newPushActiveSessionRecord("device-session-1", "user-123")))
pushHub := push.NewHub(4)
clientSubscriber := newTestRedisClientEventSubscriber(t, server, pushHub)
addr, running := runPushGateway(t, sessionCache, pushHub, clientSubscriber)
defer running.stop(t)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
stream, err := client.SubscribeEvents(context.Background(), newPushSubscribeEventsRequest("device-session-1", "request-1"))
require.NoError(t, err)
assertPushBootstrapEvent(t, recvPushEvent(t, stream), "request-1", "trace-device-session-1")
recvErrCh := make(chan error, 1)
go func() {
_, recvErr := stream.Recv()
recvErrCh <- recvErr
}()
running.cancel()
select {
case recvErr := <-recvErrCh:
require.Error(t, recvErr)
assert.Equal(t, codes.Unavailable, status.Code(recvErr))
assert.Equal(t, "gateway is shutting down", status.Convert(recvErr).Message())
case <-time.After(time.Second):
require.FailNow(t, "stream did not close after gateway shutdown")
}
}
func runPushGateway(t *testing.T, sessionCache session.Cache, pushHub *push.Hub, clientSubscriber *RedisClientEventSubscriber, extraComponents ...app.Component) (string, runningAuthenticatedGateway) {
t.Helper()
addr := unusedTCPAddr(t)
grpcCfg := config.DefaultAuthenticatedGRPCConfig()
grpcCfg.Addr = addr
grpcCfg.FreshnessWindow = 5 * time.Minute
responseSigner := newTestResponseSigner(t)
gateway := grpcapi.NewServer(grpcCfg, grpcapi.ServerDependencies{
Service: grpcapi.NewFanOutPushStreamService(pushHub, responseSigner, fixedClock{now: testNow}, zap.NewNop()),
ResponseSigner: responseSigner,
SessionCache: sessionCache,
ReplayStore: staticReplayStore{},
Clock: fixedClock{now: testNow},
PushHub: pushHub,
})
components := []app.Component{gateway, clientSubscriber}
components = append(components, extraComponents...)
application := app.New(
config.Config{
ShutdownTimeout: time.Second,
AuthenticatedGRPC: grpcCfg,
},
components...,
)
ctx, cancel := context.WithCancel(context.Background())
resultCh := make(chan error, 1)
go func() {
resultCh <- application.Run(ctx)
}()
select {
case <-clientSubscriber.started:
case <-time.After(time.Second):
require.FailNow(t, "client event subscriber did not start")
}
return addr, runningAuthenticatedGateway{
cancel: cancel,
resultCh: resultCh,
}
}
func newPushActiveSessionRecord(deviceSessionID string, userID string) session.Record {
return session.Record{
DeviceSessionID: deviceSessionID,
UserID: userID,
ClientPublicKey: pushClientPublicKeyBase64(),
Status: session.StatusActive,
}
}
func newPushSubscribeEventsRequest(deviceSessionID string, requestID string) *gatewayv1.SubscribeEventsRequest {
payloadHash := sha256.Sum256(nil)
traceID := "trace-" + deviceSessionID
req := &gatewayv1.SubscribeEventsRequest{
ProtocolVersion: "v1",
DeviceSessionId: deviceSessionID,
MessageType: "gateway.subscribe",
TimestampMs: testNow.UnixMilli(),
RequestId: requestID,
PayloadHash: payloadHash[:],
TraceId: traceID,
}
req.Signature = ed25519.Sign(pushClientPrivateKey(), authn.BuildRequestSigningInput(authn.RequestSigningFields{
ProtocolVersion: req.GetProtocolVersion(),
DeviceSessionID: req.GetDeviceSessionId(),
MessageType: req.GetMessageType(),
TimestampMS: req.GetTimestampMs(),
RequestID: req.GetRequestId(),
PayloadHash: req.GetPayloadHash(),
}))
return req
}
func recvPushEvent(t *testing.T, stream grpc.ServerStreamingClient[gatewayv1.GatewayEvent]) *gatewayv1.GatewayEvent {
t.Helper()
event, err := stream.Recv()
require.NoError(t, err)
return event
}
func assertPushBootstrapEvent(t *testing.T, event *gatewayv1.GatewayEvent, wantRequestID string, wantTraceID string) {
t.Helper()
require.NotNil(t, event)
assert.Equal(t, "gateway.server_time", event.GetEventType())
assert.Equal(t, wantRequestID, event.GetEventId())
assert.Equal(t, wantRequestID, event.GetRequestId())
assert.Equal(t, wantTraceID, event.GetTraceId())
require.NoError(t, authn.VerifyPayloadHash(event.GetPayloadBytes(), event.GetPayloadHash()))
require.NoError(t, authn.VerifyEventSignature(pushResponseSignerPublicKey(), event.GetSignature(), authn.EventSigningFields{
EventType: event.GetEventType(),
EventID: event.GetEventId(),
TimestampMS: event.GetTimestampMs(),
RequestID: event.GetRequestId(),
TraceID: event.GetTraceId(),
PayloadHash: event.GetPayloadHash(),
}))
}
func assertSignedPushEvent(t *testing.T, event *gatewayv1.GatewayEvent, want push.Event) {
t.Helper()
require.NotNil(t, event)
assert.Equal(t, want.EventType, event.GetEventType())
assert.Equal(t, want.EventID, event.GetEventId())
assert.Equal(t, want.RequestID, event.GetRequestId())
assert.Equal(t, want.TraceID, event.GetTraceId())
assert.Equal(t, want.PayloadBytes, event.GetPayloadBytes())
require.NoError(t, authn.VerifyPayloadHash(event.GetPayloadBytes(), event.GetPayloadHash()))
require.NoError(t, authn.VerifyEventSignature(pushResponseSignerPublicKey(), event.GetSignature(), authn.EventSigningFields{
EventType: event.GetEventType(),
EventID: event.GetEventId(),
TimestampMS: event.GetTimestampMs(),
RequestID: event.GetRequestId(),
TraceID: event.GetTraceId(),
PayloadHash: event.GetPayloadHash(),
}))
}
func assertNoPushEvent(t *testing.T, stream grpc.ServerStreamingClient[gatewayv1.GatewayEvent], cancel context.CancelFunc) {
t.Helper()
recvCh := make(chan *gatewayv1.GatewayEvent, 1)
errCh := make(chan error, 1)
go func() {
event, err := stream.Recv()
if err != nil {
errCh <- err
return
}
recvCh <- event
}()
select {
case event := <-recvCh:
require.FailNowf(t, "unexpected push event delivered", "%+v", event)
case <-time.After(100 * time.Millisecond):
cancel()
case err := <-errCh:
require.FailNowf(t, "stream closed unexpectedly", "%v", err)
}
}
func pushClientPrivateKey() ed25519.PrivateKey {
seed := sha256.Sum256([]byte("gateway-push-grpc-test-client"))
return ed25519.NewKeyFromSeed(seed[:])
}
func pushClientPublicKeyBase64() string {
return base64.StdEncoding.EncodeToString(pushClientPrivateKey().Public().(ed25519.PublicKey))
}
func pushResponseSignerPublicKey() ed25519.PublicKey {
seed := sha256.Sum256([]byte("gateway-events-grpc-test-response"))
return ed25519.NewKeyFromSeed(seed[:]).Public().(ed25519.PublicKey)
}
+389
View File
@@ -0,0 +1,389 @@
// Package events subscribes to internal session lifecycle streams used to keep
// the gateway hot-path session cache synchronized without per-request upstream
// lookups.
package events
import (
"context"
"crypto/tls"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"galaxy/gateway/internal/config"
"galaxy/gateway/internal/session"
"galaxy/gateway/internal/telemetry"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/otel/attribute"
"go.uber.org/zap"
)
const sessionEventReadCount int64 = 128
// SessionRevocationHandler reacts to a successfully applied revoked session
// snapshot and may tear down active resources bound to that session.
type SessionRevocationHandler interface {
// RevokeDeviceSession tears down active resources bound to deviceSessionID.
RevokeDeviceSession(deviceSessionID string)
}
// RedisSessionSubscriber consumes full session snapshots from one Redis Stream
// and applies them to a process-local session snapshot store.
type RedisSessionSubscriber struct {
client *redis.Client
stream string
pingTimeout time.Duration
readBlockTimeout time.Duration
store session.SnapshotStore
revocationHandler SessionRevocationHandler
logger *zap.Logger
metrics *telemetry.Runtime
closeOnce sync.Once
startedOnce sync.Once
started chan struct{}
}
// NewRedisSessionSubscriber constructs a Redis Stream subscriber that reuses
// the SessionCache Redis connection settings and applies updates to store.
func NewRedisSessionSubscriber(sessionCfg config.SessionCacheRedisConfig, eventsCfg config.SessionEventsRedisConfig, store session.SnapshotStore) (*RedisSessionSubscriber, error) {
return NewRedisSessionSubscriberWithObservability(sessionCfg, eventsCfg, store, nil, nil, nil)
}
// NewRedisSessionSubscriberWithRevocationHandler constructs a Redis Stream
// subscriber that reuses the SessionCache Redis connection settings, applies
// updates to store, and optionally tears down active resources for revoked
// sessions.
func NewRedisSessionSubscriberWithRevocationHandler(sessionCfg config.SessionCacheRedisConfig, eventsCfg config.SessionEventsRedisConfig, store session.SnapshotStore, revocationHandler SessionRevocationHandler) (*RedisSessionSubscriber, error) {
return NewRedisSessionSubscriberWithObservability(sessionCfg, eventsCfg, store, revocationHandler, nil, nil)
}
// NewRedisSessionSubscriberWithObservability constructs a Redis Stream
// subscriber that also logs and counts malformed internal session events.
func NewRedisSessionSubscriberWithObservability(sessionCfg config.SessionCacheRedisConfig, eventsCfg config.SessionEventsRedisConfig, store session.SnapshotStore, revocationHandler SessionRevocationHandler, logger *zap.Logger, metrics *telemetry.Runtime) (*RedisSessionSubscriber, error) {
if strings.TrimSpace(sessionCfg.Addr) == "" {
return nil, errors.New("new redis session subscriber: redis addr must not be empty")
}
if sessionCfg.DB < 0 {
return nil, errors.New("new redis session subscriber: redis db must not be negative")
}
if sessionCfg.LookupTimeout <= 0 {
return nil, errors.New("new redis session subscriber: lookup timeout must be positive")
}
if strings.TrimSpace(eventsCfg.Stream) == "" {
return nil, errors.New("new redis session subscriber: stream must not be empty")
}
if eventsCfg.ReadBlockTimeout <= 0 {
return nil, errors.New("new redis session subscriber: read block timeout must be positive")
}
if store == nil {
return nil, errors.New("new redis session subscriber: nil session snapshot store")
}
options := &redis.Options{
Addr: sessionCfg.Addr,
Username: sessionCfg.Username,
Password: sessionCfg.Password,
DB: sessionCfg.DB,
Protocol: 2,
DisableIdentity: true,
}
if sessionCfg.TLSEnabled {
options.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
}
if logger == nil {
logger = zap.NewNop()
}
return &RedisSessionSubscriber{
client: redis.NewClient(options),
stream: eventsCfg.Stream,
pingTimeout: sessionCfg.LookupTimeout,
readBlockTimeout: eventsCfg.ReadBlockTimeout,
store: store,
revocationHandler: revocationHandler,
logger: logger.Named("session_subscriber"),
metrics: metrics,
started: make(chan struct{}),
}, nil
}
// Ping verifies that the Redis backend used for session lifecycle events is
// reachable within the configured timeout budget.
func (s *RedisSessionSubscriber) Ping(ctx context.Context) error {
if s == nil || s.client == nil {
return errors.New("ping redis session subscriber: nil subscriber")
}
if ctx == nil {
return errors.New("ping redis session subscriber: nil context")
}
pingCtx, cancel := context.WithTimeout(ctx, s.pingTimeout)
defer cancel()
if err := s.client.Ping(pingCtx).Err(); err != nil {
return fmt.Errorf("ping redis session subscriber: %w", err)
}
return nil
}
// Run consumes session lifecycle events until ctx is canceled or Redis returns
// an unexpected error.
func (s *RedisSessionSubscriber) Run(ctx context.Context) error {
if s == nil || s.client == nil {
return errors.New("run redis session subscriber: nil subscriber")
}
if ctx == nil {
return errors.New("run redis session subscriber: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
lastID, err := s.resolveStartID(ctx)
if err != nil {
return err
}
s.signalStarted()
for {
streams, err := s.client.XRead(ctx, &redis.XReadArgs{
Streams: []string{s.stream, lastID},
Count: sessionEventReadCount,
Block: s.readBlockTimeout,
}).Result()
switch {
case err == nil:
for _, stream := range streams {
for _, message := range stream.Messages {
s.applyMessage(message)
lastID = message.ID
}
}
continue
case errors.Is(err, redis.Nil):
continue
case ctx.Err() != nil && (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, redis.ErrClosed)):
return ctx.Err()
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded), errors.Is(err, redis.ErrClosed):
return fmt.Errorf("run redis session subscriber: %w", err)
default:
return fmt.Errorf("run redis session subscriber: %w", err)
}
}
}
func (s *RedisSessionSubscriber) resolveStartID(ctx context.Context) (string, error) {
messages, err := s.client.XRevRangeN(ctx, s.stream, "+", "-", 1).Result()
switch {
case err == nil:
case errors.Is(err, redis.Nil):
return "0-0", nil
default:
return "", fmt.Errorf("run redis session subscriber: resolve stream tail: %w", err)
}
if len(messages) == 0 {
return "0-0", nil
}
return messages[0].ID, nil
}
// Shutdown closes the Redis client so a blocking stream read can terminate
// promptly during gateway shutdown.
func (s *RedisSessionSubscriber) Shutdown(ctx context.Context) error {
if ctx == nil {
return errors.New("shutdown redis session subscriber: nil context")
}
return s.Close()
}
// Close releases the underlying Redis client resources.
func (s *RedisSessionSubscriber) Close() error {
if s == nil || s.client == nil {
return nil
}
var err error
s.closeOnce.Do(func() {
err = s.client.Close()
})
return err
}
func (s *RedisSessionSubscriber) signalStarted() {
s.startedOnce.Do(func() {
close(s.started)
})
}
func (s *RedisSessionSubscriber) applyMessage(message redis.XMessage) {
record, err := decodeSessionRecordSnapshot(message.Values)
if err != nil {
s.logger.Warn("dropped malformed session event",
zap.String("stream", s.stream),
zap.String("message_id", message.ID),
zap.Error(err),
)
s.metrics.RecordInternalEventDrop(context.Background(),
attribute.String("component", "session_subscriber"),
attribute.String("reason", "malformed_event"),
)
if deviceSessionID, ok := extractDeviceSessionID(message.Values); ok {
s.store.Delete(deviceSessionID)
}
return
}
if err := s.store.Upsert(record); err != nil {
s.logger.Warn("dropped session snapshot after store failure",
zap.String("stream", s.stream),
zap.String("message_id", message.ID),
zap.String("device_session_id", record.DeviceSessionID),
zap.Error(err),
)
s.metrics.RecordInternalEventDrop(context.Background(),
attribute.String("component", "session_subscriber"),
attribute.String("reason", "store_failure"),
)
s.store.Delete(record.DeviceSessionID)
return
}
if record.Status == session.StatusRevoked && s.revocationHandler != nil {
s.revocationHandler.RevokeDeviceSession(record.DeviceSessionID)
}
}
func decodeSessionRecordSnapshot(values map[string]any) (session.Record, error) {
requiredKeys := map[string]struct{}{
"device_session_id": {},
"user_id": {},
"client_public_key": {},
"status": {},
}
optionalKeys := map[string]struct{}{
"revoked_at_ms": {},
}
for key := range values {
if _, ok := requiredKeys[key]; ok {
continue
}
if _, ok := optionalKeys[key]; ok {
continue
}
return session.Record{}, fmt.Errorf("decode session event: unsupported field %q", key)
}
deviceSessionID, err := requiredStringField(values, "device_session_id")
if err != nil {
return session.Record{}, err
}
userID, err := requiredStringField(values, "user_id")
if err != nil {
return session.Record{}, err
}
clientPublicKey, err := requiredStringField(values, "client_public_key")
if err != nil {
return session.Record{}, err
}
statusValue, err := requiredStringField(values, "status")
if err != nil {
return session.Record{}, err
}
record := session.Record{
DeviceSessionID: deviceSessionID,
UserID: userID,
ClientPublicKey: clientPublicKey,
Status: session.Status(statusValue),
}
if rawRevokedAtMS, ok := values["revoked_at_ms"]; ok {
revokedAtMS, err := parseInt64Field(rawRevokedAtMS, "revoked_at_ms")
if err != nil {
return session.Record{}, err
}
record.RevokedAtMS = &revokedAtMS
}
return record, nil
}
func extractDeviceSessionID(values map[string]any) (string, bool) {
value, ok := values["device_session_id"]
if !ok {
return "", false
}
deviceSessionID, err := coerceString(value)
if err != nil {
return "", false
}
if strings.TrimSpace(deviceSessionID) == "" {
return "", false
}
return deviceSessionID, true
}
func requiredStringField(values map[string]any, field string) (string, error) {
value, ok := values[field]
if !ok {
return "", fmt.Errorf("decode session event: missing %s", field)
}
stringValue, err := coerceString(value)
if err != nil {
return "", fmt.Errorf("decode session event: %s: %w", field, err)
}
if strings.TrimSpace(stringValue) == "" {
return "", fmt.Errorf("decode session event: %s must not be empty", field)
}
return stringValue, nil
}
func parseInt64Field(value any, field string) (int64, error) {
stringValue, err := coerceString(value)
if err != nil {
return 0, fmt.Errorf("decode session event: %s: %w", field, err)
}
parsed, err := strconv.ParseInt(strings.TrimSpace(stringValue), 10, 64)
if err != nil {
return 0, fmt.Errorf("decode session event: %s: %w", field, err)
}
return parsed, nil
}
func coerceString(value any) (string, error) {
switch typed := value.(type) {
case string:
return typed, nil
case []byte:
return string(typed), nil
case fmt.Stringer:
return typed.String(), nil
case int:
return strconv.Itoa(typed), nil
case int64:
return strconv.FormatInt(typed, 10), nil
case uint64:
return strconv.FormatUint(typed, 10), nil
default:
return "", fmt.Errorf("unsupported value type %T", value)
}
}
+366
View File
@@ -0,0 +1,366 @@
package events
import (
"context"
"sync"
"testing"
"time"
"galaxy/gateway/internal/config"
"galaxy/gateway/internal/session"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRedisSessionSubscriberAppliesActiveSnapshot(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
store := session.NewMemoryCache()
subscriber := newTestRedisSessionSubscriber(t, server, store)
running := runTestSubscriber(t, subscriber)
defer running.stop(t)
addSessionEvent(t, server, "gateway:session_events", map[string]string{
"device_session_id": "device-session-123",
"user_id": "user-123",
"client_public_key": "public-key-123",
"status": string(session.StatusActive),
})
require.Eventually(t, func() bool {
record, err := store.Lookup(context.Background(), "device-session-123")
if err != nil {
return false
}
return record.UserID == "user-123" && record.Status == session.StatusActive
}, time.Second, 10*time.Millisecond)
}
func TestRedisSessionSubscriberAppliesRevokedSnapshot(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
store := session.NewMemoryCache()
require.NoError(t, store.Upsert(session.Record{
DeviceSessionID: "device-session-123",
UserID: "user-123",
ClientPublicKey: "public-key-123",
Status: session.StatusActive,
}))
subscriber := newTestRedisSessionSubscriber(t, server, store)
running := runTestSubscriber(t, subscriber)
defer running.stop(t)
addSessionEvent(t, server, "gateway:session_events", map[string]string{
"device_session_id": "device-session-123",
"user_id": "user-123",
"client_public_key": "public-key-123",
"status": string(session.StatusRevoked),
"revoked_at_ms": "123456789",
})
require.Eventually(t, func() bool {
record, err := store.Lookup(context.Background(), "device-session-123")
if err != nil || record.RevokedAtMS == nil {
return false
}
return record.Status == session.StatusRevoked && *record.RevokedAtMS == 123456789
}, time.Second, 10*time.Millisecond)
}
func TestRedisSessionSubscriberRevokedSnapshotTriggersRevocationHandler(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
store := session.NewMemoryCache()
handler := &recordingSessionRevocationHandler{}
subscriber := newTestRedisSessionSubscriberWithRevocationHandler(t, server, store, handler)
running := runTestSubscriber(t, subscriber)
defer running.stop(t)
addSessionEvent(t, server, "gateway:session_events", map[string]string{
"device_session_id": "device-session-123",
"user_id": "user-123",
"client_public_key": "public-key-123",
"status": string(session.StatusRevoked),
"revoked_at_ms": "123456789",
})
require.Eventually(t, func() bool {
record, err := store.Lookup(context.Background(), "device-session-123")
if err != nil || record.Status != session.StatusRevoked {
return false
}
return assert.ObjectsAreEqual([]string{"device-session-123"}, handler.revocations())
}, time.Second, 10*time.Millisecond)
}
func TestRedisSessionSubscriberActiveSnapshotDoesNotTriggerRevocationHandler(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
store := session.NewMemoryCache()
handler := &recordingSessionRevocationHandler{}
subscriber := newTestRedisSessionSubscriberWithRevocationHandler(t, server, store, handler)
running := runTestSubscriber(t, subscriber)
defer running.stop(t)
addSessionEvent(t, server, "gateway:session_events", map[string]string{
"device_session_id": "device-session-123",
"user_id": "user-123",
"client_public_key": "public-key-123",
"status": string(session.StatusActive),
})
assert.Never(t, func() bool {
return len(handler.revocations()) != 0
}, 100*time.Millisecond, 10*time.Millisecond)
}
func TestRedisSessionSubscriberStoreFailureDoesNotTriggerRevocationHandler(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
handler := &recordingSessionRevocationHandler{}
subscriber := newTestRedisSessionSubscriberWithRevocationHandler(t, server, failingSnapshotStore{}, handler)
running := runTestSubscriber(t, subscriber)
defer running.stop(t)
addSessionEvent(t, server, "gateway:session_events", map[string]string{
"device_session_id": "device-session-123",
"user_id": "user-123",
"client_public_key": "public-key-123",
"status": string(session.StatusRevoked),
"revoked_at_ms": "123456789",
})
assert.Never(t, func() bool {
return len(handler.revocations()) != 0
}, 100*time.Millisecond, 10*time.Millisecond)
}
func TestRedisSessionSubscriberLaterEventWins(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
store := session.NewMemoryCache()
subscriber := newTestRedisSessionSubscriber(t, server, store)
running := runTestSubscriber(t, subscriber)
defer running.stop(t)
addSessionEvent(t, server, "gateway:session_events", map[string]string{
"device_session_id": "device-session-123",
"user_id": "user-123",
"client_public_key": "public-key-123",
"status": string(session.StatusActive),
})
addSessionEvent(t, server, "gateway:session_events", map[string]string{
"device_session_id": "device-session-123",
"user_id": "user-456",
"client_public_key": "public-key-456",
"status": string(session.StatusActive),
})
require.Eventually(t, func() bool {
record, err := store.Lookup(context.Background(), "device-session-123")
if err != nil {
return false
}
return record.UserID == "user-456" && record.ClientPublicKey == "public-key-456"
}, time.Second, 10*time.Millisecond)
}
func TestRedisSessionSubscriberMalformedEventEvictsAndContinues(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
store := session.NewMemoryCache()
require.NoError(t, store.Upsert(session.Record{
DeviceSessionID: "device-session-123",
UserID: "user-123",
ClientPublicKey: "public-key-123",
Status: session.StatusActive,
}))
subscriber := newTestRedisSessionSubscriber(t, server, store)
running := runTestSubscriber(t, subscriber)
defer running.stop(t)
addSessionEvent(t, server, "gateway:session_events", map[string]string{
"device_session_id": "device-session-123",
"user_id": "user-123",
"client_public_key": "public-key-123",
"status": "paused",
})
require.Eventually(t, func() bool {
_, err := store.Lookup(context.Background(), "device-session-123")
return err != nil
}, time.Second, 10*time.Millisecond)
addSessionEvent(t, server, "gateway:session_events", map[string]string{
"device_session_id": "device-session-123",
"user_id": "user-456",
"client_public_key": "public-key-456",
"status": string(session.StatusActive),
})
require.Eventually(t, func() bool {
record, err := store.Lookup(context.Background(), "device-session-123")
if err != nil {
return false
}
return record.UserID == "user-456" && record.Status == session.StatusActive
}, time.Second, 10*time.Millisecond)
}
func TestRedisSessionSubscriberShutdownInterruptsBlockingRead(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
store := session.NewMemoryCache()
subscriber := newTestRedisSessionSubscriber(t, server, store)
ctx, cancel := context.WithCancel(context.Background())
resultCh := make(chan error, 1)
go func() {
resultCh <- subscriber.Run(ctx)
}()
select {
case <-subscriber.started:
case <-time.After(time.Second):
require.FailNow(t, "subscriber did not start")
}
cancel()
require.NoError(t, subscriber.Shutdown(context.Background()))
select {
case err := <-resultCh:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(time.Second):
require.FailNow(t, "subscriber did not stop after shutdown")
}
}
func newTestRedisSessionSubscriber(t *testing.T, server *miniredis.Miniredis, store session.SnapshotStore) *RedisSessionSubscriber {
t.Helper()
return newTestRedisSessionSubscriberWithRevocationHandler(t, server, store, nil)
}
func newTestRedisSessionSubscriberWithRevocationHandler(t *testing.T, server *miniredis.Miniredis, store session.SnapshotStore, revocationHandler SessionRevocationHandler) *RedisSessionSubscriber {
t.Helper()
subscriber, err := NewRedisSessionSubscriberWithRevocationHandler(
config.SessionCacheRedisConfig{
Addr: server.Addr(),
LookupTimeout: 250 * time.Millisecond,
},
config.SessionEventsRedisConfig{
Stream: "gateway:session_events",
ReadBlockTimeout: 25 * time.Millisecond,
},
store,
revocationHandler,
)
require.NoError(t, err)
t.Cleanup(func() {
assert.NoError(t, subscriber.Close())
})
return subscriber
}
type recordingSessionRevocationHandler struct {
mu sync.Mutex
revokedIDs []string
}
func (h *recordingSessionRevocationHandler) RevokeDeviceSession(deviceSessionID string) {
h.mu.Lock()
h.revokedIDs = append(h.revokedIDs, deviceSessionID)
h.mu.Unlock()
}
func (h *recordingSessionRevocationHandler) revocations() []string {
h.mu.Lock()
defer h.mu.Unlock()
return append([]string(nil), h.revokedIDs...)
}
type failingSnapshotStore struct{}
func (failingSnapshotStore) Lookup(context.Context, string) (session.Record, error) {
return session.Record{}, session.ErrNotFound
}
func (failingSnapshotStore) Upsert(session.Record) error {
return context.DeadlineExceeded
}
func (failingSnapshotStore) Delete(string) {}
func addSessionEvent(t *testing.T, server *miniredis.Miniredis, stream string, fields map[string]string) {
t.Helper()
values := make([]string, 0, len(fields)*2)
for key, value := range fields {
values = append(values, key, value)
}
_, err := server.XAdd(stream, "*", values)
require.NoError(t, err)
}
type runningSubscriber struct {
cancel context.CancelFunc
resultCh chan error
stopOnce bool
}
func runTestSubscriber(t *testing.T, subscriber *RedisSessionSubscriber) runningSubscriber {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
resultCh := make(chan error, 1)
go func() {
resultCh <- subscriber.Run(ctx)
}()
select {
case <-subscriber.started:
case <-time.After(time.Second):
require.FailNow(t, "subscriber did not start")
}
return runningSubscriber{
cancel: cancel,
resultCh: resultCh,
}
}
func (r runningSubscriber) stop(t *testing.T) {
t.Helper()
r.cancel()
select {
case err := <-r.resultCh:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(time.Second):
require.FailNow(t, "subscriber did not stop")
}
}
+145
View File
@@ -0,0 +1,145 @@
package grpcapi
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"strings"
"time"
"galaxy/gateway/internal/authn"
"galaxy/gateway/internal/clock"
"galaxy/gateway/internal/downstream"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// commandRoutingService translates the verified authenticated request context
// into an internal downstream command and signs successful unary responses.
type commandRoutingService struct {
gatewayv1.UnimplementedEdgeGatewayServer
subscribeDelegate gatewayv1.EdgeGatewayServer
router downstream.Router
responseSigner authn.ResponseSigner
clock clock.Clock
downstreamTimeout time.Duration
}
// ExecuteCommand builds a verified downstream command, routes it by exact
// message_type, executes it, and signs the resulting unary response.
func (s commandRoutingService) ExecuteCommand(ctx context.Context, _ *gatewayv1.ExecuteCommandRequest) (*gatewayv1.ExecuteCommandResponse, error) {
command, err := authenticatedCommandFromContext(ctx)
if err != nil {
return nil, err
}
client, err := s.router.Route(command.MessageType)
switch {
case err == nil:
case errors.Is(err, downstream.ErrRouteNotFound):
return nil, status.Error(codes.Unimplemented, "message_type is not routed")
case errors.Is(err, downstream.ErrDownstreamUnavailable):
return nil, status.Error(codes.Unavailable, "downstream service is unavailable")
default:
return nil, status.Error(codes.Internal, "downstream route resolution failed")
}
downstreamCtx, cancel := context.WithTimeout(ctx, s.downstreamTimeout)
defer cancel()
result, err := client.ExecuteCommand(downstreamCtx, command)
switch {
case err == nil:
case errors.Is(err, downstream.ErrDownstreamUnavailable),
errors.Is(err, context.DeadlineExceeded),
errors.Is(err, context.Canceled):
return nil, status.Error(codes.Unavailable, "downstream service is unavailable")
default:
return nil, status.Error(codes.Internal, "downstream execution failed")
}
if strings.TrimSpace(result.ResultCode) == "" {
return nil, status.Error(codes.Internal, "downstream response is invalid")
}
responseTimestampMS := s.clock.Now().UTC().UnixMilli()
payloadHash := sha256.Sum256(result.PayloadBytes)
signature, err := s.responseSigner.SignResponse(authn.ResponseSigningFields{
ProtocolVersion: command.ProtocolVersion,
RequestID: command.RequestID,
TimestampMS: responseTimestampMS,
ResultCode: result.ResultCode,
PayloadHash: payloadHash[:],
})
if err != nil {
return nil, status.Error(codes.Unavailable, "response signer is unavailable")
}
return &gatewayv1.ExecuteCommandResponse{
ProtocolVersion: command.ProtocolVersion,
RequestId: command.RequestID,
TimestampMs: responseTimestampMS,
ResultCode: result.ResultCode,
PayloadBytes: bytes.Clone(result.PayloadBytes),
PayloadHash: bytes.Clone(payloadHash[:]),
Signature: signature,
}, nil
}
// SubscribeEvents delegates to the authenticated streaming service
// implementation selected during server construction.
func (s commandRoutingService) SubscribeEvents(req *gatewayv1.SubscribeEventsRequest, stream grpc.ServerStreamingServer[gatewayv1.GatewayEvent]) error {
return s.subscribeDelegate.SubscribeEvents(req, stream)
}
// newCommandRoutingService constructs the final authenticated service that
// owns verified unary routing while preserving the delegated streaming path.
func newCommandRoutingService(subscribeDelegate gatewayv1.EdgeGatewayServer, router downstream.Router, responseSigner authn.ResponseSigner, clk clock.Clock, downstreamTimeout time.Duration) gatewayv1.EdgeGatewayServer {
return commandRoutingService{
subscribeDelegate: subscribeDelegate,
router: router,
responseSigner: responseSigner,
clock: clk,
downstreamTimeout: downstreamTimeout,
}
}
func authenticatedCommandFromContext(ctx context.Context) (downstream.AuthenticatedCommand, error) {
envelope, ok := parsedEnvelopeFromContext(ctx)
if !ok {
return downstream.AuthenticatedCommand{}, status.Error(codes.Internal, "authenticated request context is incomplete")
}
record, ok := resolvedSessionFromContext(ctx)
if !ok {
return downstream.AuthenticatedCommand{}, status.Error(codes.Internal, "authenticated request context is incomplete")
}
return downstream.AuthenticatedCommand{
ProtocolVersion: envelope.ProtocolVersion,
UserID: record.UserID,
DeviceSessionID: record.DeviceSessionID,
MessageType: envelope.MessageType,
TimestampMS: envelope.TimestampMS,
RequestID: envelope.RequestID,
TraceID: envelope.TraceID,
PayloadBytes: bytes.Clone(envelope.PayloadBytes),
}, nil
}
type unavailableResponseSigner struct{}
func (unavailableResponseSigner) SignResponse(authn.ResponseSigningFields) ([]byte, error) {
return nil, errors.New("response signer is unavailable")
}
func (unavailableResponseSigner) SignEvent(authn.EventSigningFields) ([]byte, error) {
return nil, errors.New("response signer is unavailable")
}
var _ gatewayv1.EdgeGatewayServer = commandRoutingService{}
@@ -0,0 +1,296 @@
package grpcapi
import (
"context"
"crypto/sha256"
"fmt"
"testing"
"time"
"galaxy/gateway/internal/authn"
"galaxy/gateway/internal/downstream"
"galaxy/gateway/internal/testutil"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/trace"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestExecuteCommandRoutesVerifiedCommandAndSignsResponse(t *testing.T) {
t.Parallel()
signer := newTestEd25519ResponseSigner()
moveClient := &recordingDownstreamClient{
executeFunc: func(_ context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
assert.Equal(t, downstream.AuthenticatedCommand{
ProtocolVersion: "v1",
UserID: "user-123",
DeviceSessionID: "device-session-123",
MessageType: "fleet.move",
TimestampMS: testCurrentTime.UnixMilli(),
RequestID: "request-123",
TraceID: "trace-123",
PayloadBytes: []byte("payload"),
}, command)
return downstream.UnaryResult{
ResultCode: "accepted",
PayloadBytes: []byte("downstream-response"),
}, nil
},
}
renameClient := &recordingDownstreamClient{}
server, runGateway := newTestGateway(t, ServerDependencies{
Router: downstream.NewStaticRouter(map[string]downstream.Client{
"fleet.move": moveClient,
"fleet.rename": renameClient,
}),
ResponseSigner: signer,
SessionCache: userMappedSessionCache(map[string]string{"device-session-123": "user-123"}),
ReplayStore: staticReplayStore{},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
response, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequest())
require.NoError(t, err)
assert.Equal(t, "v1", response.GetProtocolVersion())
assert.Equal(t, "request-123", response.GetRequestId())
assert.Equal(t, testCurrentTime.UnixMilli(), response.GetTimestampMs())
assert.Equal(t, "accepted", response.GetResultCode())
assert.Equal(t, []byte("downstream-response"), response.GetPayloadBytes())
assert.Equal(t, 1, moveClient.executeCalls)
assert.Zero(t, renameClient.executeCalls)
wantHash := sha256.Sum256([]byte("downstream-response"))
assert.Equal(t, wantHash[:], response.GetPayloadHash())
require.NoError(t, authn.VerifyPayloadHash(response.GetPayloadBytes(), response.GetPayloadHash()))
require.NoError(t, authn.VerifyResponseSignature(signer.PublicKey(), response.GetSignature(), authn.ResponseSigningFields{
ProtocolVersion: response.GetProtocolVersion(),
RequestID: response.GetRequestId(),
TimestampMS: response.GetTimestampMs(),
ResultCode: response.GetResultCode(),
PayloadHash: response.GetPayloadHash(),
}))
}
func TestExecuteCommandRouteMissReturnsUnimplemented(t *testing.T) {
t.Parallel()
server, runGateway := newTestGateway(t, ServerDependencies{
Router: downstream.NewStaticRouter(nil),
SessionCache: userMappedSessionCache(map[string]string{"device-session-123": "user-123"}),
ReplayStore: staticReplayStore{},
ResponseSigner: newTestResponseSigner(),
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequest())
require.Error(t, err)
assert.Equal(t, codes.Unimplemented, status.Code(err))
assert.Equal(t, "message_type is not routed", status.Convert(err).Message())
}
func TestExecuteCommandMapsDownstreamUnavailableToUnavailable(t *testing.T) {
t.Parallel()
failingClient := &recordingDownstreamClient{
executeFunc: func(context.Context, downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
return downstream.UnaryResult{}, fmt.Errorf("rpc transport failed: %w", downstream.ErrDownstreamUnavailable)
},
}
server, runGateway := newTestGateway(t, ServerDependencies{
Router: downstream.NewStaticRouter(map[string]downstream.Client{
"fleet.move": failingClient,
}),
SessionCache: userMappedSessionCache(map[string]string{"device-session-123": "user-123"}),
ReplayStore: staticReplayStore{},
ResponseSigner: newTestResponseSigner(),
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequest())
require.Error(t, err)
assert.Equal(t, codes.Unavailable, status.Code(err))
assert.Equal(t, "downstream service is unavailable", status.Convert(err).Message())
assert.Equal(t, 1, failingClient.executeCalls)
}
func TestExecuteCommandPropagatesOTelSpanContextToDownstream(t *testing.T) {
t.Parallel()
logger := zap.NewNop()
telemetryRuntime := testutil.NewTelemetryRuntime(t, logger)
var (
seenSpanContext trace.SpanContext
seenCommand downstream.AuthenticatedCommand
)
server, runGateway := newTestGateway(t, ServerDependencies{
Router: downstream.NewStaticRouter(map[string]downstream.Client{
"fleet.move": &recordingDownstreamClient{
executeFunc: func(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
seenSpanContext = trace.SpanContextFromContext(ctx)
seenCommand = command
return downstream.UnaryResult{
ResultCode: "accepted",
PayloadBytes: []byte("downstream-response"),
}, nil
},
},
}),
ResponseSigner: newTestResponseSigner(),
SessionCache: userMappedSessionCache(map[string]string{"device-session-123": "user-123"}),
ReplayStore: staticReplayStore{},
Logger: logger,
Telemetry: telemetryRuntime,
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequest())
require.NoError(t, err)
assert.True(t, seenSpanContext.IsValid())
assert.Equal(t, "trace-123", seenCommand.TraceID)
}
func TestExecuteCommandDrainsInFlightUnaryDuringShutdown(t *testing.T) {
t.Parallel()
started := make(chan struct{})
release := make(chan struct{})
server, runGateway := newTestGateway(t, ServerDependencies{
Router: downstream.NewStaticRouter(map[string]downstream.Client{
"fleet.move": &recordingDownstreamClient{
executeFunc: func(_ context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
close(started)
<-release
return downstream.UnaryResult{
ResultCode: "accepted",
PayloadBytes: []byte("downstream-response"),
}, nil
},
},
}),
ResponseSigner: newTestResponseSigner(),
SessionCache: userMappedSessionCache(map[string]string{"device-session-123": "user-123"}),
ReplayStore: staticReplayStore{},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
resultCh := make(chan error, 1)
go func() {
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequest())
resultCh <- err
}()
require.Eventually(t, func() bool {
select {
case <-started:
return true
default:
return false
}
}, time.Second, 10*time.Millisecond, "downstream execution did not start")
runGateway.cancel()
require.Never(t, func() bool {
select {
case <-resultCh:
return true
default:
return false
}
}, 100*time.Millisecond, 10*time.Millisecond, "unary request returned before downstream release")
close(release)
var err error
require.Eventually(t, func() bool {
select {
case err = <-resultCh:
return true
default:
return false
}
}, time.Second, 10*time.Millisecond, "unary request did not drain before shutdown timeout")
require.NoError(t, err)
}
func TestExecuteCommandLogsDoNotContainSensitiveTransportMaterial(t *testing.T) {
t.Parallel()
logger, logBuffer := testutil.NewObservedLogger(t)
server, runGateway := newTestGateway(t, ServerDependencies{
Router: downstream.NewStaticRouter(map[string]downstream.Client{
"fleet.move": &recordingDownstreamClient{},
}),
ResponseSigner: newTestResponseSigner(),
SessionCache: userMappedSessionCache(map[string]string{"device-session-123": "user-123"}),
ReplayStore: staticReplayStore{},
Logger: logger,
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequest())
require.NoError(t, err)
logOutput := logBuffer.String()
assert.NotContains(t, logOutput, "payload_hash")
assert.NotContains(t, logOutput, "signature")
assert.NotContains(t, logOutput, `"payload"`)
}
+214
View File
@@ -0,0 +1,214 @@
package grpcapi
import (
"bytes"
"context"
"fmt"
"galaxy/gateway/proto/galaxy/gateway/v1"
"buf.build/go/protovalidate"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const supportedProtocolVersion = "v1"
// parsedEnvelope captures the authenticated transport fields extracted from a
// request envelope after validation succeeds. Later wrappers may enrich this
// structure without changing the raw gRPC request types.
type parsedEnvelope struct {
ProtocolVersion string
DeviceSessionID string
MessageType string
TimestampMS int64
RequestID string
TraceID string
PayloadBytes []byte
PayloadHash []byte
Signature []byte
}
// parsedEnvelopeFromContext returns the parsed envelope previously attached to
// ctx by the envelope-validating gRPC service wrapper.
func parsedEnvelopeFromContext(ctx context.Context) (parsedEnvelope, bool) {
if ctx == nil {
return parsedEnvelope{}, false
}
envelope, ok := ctx.Value(parsedEnvelopeContextKey{}).(parsedEnvelope)
if !ok {
return parsedEnvelope{}, false
}
return envelope, true
}
// envelopeValidatingService applies envelope parsing and the protocol gate
// before delegating to the configured service implementation.
type envelopeValidatingService struct {
gatewayv1.UnimplementedEdgeGatewayServer
delegate gatewayv1.EdgeGatewayServer
}
// ExecuteCommand validates req and only then forwards it to the configured
// delegate with the parsed envelope attached to ctx.
func (s envelopeValidatingService) ExecuteCommand(ctx context.Context, req *gatewayv1.ExecuteCommandRequest) (*gatewayv1.ExecuteCommandResponse, error) {
envelope, err := parseExecuteCommandRequest(req)
if err != nil {
return nil, err
}
return s.delegate.ExecuteCommand(context.WithValue(ctx, parsedEnvelopeContextKey{}, envelope), req)
}
// SubscribeEvents validates req and only then forwards it to the configured
// delegate with the parsed envelope attached to the stream context.
func (s envelopeValidatingService) SubscribeEvents(req *gatewayv1.SubscribeEventsRequest, stream grpc.ServerStreamingServer[gatewayv1.GatewayEvent]) error {
envelope, err := parseSubscribeEventsRequest(req)
if err != nil {
return err
}
return s.delegate.SubscribeEvents(req, envelopeContextStream{
ServerStreamingServer: stream,
ctx: context.WithValue(stream.Context(), parsedEnvelopeContextKey{}, envelope),
})
}
// parseExecuteCommandRequest validates req according to the request-envelope
// rules and returns a cloned parsed envelope suitable for later auth steps.
func parseExecuteCommandRequest(req *gatewayv1.ExecuteCommandRequest) (parsedEnvelope, error) {
if req == nil {
return parsedEnvelope{}, newMalformedEnvelopeError("request envelope must not be nil")
}
if err := protovalidate.Validate(req); err != nil {
return parsedEnvelope{}, canonicalExecuteCommandValidationError(req)
}
if req.GetProtocolVersion() != supportedProtocolVersion {
return parsedEnvelope{}, newUnsupportedProtocolVersionError(req.GetProtocolVersion())
}
return parsedEnvelope{
ProtocolVersion: req.GetProtocolVersion(),
DeviceSessionID: req.GetDeviceSessionId(),
MessageType: req.GetMessageType(),
TimestampMS: req.GetTimestampMs(),
RequestID: req.GetRequestId(),
TraceID: req.GetTraceId(),
PayloadBytes: bytes.Clone(req.GetPayloadBytes()),
PayloadHash: bytes.Clone(req.GetPayloadHash()),
Signature: bytes.Clone(req.GetSignature()),
}, nil
}
// parseSubscribeEventsRequest validates req according to the request-envelope
// rules and returns a cloned parsed envelope suitable for later auth steps.
func parseSubscribeEventsRequest(req *gatewayv1.SubscribeEventsRequest) (parsedEnvelope, error) {
if req == nil {
return parsedEnvelope{}, newMalformedEnvelopeError("request envelope must not be nil")
}
if err := protovalidate.Validate(req); err != nil {
return parsedEnvelope{}, canonicalSubscribeEventsValidationError(req)
}
if req.GetProtocolVersion() != supportedProtocolVersion {
return parsedEnvelope{}, newUnsupportedProtocolVersionError(req.GetProtocolVersion())
}
return parsedEnvelope{
ProtocolVersion: req.GetProtocolVersion(),
DeviceSessionID: req.GetDeviceSessionId(),
MessageType: req.GetMessageType(),
TimestampMS: req.GetTimestampMs(),
RequestID: req.GetRequestId(),
TraceID: req.GetTraceId(),
PayloadBytes: bytes.Clone(req.GetPayloadBytes()),
PayloadHash: bytes.Clone(req.GetPayloadHash()),
Signature: bytes.Clone(req.GetSignature()),
}, nil
}
// newEnvelopeValidatingService wraps delegate with the envelope-validation
// gate.
func newEnvelopeValidatingService(delegate gatewayv1.EdgeGatewayServer) gatewayv1.EdgeGatewayServer {
return envelopeValidatingService{delegate: delegate}
}
// canonicalExecuteCommandValidationError maps any ExecuteCommand validation
// failure into the stable canonical error chosen by field order.
func canonicalExecuteCommandValidationError(req *gatewayv1.ExecuteCommandRequest) error {
switch {
case req.GetProtocolVersion() == "":
return newMalformedEnvelopeError("protocol_version must not be empty")
case req.GetDeviceSessionId() == "":
return newMalformedEnvelopeError("device_session_id must not be empty")
case req.GetMessageType() == "":
return newMalformedEnvelopeError("message_type must not be empty")
case req.GetTimestampMs() <= 0:
return newMalformedEnvelopeError("timestamp_ms must be greater than zero")
case req.GetRequestId() == "":
return newMalformedEnvelopeError("request_id must not be empty")
case len(req.GetPayloadBytes()) == 0:
return newMalformedEnvelopeError("payload_bytes must not be empty")
case len(req.GetPayloadHash()) == 0:
return newMalformedEnvelopeError("payload_hash must not be empty")
case len(req.GetSignature()) == 0:
return newMalformedEnvelopeError("signature must not be empty")
default:
return newMalformedEnvelopeError("request envelope is invalid")
}
}
// canonicalSubscribeEventsValidationError maps any SubscribeEvents validation
// failure into the stable canonical error chosen by field order.
func canonicalSubscribeEventsValidationError(req *gatewayv1.SubscribeEventsRequest) error {
switch {
case req.GetProtocolVersion() == "":
return newMalformedEnvelopeError("protocol_version must not be empty")
case req.GetDeviceSessionId() == "":
return newMalformedEnvelopeError("device_session_id must not be empty")
case req.GetMessageType() == "":
return newMalformedEnvelopeError("message_type must not be empty")
case req.GetTimestampMs() <= 0:
return newMalformedEnvelopeError("timestamp_ms must be greater than zero")
case req.GetRequestId() == "":
return newMalformedEnvelopeError("request_id must not be empty")
case len(req.GetPayloadHash()) == 0:
return newMalformedEnvelopeError("payload_hash must not be empty")
case len(req.GetSignature()) == 0:
return newMalformedEnvelopeError("signature must not be empty")
default:
return newMalformedEnvelopeError("request envelope is invalid")
}
}
// newMalformedEnvelopeError returns the stable malformed-envelope reject used
// before the gateway performs any auth or routing work.
func newMalformedEnvelopeError(message string) error {
return status.Error(codes.InvalidArgument, message)
}
// newUnsupportedProtocolVersionError returns the stable reject for a non-empty
// but unsupported protocol_version literal.
func newUnsupportedProtocolVersionError(version string) error {
return status.Error(codes.FailedPrecondition, fmt.Sprintf("unsupported protocol_version %q", version))
}
type parsedEnvelopeContextKey struct{}
type envelopeContextStream struct {
grpc.ServerStreamingServer[gatewayv1.GatewayEvent]
ctx context.Context
}
func (s envelopeContextStream) Context() context.Context {
if s.ctx == nil {
return context.Background()
}
return s.ctx
}
var _ gatewayv1.EdgeGatewayServer = envelopeValidatingService{}
+420
View File
@@ -0,0 +1,420 @@
package grpcapi
import (
"context"
"testing"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
func TestParseExecuteCommandRequest(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mutate func(*gatewayv1.ExecuteCommandRequest)
wantCode codes.Code
wantMessage string
assertValid func(*testing.T, *gatewayv1.ExecuteCommandRequest, parsedEnvelope)
}{
{
name: "nil request",
wantCode: codes.InvalidArgument,
wantMessage: "request envelope must not be nil",
},
{
name: "empty protocol version",
mutate: func(req *gatewayv1.ExecuteCommandRequest) {
req.ProtocolVersion = ""
},
wantCode: codes.InvalidArgument,
wantMessage: "protocol_version must not be empty",
},
{
name: "empty device session id",
mutate: func(req *gatewayv1.ExecuteCommandRequest) {
req.DeviceSessionId = ""
},
wantCode: codes.InvalidArgument,
wantMessage: "device_session_id must not be empty",
},
{
name: "empty message type",
mutate: func(req *gatewayv1.ExecuteCommandRequest) {
req.MessageType = ""
},
wantCode: codes.InvalidArgument,
wantMessage: "message_type must not be empty",
},
{
name: "zero timestamp",
mutate: func(req *gatewayv1.ExecuteCommandRequest) {
req.TimestampMs = 0
},
wantCode: codes.InvalidArgument,
wantMessage: "timestamp_ms must be greater than zero",
},
{
name: "empty request id",
mutate: func(req *gatewayv1.ExecuteCommandRequest) {
req.RequestId = ""
},
wantCode: codes.InvalidArgument,
wantMessage: "request_id must not be empty",
},
{
name: "empty payload bytes",
mutate: func(req *gatewayv1.ExecuteCommandRequest) {
req.PayloadBytes = nil
},
wantCode: codes.InvalidArgument,
wantMessage: "payload_bytes must not be empty",
},
{
name: "empty payload hash",
mutate: func(req *gatewayv1.ExecuteCommandRequest) {
req.PayloadHash = nil
},
wantCode: codes.InvalidArgument,
wantMessage: "payload_hash must not be empty",
},
{
name: "empty signature",
mutate: func(req *gatewayv1.ExecuteCommandRequest) {
req.Signature = nil
},
wantCode: codes.InvalidArgument,
wantMessage: "signature must not be empty",
},
{
name: "unsupported protocol version",
mutate: func(req *gatewayv1.ExecuteCommandRequest) {
req.ProtocolVersion = "v2"
},
wantCode: codes.FailedPrecondition,
wantMessage: `unsupported protocol_version "v2"`,
},
{
name: "valid request",
wantCode: codes.OK,
assertValid: func(t *testing.T, req *gatewayv1.ExecuteCommandRequest, envelope parsedEnvelope) {
t.Helper()
assert.Equal(t, supportedProtocolVersion, envelope.ProtocolVersion)
assert.Equal(t, req.GetDeviceSessionId(), envelope.DeviceSessionID)
assert.Equal(t, req.GetMessageType(), envelope.MessageType)
assert.Equal(t, req.GetTimestampMs(), envelope.TimestampMS)
assert.Equal(t, req.GetRequestId(), envelope.RequestID)
assert.Equal(t, req.GetTraceId(), envelope.TraceID)
assert.Equal(t, req.GetPayloadBytes(), envelope.PayloadBytes)
assert.Equal(t, req.GetPayloadHash(), envelope.PayloadHash)
assert.Equal(t, req.GetSignature(), envelope.Signature)
originalPayloadBytes := append([]byte(nil), req.GetPayloadBytes()...)
originalPayloadHash := append([]byte(nil), req.GetPayloadHash()...)
originalSignature := append([]byte(nil), req.GetSignature()...)
envelope.PayloadBytes[0] = 'X'
envelope.PayloadHash[0] = 'Y'
envelope.Signature[0] = 'Z'
assert.Equal(t, originalPayloadBytes, req.GetPayloadBytes())
assert.Equal(t, originalPayloadHash, req.GetPayloadHash())
assert.Equal(t, originalSignature, req.GetSignature())
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var req *gatewayv1.ExecuteCommandRequest
if tt.name != "nil request" {
req = newValidExecuteCommandRequest()
if tt.mutate != nil {
tt.mutate(req)
}
}
envelope, err := parseExecuteCommandRequest(req)
if tt.wantCode != codes.OK {
require.Error(t, err)
assert.Equal(t, tt.wantCode, status.Code(err))
assert.Equal(t, tt.wantMessage, status.Convert(err).Message())
return
}
require.NoError(t, err)
require.NotNil(t, tt.assertValid)
tt.assertValid(t, req, envelope)
})
}
}
func TestParseSubscribeEventsRequest(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mutate func(*gatewayv1.SubscribeEventsRequest)
wantCode codes.Code
wantMessage string
assertValid func(*testing.T, *gatewayv1.SubscribeEventsRequest, parsedEnvelope)
}{
{
name: "nil request",
wantCode: codes.InvalidArgument,
wantMessage: "request envelope must not be nil",
},
{
name: "empty protocol version",
mutate: func(req *gatewayv1.SubscribeEventsRequest) {
req.ProtocolVersion = ""
},
wantCode: codes.InvalidArgument,
wantMessage: "protocol_version must not be empty",
},
{
name: "empty device session id",
mutate: func(req *gatewayv1.SubscribeEventsRequest) {
req.DeviceSessionId = ""
},
wantCode: codes.InvalidArgument,
wantMessage: "device_session_id must not be empty",
},
{
name: "empty message type",
mutate: func(req *gatewayv1.SubscribeEventsRequest) {
req.MessageType = ""
},
wantCode: codes.InvalidArgument,
wantMessage: "message_type must not be empty",
},
{
name: "zero timestamp",
mutate: func(req *gatewayv1.SubscribeEventsRequest) {
req.TimestampMs = 0
},
wantCode: codes.InvalidArgument,
wantMessage: "timestamp_ms must be greater than zero",
},
{
name: "empty request id",
mutate: func(req *gatewayv1.SubscribeEventsRequest) {
req.RequestId = ""
},
wantCode: codes.InvalidArgument,
wantMessage: "request_id must not be empty",
},
{
name: "empty payload hash",
mutate: func(req *gatewayv1.SubscribeEventsRequest) {
req.PayloadHash = nil
},
wantCode: codes.InvalidArgument,
wantMessage: "payload_hash must not be empty",
},
{
name: "empty signature",
mutate: func(req *gatewayv1.SubscribeEventsRequest) {
req.Signature = nil
},
wantCode: codes.InvalidArgument,
wantMessage: "signature must not be empty",
},
{
name: "unsupported protocol version",
mutate: func(req *gatewayv1.SubscribeEventsRequest) {
req.ProtocolVersion = "v2"
},
wantCode: codes.FailedPrecondition,
wantMessage: `unsupported protocol_version "v2"`,
},
{
name: "valid request with empty payload bytes",
wantCode: codes.OK,
assertValid: func(t *testing.T, req *gatewayv1.SubscribeEventsRequest, envelope parsedEnvelope) {
t.Helper()
assert.Empty(t, req.GetPayloadBytes())
assert.Empty(t, envelope.PayloadBytes)
assert.Equal(t, req.GetPayloadHash(), envelope.PayloadHash)
assert.Equal(t, req.GetSignature(), envelope.Signature)
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var req *gatewayv1.SubscribeEventsRequest
if tt.name != "nil request" {
req = newValidSubscribeEventsRequest()
if tt.mutate != nil {
tt.mutate(req)
}
}
envelope, err := parseSubscribeEventsRequest(req)
if tt.wantCode != codes.OK {
require.Error(t, err)
assert.Equal(t, tt.wantCode, status.Code(err))
assert.Equal(t, tt.wantMessage, status.Convert(err).Message())
return
}
require.NoError(t, err)
require.NotNil(t, tt.assertValid)
tt.assertValid(t, req, envelope)
})
}
}
func TestEnvelopeValidatingServiceExecuteCommandRejectsInvalidRequestBeforeDelegate(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
service := newEnvelopeValidatingService(delegate)
_, err := service.ExecuteCommand(context.Background(), &gatewayv1.ExecuteCommandRequest{})
require.Error(t, err)
assert.Equal(t, codes.InvalidArgument, status.Code(err))
assert.Zero(t, delegate.executeCalls)
}
func TestEnvelopeValidatingServiceSubscribeEventsRejectsInvalidRequestBeforeDelegate(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
service := newEnvelopeValidatingService(delegate)
err := service.SubscribeEvents(&gatewayv1.SubscribeEventsRequest{}, stubGatewayEventStream{})
require.Error(t, err)
assert.Equal(t, codes.InvalidArgument, status.Code(err))
assert.Zero(t, delegate.subscribeCalls)
}
func TestEnvelopeValidatingServiceExecuteCommandAttachesParsedEnvelope(t *testing.T) {
t.Parallel()
want := newValidExecuteCommandRequest()
delegate := &recordingEdgeGatewayService{
executeCommandFunc: func(ctx context.Context, req *gatewayv1.ExecuteCommandRequest) (*gatewayv1.ExecuteCommandResponse, error) {
envelope, ok := parsedEnvelopeFromContext(ctx)
require.True(t, ok)
assert.Equal(t, want.GetRequestId(), envelope.RequestID)
assert.Equal(t, want.GetDeviceSessionId(), envelope.DeviceSessionID)
assert.Equal(t, want.GetMessageType(), envelope.MessageType)
assert.Equal(t, want.GetPayloadBytes(), envelope.PayloadBytes)
return &gatewayv1.ExecuteCommandResponse{RequestId: req.GetRequestId()}, nil
},
}
service := newEnvelopeValidatingService(delegate)
response, err := service.ExecuteCommand(context.Background(), want)
require.NoError(t, err)
assert.Equal(t, want.GetRequestId(), response.GetRequestId())
assert.Equal(t, 1, delegate.executeCalls)
}
func TestEnvelopeValidatingServiceSubscribeEventsAttachesParsedEnvelope(t *testing.T) {
t.Parallel()
want := newValidSubscribeEventsRequest()
delegate := &recordingEdgeGatewayService{
subscribeEventsFunc: func(req *gatewayv1.SubscribeEventsRequest, stream grpc.ServerStreamingServer[gatewayv1.GatewayEvent]) error {
envelope, ok := parsedEnvelopeFromContext(stream.Context())
require.True(t, ok)
assert.Equal(t, want.GetRequestId(), envelope.RequestID)
assert.Equal(t, want.GetDeviceSessionId(), envelope.DeviceSessionID)
assert.Equal(t, want.GetMessageType(), envelope.MessageType)
assert.Equal(t, want.GetPayloadHash(), envelope.PayloadHash)
assert.Equal(t, want.GetSignature(), envelope.Signature)
return nil
},
}
service := newEnvelopeValidatingService(delegate)
err := service.SubscribeEvents(want, stubGatewayEventStream{})
require.NoError(t, err)
assert.Equal(t, 1, delegate.subscribeCalls)
}
type recordingEdgeGatewayService struct {
gatewayv1.UnimplementedEdgeGatewayServer
executeCalls int
subscribeCalls int
executeCommandFunc func(context.Context, *gatewayv1.ExecuteCommandRequest) (*gatewayv1.ExecuteCommandResponse, error)
subscribeEventsFunc func(*gatewayv1.SubscribeEventsRequest, grpc.ServerStreamingServer[gatewayv1.GatewayEvent]) error
}
func (s *recordingEdgeGatewayService) ExecuteCommand(ctx context.Context, req *gatewayv1.ExecuteCommandRequest) (*gatewayv1.ExecuteCommandResponse, error) {
s.executeCalls++
if s.executeCommandFunc != nil {
return s.executeCommandFunc(ctx, req)
}
return &gatewayv1.ExecuteCommandResponse{}, nil
}
func (s *recordingEdgeGatewayService) SubscribeEvents(req *gatewayv1.SubscribeEventsRequest, stream grpc.ServerStreamingServer[gatewayv1.GatewayEvent]) error {
s.subscribeCalls++
if s.subscribeEventsFunc != nil {
return s.subscribeEventsFunc(req, stream)
}
return nil
}
type stubGatewayEventStream struct {
grpc.ServerStream
ctx context.Context
}
func (s stubGatewayEventStream) Send(*gatewayv1.GatewayEvent) error {
return nil
}
func (s stubGatewayEventStream) SetHeader(metadata.MD) error {
return nil
}
func (s stubGatewayEventStream) SendHeader(metadata.MD) error {
return nil
}
func (s stubGatewayEventStream) SetTrailer(metadata.MD) {}
func (s stubGatewayEventStream) Context() context.Context {
if s.ctx == nil {
return context.Background()
}
return s.ctx
}
func (s stubGatewayEventStream) SendMsg(any) error {
return nil
}
func (s stubGatewayEventStream) RecvMsg(any) error {
return nil
}
@@ -0,0 +1,95 @@
package grpcapi
import (
"context"
"errors"
"time"
"galaxy/gateway/internal/clock"
"galaxy/gateway/internal/replay"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const minimumReplayReservationTTL = time.Millisecond
// freshnessAndReplayService applies freshness and anti-replay checks after
// client-signature verification and before later policy or routing steps run.
type freshnessAndReplayService struct {
gatewayv1.UnimplementedEdgeGatewayServer
delegate gatewayv1.EdgeGatewayServer
clock clock.Clock
replayStore replay.Store
freshnessWindow time.Duration
}
// ExecuteCommand verifies request freshness and replay protection before
// delegating to the configured service implementation.
func (s freshnessAndReplayService) ExecuteCommand(ctx context.Context, req *gatewayv1.ExecuteCommandRequest) (*gatewayv1.ExecuteCommandResponse, error) {
if err := s.verifyFreshnessAndReplay(ctx); err != nil {
return nil, err
}
return s.delegate.ExecuteCommand(ctx, req)
}
// SubscribeEvents verifies request freshness and replay protection before
// delegating to the configured service implementation.
func (s freshnessAndReplayService) SubscribeEvents(req *gatewayv1.SubscribeEventsRequest, stream grpc.ServerStreamingServer[gatewayv1.GatewayEvent]) error {
if err := s.verifyFreshnessAndReplay(stream.Context()); err != nil {
return err
}
return s.delegate.SubscribeEvents(req, stream)
}
// newFreshnessAndReplayService wraps delegate with the freshness and replay
// gate.
func newFreshnessAndReplayService(delegate gatewayv1.EdgeGatewayServer, clk clock.Clock, replayStore replay.Store, freshnessWindow time.Duration) gatewayv1.EdgeGatewayServer {
return freshnessAndReplayService{
delegate: delegate,
clock: clk,
replayStore: replayStore,
freshnessWindow: freshnessWindow,
}
}
func (s freshnessAndReplayService) verifyFreshnessAndReplay(ctx context.Context) error {
envelope, ok := parsedEnvelopeFromContext(ctx)
if !ok {
return status.Error(codes.Internal, "authenticated request context is incomplete")
}
now := s.clock.Now().UTC()
requestTime := time.UnixMilli(envelope.TimestampMS).UTC()
if requestTime.Before(now.Add(-s.freshnessWindow)) || requestTime.After(now.Add(s.freshnessWindow)) {
return status.Error(codes.FailedPrecondition, "request timestamp is outside the freshness window")
}
ttl := requestTime.Add(s.freshnessWindow).Sub(now)
if ttl < minimumReplayReservationTTL {
ttl = minimumReplayReservationTTL
}
err := s.replayStore.Reserve(ctx, envelope.DeviceSessionID, envelope.RequestID, ttl)
switch {
case err == nil:
return nil
case errors.Is(err, replay.ErrDuplicate):
return status.Error(codes.FailedPrecondition, "request replay detected")
default:
return status.Error(codes.Unavailable, "replay store is unavailable")
}
}
type unavailableReplayStore struct{}
func (unavailableReplayStore) Reserve(context.Context, string, string, time.Duration) error {
return errors.New("replay store is unavailable")
}
var _ gatewayv1.EdgeGatewayServer = freshnessAndReplayService{}
@@ -0,0 +1,509 @@
package grpcapi
import (
"context"
"errors"
"io"
"sync"
"testing"
"time"
"galaxy/gateway/internal/replay"
"galaxy/gateway/internal/session"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestExecuteCommandRejectsStaleTimestamp(t *testing.T) {
t.Parallel()
tests := []struct {
name string
timestampMS int64
}{
{
name: "past window",
timestampMS: testCurrentTime.Add(-testFreshnessWindow - time.Millisecond).UnixMilli(),
},
{
name: "future window",
timestampMS: testCurrentTime.Add(testFreshnessWindow + time.Millisecond).UnixMilli(),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{lookupFunc: func(context.Context, string) (session.Record, error) { return newActiveSessionRecord(), nil }},
ReplayStore: staticReplayStore{},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequestWithTimestamp("device-session-123", "request-123", tt.timestampMS))
require.Error(t, err)
assert.Equal(t, codes.FailedPrecondition, status.Code(err))
assert.Equal(t, "request timestamp is outside the freshness window", status.Convert(err).Message())
assert.Zero(t, delegate.executeCalls)
})
}
}
func TestSubscribeEventsRejectsStaleTimestamp(t *testing.T) {
t.Parallel()
tests := []struct {
name string
timestampMS int64
}{
{
name: "past window",
timestampMS: testCurrentTime.Add(-testFreshnessWindow - time.Millisecond).UnixMilli(),
},
{
name: "future window",
timestampMS: testCurrentTime.Add(testFreshnessWindow + time.Millisecond).UnixMilli(),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{lookupFunc: func(context.Context, string) (session.Record, error) { return newActiveSessionRecord(), nil }},
ReplayStore: staticReplayStore{},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
err := subscribeEventsError(t, context.Background(), client, newValidSubscribeEventsRequestWithTimestamp("device-session-123", "request-123", tt.timestampMS))
require.Error(t, err)
assert.Equal(t, codes.FailedPrecondition, status.Code(err))
assert.Equal(t, "request timestamp is outside the freshness window", status.Convert(err).Message())
assert.Zero(t, delegate.subscribeCalls)
})
}
}
func TestExecuteCommandRejectsReplay(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{lookupFunc: func(context.Context, string) (session.Record, error) { return newActiveSessionRecord(), nil }},
ReplayStore: staticReplayStore{
reserveFunc: replayDuplicateBySessionAndRequest(),
},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
req := newValidExecuteCommandRequest()
_, err := client.ExecuteCommand(context.Background(), req)
require.NoError(t, err)
_, err = client.ExecuteCommand(context.Background(), req)
require.Error(t, err)
assert.Equal(t, codes.FailedPrecondition, status.Code(err))
assert.Equal(t, "request replay detected", status.Convert(err).Message())
assert.Equal(t, 1, delegate.executeCalls)
}
func TestSubscribeEventsRejectsReplay(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{lookupFunc: func(context.Context, string) (session.Record, error) { return newActiveSessionRecord(), nil }},
ReplayStore: staticReplayStore{
reserveFunc: replayDuplicateBySessionAndRequest(),
},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
req := newValidSubscribeEventsRequest()
stream, err := client.SubscribeEvents(context.Background(), req)
require.NoError(t, err)
event := recvBootstrapEvent(t, stream)
assertServerTimeBootstrapEvent(t, event, newTestResponseSignerPublicKey(), "request-123", "trace-123", testCurrentTime.UnixMilli())
_, err = stream.Recv()
require.ErrorIs(t, err, io.EOF)
err = subscribeEventsError(t, context.Background(), client, req)
require.Error(t, err)
assert.Equal(t, codes.FailedPrecondition, status.Code(err))
assert.Equal(t, "request replay detected", status.Convert(err).Message())
assert.Equal(t, 1, delegate.subscribeCalls)
}
func TestExecuteCommandAllowsSameRequestIDAcrossDistinctSessions(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{
executeCommandFunc: func(ctx context.Context, req *gatewayv1.ExecuteCommandRequest) (*gatewayv1.ExecuteCommandResponse, error) {
return &gatewayv1.ExecuteCommandResponse{RequestId: req.GetRequestId()}, nil
},
}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{
lookupFunc: func(ctx context.Context, deviceSessionID string) (session.Record, error) {
return newActiveSessionRecordWithSessionID(deviceSessionID), nil
},
},
ReplayStore: staticReplayStore{
reserveFunc: replayDuplicateBySessionAndRequest(),
},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequestWithSessionAndRequestID("device-session-123", "request-shared"))
require.NoError(t, err)
_, err = client.ExecuteCommand(context.Background(), newValidExecuteCommandRequestWithSessionAndRequestID("device-session-456", "request-shared"))
require.NoError(t, err)
assert.Equal(t, 2, delegate.executeCalls)
}
func TestSubscribeEventsAllowsSameRequestIDAcrossDistinctSessions(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{
subscribeEventsFunc: func(req *gatewayv1.SubscribeEventsRequest, stream grpc.ServerStreamingServer[gatewayv1.GatewayEvent]) error {
return nil
},
}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{
lookupFunc: func(ctx context.Context, deviceSessionID string) (session.Record, error) {
return newActiveSessionRecordWithSessionID(deviceSessionID), nil
},
},
ReplayStore: staticReplayStore{
reserveFunc: replayDuplicateBySessionAndRequest(),
},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
stream, err := client.SubscribeEvents(context.Background(), newValidSubscribeEventsRequestWithSessionAndRequestID("device-session-123", "request-shared"))
require.NoError(t, err)
event := recvBootstrapEvent(t, stream)
assertServerTimeBootstrapEvent(t, event, newTestResponseSignerPublicKey(), "request-shared", "trace-123", testCurrentTime.UnixMilli())
_, err = stream.Recv()
require.ErrorIs(t, err, io.EOF)
stream, err = client.SubscribeEvents(context.Background(), newValidSubscribeEventsRequestWithSessionAndRequestID("device-session-456", "request-shared"))
require.NoError(t, err)
event = recvBootstrapEvent(t, stream)
assertServerTimeBootstrapEvent(t, event, newTestResponseSignerPublicKey(), "request-shared", "trace-123", testCurrentTime.UnixMilli())
_, err = stream.Recv()
require.ErrorIs(t, err, io.EOF)
assert.Equal(t, 2, delegate.subscribeCalls)
}
func TestExecuteCommandRejectsReplayStoreUnavailable(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{lookupFunc: func(context.Context, string) (session.Record, error) { return newActiveSessionRecord(), nil }},
ReplayStore: staticReplayStore{
reserveFunc: func(context.Context, string, string, time.Duration) error {
return errors.New("redis down")
},
},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequest())
require.Error(t, err)
assert.Equal(t, codes.Unavailable, status.Code(err))
assert.Equal(t, "replay store is unavailable", status.Convert(err).Message())
assert.Zero(t, delegate.executeCalls)
}
func TestSubscribeEventsRejectsReplayStoreUnavailable(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{lookupFunc: func(context.Context, string) (session.Record, error) { return newActiveSessionRecord(), nil }},
ReplayStore: staticReplayStore{
reserveFunc: func(context.Context, string, string, time.Duration) error {
return errors.New("redis down")
},
},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
err := subscribeEventsError(t, context.Background(), client, newValidSubscribeEventsRequest())
require.Error(t, err)
assert.Equal(t, codes.Unavailable, status.Code(err))
assert.Equal(t, "replay store is unavailable", status.Convert(err).Message())
assert.Zero(t, delegate.subscribeCalls)
}
func TestExecuteCommandFreshRequestReachesDelegateAndUsesDynamicReplayTTL(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{
executeCommandFunc: func(ctx context.Context, req *gatewayv1.ExecuteCommandRequest) (*gatewayv1.ExecuteCommandResponse, error) {
return &gatewayv1.ExecuteCommandResponse{RequestId: req.GetRequestId()}, nil
},
}
var reservedDeviceSessionID string
var reservedRequestID string
var reservedTTL time.Duration
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{lookupFunc: func(context.Context, string) (session.Record, error) { return newActiveSessionRecord(), nil }},
ReplayStore: staticReplayStore{
reserveFunc: func(ctx context.Context, deviceSessionID string, requestID string, ttl time.Duration) error {
reservedDeviceSessionID = deviceSessionID
reservedRequestID = requestID
reservedTTL = ttl
return nil
},
},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
response, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequest())
require.NoError(t, err)
assert.Equal(t, "request-123", response.GetRequestId())
assert.Equal(t, "device-session-123", reservedDeviceSessionID)
assert.Equal(t, "request-123", reservedRequestID)
assert.Equal(t, testFreshnessWindow, reservedTTL)
assert.Equal(t, 1, delegate.executeCalls)
}
func TestSubscribeEventsFreshRequestReachesDelegateAndUsesDynamicReplayTTL(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{
subscribeEventsFunc: func(req *gatewayv1.SubscribeEventsRequest, stream grpc.ServerStreamingServer[gatewayv1.GatewayEvent]) error {
return nil
},
}
var reservedTTL time.Duration
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{lookupFunc: func(context.Context, string) (session.Record, error) { return newActiveSessionRecord(), nil }},
ReplayStore: staticReplayStore{
reserveFunc: func(ctx context.Context, deviceSessionID string, requestID string, ttl time.Duration) error {
assert.Equal(t, "device-session-123", deviceSessionID)
assert.Equal(t, "request-123", requestID)
reservedTTL = ttl
return nil
},
},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
stream, err := client.SubscribeEvents(context.Background(), newValidSubscribeEventsRequest())
require.NoError(t, err)
event := recvBootstrapEvent(t, stream)
assertServerTimeBootstrapEvent(t, event, newTestResponseSignerPublicKey(), "request-123", "trace-123", testCurrentTime.UnixMilli())
_, err = stream.Recv()
require.ErrorIs(t, err, io.EOF)
assert.Equal(t, testFreshnessWindow, reservedTTL)
assert.Equal(t, 1, delegate.subscribeCalls)
}
func TestExecuteCommandFutureSkewUsesExtendedReplayTTL(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{
executeCommandFunc: func(ctx context.Context, req *gatewayv1.ExecuteCommandRequest) (*gatewayv1.ExecuteCommandResponse, error) {
return &gatewayv1.ExecuteCommandResponse{RequestId: req.GetRequestId()}, nil
},
}
var reservedTTL time.Duration
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{lookupFunc: func(context.Context, string) (session.Record, error) { return newActiveSessionRecord(), nil }},
ReplayStore: staticReplayStore{
reserveFunc: func(ctx context.Context, deviceSessionID string, requestID string, ttl time.Duration) error {
reservedTTL = ttl
return nil
},
},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(
context.Background(),
newValidExecuteCommandRequestWithTimestamp("device-session-123", "request-123", testCurrentTime.Add(2*time.Minute).UnixMilli()),
)
require.NoError(t, err)
assert.Equal(t, 7*time.Minute, reservedTTL)
assert.Equal(t, 1, delegate.executeCalls)
}
func TestExecuteCommandBoundaryFreshnessUsesMinimumReplayTTL(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{
executeCommandFunc: func(ctx context.Context, req *gatewayv1.ExecuteCommandRequest) (*gatewayv1.ExecuteCommandResponse, error) {
return &gatewayv1.ExecuteCommandResponse{RequestId: req.GetRequestId()}, nil
},
}
var reservedTTL time.Duration
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{lookupFunc: func(context.Context, string) (session.Record, error) { return newActiveSessionRecord(), nil }},
ReplayStore: staticReplayStore{
reserveFunc: func(ctx context.Context, deviceSessionID string, requestID string, ttl time.Duration) error {
reservedTTL = ttl
return nil
},
},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(
context.Background(),
newValidExecuteCommandRequestWithTimestamp("device-session-123", "request-123", testCurrentTime.Add(-testFreshnessWindow).UnixMilli()),
)
require.NoError(t, err)
assert.Equal(t, minimumReplayReservationTTL, reservedTTL)
assert.Equal(t, 1, delegate.executeCalls)
}
func replayDuplicateBySessionAndRequest() func(context.Context, string, string, time.Duration) error {
var (
mu sync.Mutex
seen = make(map[string]struct{})
)
return func(ctx context.Context, deviceSessionID string, requestID string, ttl time.Duration) error {
mu.Lock()
defer mu.Unlock()
key := deviceSessionID + "\x00" + requestID
if _, ok := seen[key]; ok {
return replay.ErrDuplicate
}
seen[key] = struct{}{}
return nil
}
}
+147
View File
@@ -0,0 +1,147 @@
package grpcapi
import (
"context"
"errors"
"path"
"time"
"galaxy/gateway/internal/logging"
"galaxy/gateway/internal/telemetry"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"go.opentelemetry.io/otel/attribute"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func observabilityUnaryInterceptor(logger *zap.Logger, metrics *telemetry.Runtime) grpc.UnaryServerInterceptor {
if logger == nil {
logger = zap.NewNop()
}
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
start := time.Now()
resp, err := handler(ctx, req)
recordGRPCRequest(logger, metrics, ctx, info.FullMethod, req, resp, err, time.Since(start), "unary")
return resp, err
}
}
func observabilityStreamInterceptor(logger *zap.Logger, metrics *telemetry.Runtime) grpc.StreamServerInterceptor {
if logger == nil {
logger = zap.NewNop()
}
return func(srv any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
start := time.Now()
wrapped := &observabilityServerStream{ServerStream: stream}
err := handler(srv, wrapped)
recordGRPCRequest(logger, metrics, stream.Context(), info.FullMethod, wrapped.request, nil, err, time.Since(start), "stream")
return err
}
}
type observabilityServerStream struct {
grpc.ServerStream
request any
}
func (s *observabilityServerStream) RecvMsg(m any) error {
err := s.ServerStream.RecvMsg(m)
if err == nil && s.request == nil {
s.request = m
}
return err
}
func recordGRPCRequest(logger *zap.Logger, metrics *telemetry.Runtime, ctx context.Context, fullMethod string, req any, resp any, err error, duration time.Duration, streamKind string) {
rpcMethod := path.Base(fullMethod)
messageType, requestID, traceID := grpcEnvelopeFields(req)
resultCode := grpcResultCode(resp)
grpcCode, grpcMessage, outcome := grpcOutcome(err)
rejectReason := telemetry.RejectReason(outcome)
attrs := []attribute.KeyValue{
attribute.String("rpc_method", rpcMethod),
attribute.String("message_type", messageType),
attribute.String("edge_outcome", string(outcome)),
}
if resultCode != "" {
attrs = append(attrs, attribute.String("result_code", resultCode))
}
if rejectReason != "" {
attrs = append(attrs, attribute.String("reject_reason", rejectReason))
}
metrics.RecordAuthenticatedGRPC(ctx, attrs, duration)
fields := []zap.Field{
zap.String("component", "authenticated_grpc"),
zap.String("transport", "grpc"),
zap.String("stream_kind", streamKind),
zap.String("rpc_method", rpcMethod),
zap.String("message_type", messageType),
zap.String("grpc_code", grpcCode.String()),
zap.Float64("duration_ms", float64(duration.Microseconds())/1000),
zap.String("request_id", requestID),
zap.String("trace_id", traceID),
zap.String("peer_ip", peerIPFromContext(ctx)),
zap.String("edge_outcome", string(outcome)),
}
if resultCode != "" {
fields = append(fields, zap.String("result_code", resultCode))
}
if rejectReason != "" {
fields = append(fields, zap.String("reject_reason", rejectReason))
}
if grpcMessage != "" {
fields = append(fields, zap.String("grpc_message", grpcMessage))
}
fields = append(fields, logging.TraceFieldsFromContext(ctx)...)
switch outcome {
case telemetry.EdgeOutcomeSuccess:
logger.Info("authenticated gRPC request completed", fields...)
case telemetry.EdgeOutcomeBackendUnavailable, telemetry.EdgeOutcomeDownstreamUnavailable, telemetry.EdgeOutcomeInternalError:
logger.Error("authenticated gRPC request failed", fields...)
default:
logger.Warn("authenticated gRPC request rejected", fields...)
}
}
func grpcEnvelopeFields(req any) (messageType string, requestID string, traceID string) {
switch typed := req.(type) {
case *gatewayv1.ExecuteCommandRequest:
return typed.GetMessageType(), typed.GetRequestId(), typed.GetTraceId()
case *gatewayv1.SubscribeEventsRequest:
return typed.GetMessageType(), typed.GetRequestId(), typed.GetTraceId()
default:
return "", "", ""
}
}
func grpcResultCode(resp any) string {
typed, ok := resp.(*gatewayv1.ExecuteCommandResponse)
if !ok {
return ""
}
return typed.GetResultCode()
}
func grpcOutcome(err error) (codes.Code, string, telemetry.EdgeOutcome) {
switch {
case err == nil:
return codes.OK, "", telemetry.EdgeOutcomeSuccess
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
return codes.Canceled, err.Error(), telemetry.EdgeOutcomeSuccess
default:
grpcStatus := status.Convert(err)
return grpcStatus.Code(), grpcStatus.Message(), telemetry.OutcomeFromGRPCStatus(grpcStatus.Code(), grpcStatus.Message())
}
}
+66
View File
@@ -0,0 +1,66 @@
package grpcapi
import (
"context"
"errors"
"galaxy/gateway/internal/authn"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// payloadHashVerifyingService applies payload-hash verification after session
// lookup and before any later auth or routing step runs.
type payloadHashVerifyingService struct {
gatewayv1.UnimplementedEdgeGatewayServer
delegate gatewayv1.EdgeGatewayServer
}
// ExecuteCommand verifies req payload integrity before delegating to the
// configured service implementation.
func (s payloadHashVerifyingService) ExecuteCommand(ctx context.Context, req *gatewayv1.ExecuteCommandRequest) (*gatewayv1.ExecuteCommandResponse, error) {
if err := verifyPayloadHash(ctx); err != nil {
return nil, err
}
return s.delegate.ExecuteCommand(ctx, req)
}
// SubscribeEvents verifies req payload integrity before delegating to the
// configured service implementation.
func (s payloadHashVerifyingService) SubscribeEvents(req *gatewayv1.SubscribeEventsRequest, stream grpc.ServerStreamingServer[gatewayv1.GatewayEvent]) error {
if err := verifyPayloadHash(stream.Context()); err != nil {
return err
}
return s.delegate.SubscribeEvents(req, stream)
}
// newPayloadHashVerifyingService wraps delegate with the payload-hash
// verification gate.
func newPayloadHashVerifyingService(delegate gatewayv1.EdgeGatewayServer) gatewayv1.EdgeGatewayServer {
return payloadHashVerifyingService{delegate: delegate}
}
func verifyPayloadHash(ctx context.Context) error {
envelope, ok := parsedEnvelopeFromContext(ctx)
if !ok {
return status.Error(codes.Internal, "authenticated request context is incomplete")
}
err := authn.VerifyPayloadHash(envelope.PayloadBytes, envelope.PayloadHash)
switch {
case err == nil:
return nil
case errors.Is(err, authn.ErrInvalidPayloadHash), errors.Is(err, authn.ErrPayloadHashMismatch):
return status.Error(codes.InvalidArgument, err.Error())
default:
return status.Error(codes.Internal, "payload hash verification failed")
}
}
var _ gatewayv1.EdgeGatewayServer = payloadHashVerifyingService{}
@@ -0,0 +1,125 @@
package grpcapi
import (
"context"
"crypto/sha256"
"testing"
"galaxy/gateway/internal/session"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestExecuteCommandRejectsPayloadHashWithInvalidLength(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{lookupFunc: func(context.Context, string) (session.Record, error) { return newActiveSessionRecord(), nil }},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
req := newValidExecuteCommandRequest()
req.PayloadHash = []byte("short")
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), req)
require.Error(t, err)
assert.Equal(t, codes.InvalidArgument, status.Code(err))
assert.Equal(t, "payload_hash must be a 32-byte SHA-256 digest", status.Convert(err).Message())
assert.Zero(t, delegate.executeCalls)
}
func TestExecuteCommandRejectsPayloadHashMismatch(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{lookupFunc: func(context.Context, string) (session.Record, error) { return newActiveSessionRecord(), nil }},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
req := newValidExecuteCommandRequest()
sum := sha256.Sum256([]byte("other"))
req.PayloadHash = sum[:]
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), req)
require.Error(t, err)
assert.Equal(t, codes.InvalidArgument, status.Code(err))
assert.Equal(t, "payload_hash does not match payload_bytes", status.Convert(err).Message())
assert.Zero(t, delegate.executeCalls)
}
func TestSubscribeEventsRejectsPayloadHashWithInvalidLength(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{lookupFunc: func(context.Context, string) (session.Record, error) { return newActiveSessionRecord(), nil }},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
req := newValidSubscribeEventsRequest()
req.PayloadHash = []byte("short")
client := gatewayv1.NewEdgeGatewayClient(conn)
err := subscribeEventsError(t, context.Background(), client, req)
require.Error(t, err)
assert.Equal(t, codes.InvalidArgument, status.Code(err))
assert.Equal(t, "payload_hash must be a 32-byte SHA-256 digest", status.Convert(err).Message())
assert.Zero(t, delegate.subscribeCalls)
}
func TestSubscribeEventsRejectsPayloadHashMismatch(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{lookupFunc: func(context.Context, string) (session.Record, error) { return newActiveSessionRecord(), nil }},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
req := newValidSubscribeEventsRequest()
sum := sha256.Sum256([]byte("other"))
req.PayloadHash = sum[:]
client := gatewayv1.NewEdgeGatewayClient(conn)
err := subscribeEventsError(t, context.Background(), client, req)
require.Error(t, err)
assert.Equal(t, codes.InvalidArgument, status.Code(err))
assert.Equal(t, "payload_hash does not match payload_bytes", status.Convert(err).Message())
assert.Zero(t, delegate.subscribeCalls)
}
+172
View File
@@ -0,0 +1,172 @@
package grpcapi
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"galaxy/gateway/internal/authn"
"galaxy/gateway/internal/clock"
"galaxy/gateway/internal/logging"
"galaxy/gateway/internal/push"
"galaxy/gateway/internal/telemetry"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// NewFanOutPushStreamService constructs the authenticated SubscribeEvents tail
// service that registers active streams in hub and forwards client-facing
// events after the bootstrap event has been sent.
func NewFanOutPushStreamService(hub *push.Hub, responseSigner authn.ResponseSigner, clk clock.Clock, logger *zap.Logger) gatewayv1.EdgeGatewayServer {
if responseSigner == nil {
responseSigner = unavailableResponseSigner{}
}
if clk == nil {
clk = clock.System{}
}
if logger == nil {
logger = zap.NewNop()
}
return fanOutPushStreamService{
hub: hub,
responseSigner: responseSigner,
clock: clk,
logger: logger.Named("push_stream"),
}
}
// fanOutPushStreamService owns the post-bootstrap authenticated push-stream
// lifecycle backed by the in-memory push hub.
type fanOutPushStreamService struct {
gatewayv1.UnimplementedEdgeGatewayServer
hub *push.Hub
responseSigner authn.ResponseSigner
clock clock.Clock
logger *zap.Logger
}
// SubscribeEvents registers the verified stream in the push hub and forwards
// matching client-facing events until the stream ends.
func (s fanOutPushStreamService) SubscribeEvents(_ *gatewayv1.SubscribeEventsRequest, stream grpc.ServerStreamingServer[gatewayv1.GatewayEvent]) error {
binding, ok := authenticatedStreamBindingFromContext(stream.Context())
if !ok {
return status.Error(codes.Internal, "authenticated request context is incomplete")
}
if s.hub == nil {
return status.Error(codes.Internal, "push hub is unavailable")
}
subscription, err := s.hub.Register(push.StreamBinding{
UserID: binding.UserID,
DeviceSessionID: binding.DeviceSessionID,
})
if err != nil {
return status.Error(codes.Internal, "push stream registration failed")
}
defer subscription.Close()
openFields := []zap.Field{
zap.String("component", "authenticated_grpc"),
zap.String("transport", "grpc"),
zap.String("rpc_method", authenticatedRPCSubscribeEvents),
zap.String("message_type", binding.MessageType),
zap.String("request_id", binding.RequestID),
zap.String("trace_id", binding.TraceID),
zap.String("device_session_id", binding.DeviceSessionID),
zap.String("user_id", binding.UserID),
}
openFields = append(openFields, logging.TraceFieldsFromContext(stream.Context())...)
s.logger.Info("push stream opened", openFields...)
for {
select {
case <-stream.Context().Done():
s.logger.Info("push stream closed", append(openFields, zap.String("edge_outcome", string(mapSubscriptionOutcome(stream.Context().Err()))))...)
return stream.Context().Err()
case <-subscription.Done():
subscriptionErr := subscription.Err()
s.logger.Warn("push stream closed", append(openFields,
zap.String("edge_outcome", string(mapSubscriptionOutcome(subscriptionErr))),
zap.String("reject_reason", string(mapSubscriptionOutcome(subscriptionErr))),
)...)
return mapSubscriptionError(subscriptionErr)
case event := <-subscription.Events():
signedEvent, err := s.buildGatewayEvent(event)
if err != nil {
return err
}
if err := stream.Send(signedEvent); err != nil {
return err
}
}
}
}
func (s fanOutPushStreamService) buildGatewayEvent(event push.Event) (*gatewayv1.GatewayEvent, error) {
timestampMS := s.clock.Now().UTC().UnixMilli()
payloadHash := sha256.Sum256(event.PayloadBytes)
signature, err := s.responseSigner.SignEvent(authn.EventSigningFields{
EventType: event.EventType,
EventID: event.EventID,
TimestampMS: timestampMS,
RequestID: event.RequestID,
TraceID: event.TraceID,
PayloadHash: payloadHash[:],
})
if err != nil {
return nil, status.Error(codes.Unavailable, "response signer is unavailable")
}
return &gatewayv1.GatewayEvent{
EventType: event.EventType,
EventId: event.EventID,
TimestampMs: timestampMS,
PayloadBytes: bytes.Clone(event.PayloadBytes),
PayloadHash: bytes.Clone(payloadHash[:]),
Signature: signature,
RequestId: event.RequestID,
TraceId: event.TraceID,
}, nil
}
func mapSubscriptionError(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, push.ErrSubscriptionRevoked):
return status.Error(codes.FailedPrecondition, "device session is revoked")
case errors.Is(err, push.ErrSubscriptionOverflow):
return status.Error(codes.ResourceExhausted, "push stream overflowed")
case errors.Is(err, push.ErrHubShuttingDown):
return status.Error(codes.Unavailable, "gateway is shutting down")
default:
return status.Error(codes.Internal, "push stream closed unexpectedly")
}
}
func mapSubscriptionOutcome(err error) telemetry.EdgeOutcome {
switch {
case err == nil:
return telemetry.EdgeOutcomeSuccess
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
return telemetry.EdgeOutcomeSuccess
case errors.Is(err, push.ErrSubscriptionRevoked):
return telemetry.EdgeOutcomeRevokedSession
case errors.Is(err, push.ErrSubscriptionOverflow):
return telemetry.EdgeOutcomeRateLimited
case errors.Is(err, push.ErrHubShuttingDown):
return telemetry.EdgeOutcomeGatewayShuttingDown
default:
return telemetry.EdgeOutcomeInternalError
}
}
var _ gatewayv1.EdgeGatewayServer = fanOutPushStreamService{}
+164
View File
@@ -0,0 +1,164 @@
package grpcapi
import (
"bytes"
"context"
"crypto/sha256"
"galaxy/gateway/internal/authn"
"galaxy/gateway/internal/clock"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
gatewayfbs "galaxy/schema/fbs/gateway"
flatbuffers "github.com/google/flatbuffers/go"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const serverTimeEventType = "gateway.server_time"
// authenticatedStreamBinding captures the verified identity bound to one
// authenticated SubscribeEvents stream after the full ingress pipeline
// succeeds.
type authenticatedStreamBinding struct {
UserID string
DeviceSessionID string
MessageType string
RequestID string
TraceID string
}
// authenticatedStreamBindingFromContext returns the verified stream binding
// previously attached to ctx by the authenticated push-stream service.
func authenticatedStreamBindingFromContext(ctx context.Context) (authenticatedStreamBinding, bool) {
if ctx == nil {
return authenticatedStreamBinding{}, false
}
binding, ok := ctx.Value(authenticatedStreamBindingContextKey{}).(authenticatedStreamBinding)
if !ok {
return authenticatedStreamBinding{}, false
}
return binding, true
}
// authenticatedPushStreamService owns SubscribeEvents bootstrap behavior:
// bind the authenticated stream, send the initial signed server-time event,
// and then hand the stream lifecycle to the configured tail delegate.
type authenticatedPushStreamService struct {
gatewayv1.UnimplementedEdgeGatewayServer
tailDelegate gatewayv1.EdgeGatewayServer
responseSigner authn.ResponseSigner
clock clock.Clock
}
// SubscribeEvents binds the verified stream identity, sends the initial signed
// server-time event, and then delegates the remaining lifecycle.
func (s authenticatedPushStreamService) SubscribeEvents(req *gatewayv1.SubscribeEventsRequest, stream grpc.ServerStreamingServer[gatewayv1.GatewayEvent]) error {
envelope, ok := parsedEnvelopeFromContext(stream.Context())
if !ok {
return status.Error(codes.Internal, "authenticated request context is incomplete")
}
record, ok := resolvedSessionFromContext(stream.Context())
if !ok {
return status.Error(codes.Internal, "authenticated request context is incomplete")
}
binding := authenticatedStreamBinding{
UserID: record.UserID,
DeviceSessionID: record.DeviceSessionID,
MessageType: envelope.MessageType,
RequestID: envelope.RequestID,
TraceID: envelope.TraceID,
}
boundStream := authenticatedStreamContextStream{
ServerStreamingServer: stream,
ctx: context.WithValue(
stream.Context(),
authenticatedStreamBindingContextKey{},
binding,
),
}
serverTimeMS := s.clock.Now().UTC().UnixMilli()
payloadBytes := buildServerTimeEventPayload(serverTimeMS)
payloadHash := sha256.Sum256(payloadBytes)
signature, err := s.responseSigner.SignEvent(authn.EventSigningFields{
EventType: serverTimeEventType,
EventID: envelope.RequestID,
TimestampMS: serverTimeMS,
RequestID: envelope.RequestID,
TraceID: envelope.TraceID,
PayloadHash: payloadHash[:],
})
if err != nil {
return status.Error(codes.Unavailable, "response signer is unavailable")
}
if err := boundStream.Send(&gatewayv1.GatewayEvent{
EventType: serverTimeEventType,
EventId: envelope.RequestID,
TimestampMs: serverTimeMS,
PayloadBytes: bytes.Clone(payloadBytes),
PayloadHash: bytes.Clone(payloadHash[:]),
Signature: signature,
RequestId: envelope.RequestID,
TraceId: envelope.TraceID,
}); err != nil {
return err
}
return s.tailDelegate.SubscribeEvents(req, boundStream)
}
func newAuthenticatedPushStreamService(tailDelegate gatewayv1.EdgeGatewayServer, responseSigner authn.ResponseSigner, clk clock.Clock) gatewayv1.EdgeGatewayServer {
if tailDelegate == nil {
tailDelegate = holdOpenSubscribeEventsService{}
}
return authenticatedPushStreamService{
tailDelegate: tailDelegate,
responseSigner: responseSigner,
clock: clk,
}
}
func buildServerTimeEventPayload(serverTimeMS int64) []byte {
builder := flatbuffers.NewBuilder(32)
gatewayfbs.ServerTimeEventStart(builder)
gatewayfbs.ServerTimeEventAddServerTimeMs(builder, serverTimeMS)
eventOffset := gatewayfbs.ServerTimeEventEnd(builder)
gatewayfbs.FinishServerTimeEventBuffer(builder, eventOffset)
return bytes.Clone(builder.FinishedBytes())
}
type authenticatedStreamBindingContextKey struct{}
type authenticatedStreamContextStream struct {
grpc.ServerStreamingServer[gatewayv1.GatewayEvent]
ctx context.Context
}
func (s authenticatedStreamContextStream) Context() context.Context {
if s.ctx == nil {
return context.Background()
}
return s.ctx
}
type holdOpenSubscribeEventsService struct {
gatewayv1.UnimplementedEdgeGatewayServer
}
func (holdOpenSubscribeEventsService) SubscribeEvents(_ *gatewayv1.SubscribeEventsRequest, stream grpc.ServerStreamingServer[gatewayv1.GatewayEvent]) error {
<-stream.Context().Done()
return stream.Context().Err()
}
var _ gatewayv1.EdgeGatewayServer = authenticatedPushStreamService{}
+286
View File
@@ -0,0 +1,286 @@
package grpcapi
import (
"context"
"errors"
"net"
"strings"
"galaxy/gateway/internal/config"
"galaxy/gateway/internal/ratelimit"
"galaxy/gateway/internal/session"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
)
const (
authenticatedGRPCBaseBucketKeyPrefix = "authenticated_grpc/"
authenticatedGRPCIPBucketKeySegment = authenticatedGRPCBaseBucketKeyPrefix + "ip="
authenticatedGRPCSessionBucketKeySegment = authenticatedGRPCBaseBucketKeyPrefix + "session="
authenticatedGRPCUserBucketKeySegment = authenticatedGRPCBaseBucketKeyPrefix + "user="
authenticatedGRPCMessageClassBucketKeySegment = authenticatedGRPCBaseBucketKeyPrefix + "message_class="
unknownAuthenticatedPeerIP = "unknown"
authenticatedRPCExecuteCommand = "ExecuteCommand"
authenticatedRPCSubscribeEvents = "SubscribeEvents"
)
var (
// ErrAuthenticatedPolicyDenied reports that the authenticated request was
// rejected by later edge policy after transport authenticity succeeded.
ErrAuthenticatedPolicyDenied = errors.New("authenticated request rejected by edge policy")
// ErrAuthenticatedPolicyUnavailable reports that authenticated policy could
// not be evaluated because its backing dependency is unavailable.
ErrAuthenticatedPolicyUnavailable = errors.New("authenticated request policy is unavailable")
)
// AuthenticatedRequestLimiter applies authenticated gRPC rate-limit policy to
// one concrete bucket key.
type AuthenticatedRequestLimiter interface {
// Reserve evaluates key under policy and reports whether the request may
// proceed immediately.
Reserve(key string, policy ratelimit.Policy) ratelimit.Decision
}
// AuthenticatedRequest describes the authenticated request metadata exposed to
// the edge-policy hook.
type AuthenticatedRequest struct {
// RPCMethod identifies the public gRPC method being processed.
RPCMethod string
// PeerIP is the transport peer IP derived from the gRPC connection.
PeerIP string
// MessageClass is the stable rate-limit and policy class. The gateway uses
// the full message_type literal because the v1 transport does not yet define
// a coarser authenticated class taxonomy.
MessageClass string
// Envelope contains the verified transport envelope fields used by later
// edge policy.
Envelope AuthenticatedRequestEnvelope
// Session contains the authenticated identity resolved from SessionCache.
Session session.Record
}
// AuthenticatedRequestEnvelope describes the verified request envelope fields
// exposed to the edge-policy hook.
type AuthenticatedRequestEnvelope struct {
// ProtocolVersion is the supported transport protocol version literal.
ProtocolVersion string
// DeviceSessionID is the authenticated device-session identifier.
DeviceSessionID string
// MessageType is the verified downstream routing key supplied by the client.
MessageType string
// TimestampMS is the client timestamp that already passed freshness checks.
TimestampMS int64
// RequestID is the authenticated transport request identifier.
RequestID string
// TraceID is the optional client-supplied correlation identifier.
TraceID string
}
// AuthenticatedRequestPolicy evaluates later authenticated edge policy after
// transport authenticity and rate-limit checks succeed.
type AuthenticatedRequestPolicy interface {
// Evaluate returns nil when the authenticated request may proceed. It should
// wrap ErrAuthenticatedPolicyDenied for stable reject mapping and
// ErrAuthenticatedPolicyUnavailable when its backing dependency is
// temporarily unavailable.
Evaluate(ctx context.Context, request AuthenticatedRequest) error
}
type authenticatedRateLimitService struct {
gatewayv1.UnimplementedEdgeGatewayServer
delegate gatewayv1.EdgeGatewayServer
limiter AuthenticatedRequestLimiter
policy AuthenticatedRequestPolicy
cfg config.AuthenticatedGRPCAntiAbuseConfig
}
// ExecuteCommand applies authenticated rate limits and edge policy before
// delegating to the configured service implementation.
func (s authenticatedRateLimitService) ExecuteCommand(ctx context.Context, req *gatewayv1.ExecuteCommandRequest) (*gatewayv1.ExecuteCommandResponse, error) {
if err := s.applyRateLimitsAndPolicy(ctx, authenticatedRPCExecuteCommand); err != nil {
return nil, err
}
return s.delegate.ExecuteCommand(ctx, req)
}
// SubscribeEvents applies authenticated rate limits and edge policy before
// delegating to the configured service implementation.
func (s authenticatedRateLimitService) SubscribeEvents(req *gatewayv1.SubscribeEventsRequest, stream grpc.ServerStreamingServer[gatewayv1.GatewayEvent]) error {
if err := s.applyRateLimitsAndPolicy(stream.Context(), authenticatedRPCSubscribeEvents); err != nil {
return err
}
return s.delegate.SubscribeEvents(req, stream)
}
// newAuthenticatedRateLimitService wraps delegate with the authenticated
// rate-limit and edge-policy gate.
func newAuthenticatedRateLimitService(delegate gatewayv1.EdgeGatewayServer, limiter AuthenticatedRequestLimiter, policy AuthenticatedRequestPolicy, cfg config.AuthenticatedGRPCAntiAbuseConfig) gatewayv1.EdgeGatewayServer {
return authenticatedRateLimitService{
delegate: delegate,
limiter: limiter,
policy: policy,
cfg: cfg,
}
}
func (s authenticatedRateLimitService) applyRateLimitsAndPolicy(ctx context.Context, rpcMethod string) error {
request, err := authenticatedRequestFromContext(ctx, rpcMethod)
if err != nil {
return err
}
if err := s.applyRateLimits(request); err != nil {
return err
}
if err := s.applyPolicy(ctx, request); err != nil {
return err
}
return nil
}
func (s authenticatedRateLimitService) applyRateLimits(request AuthenticatedRequest) error {
checks := []struct {
key string
policy config.AuthenticatedRateLimitConfig
}{
{
key: authenticatedGRPCIPBucketKey(request.PeerIP),
policy: s.cfg.IP,
},
{
key: authenticatedGRPCSessionBucketKey(request.Envelope.DeviceSessionID),
policy: s.cfg.Session,
},
{
key: authenticatedGRPCUserBucketKey(request.Session.UserID),
policy: s.cfg.User,
},
{
key: authenticatedGRPCMessageClassBucketKey(request.MessageClass),
policy: s.cfg.MessageClass,
},
}
for _, check := range checks {
decision := s.limiter.Reserve(check.key, ratelimit.Policy{
Requests: check.policy.Requests,
Window: check.policy.Window,
Burst: check.policy.Burst,
})
if !decision.Allowed {
return status.Error(codes.ResourceExhausted, "authenticated request rate limit exceeded")
}
}
return nil
}
func (s authenticatedRateLimitService) applyPolicy(ctx context.Context, request AuthenticatedRequest) error {
err := s.policy.Evaluate(ctx, request)
switch {
case err == nil:
return nil
case errors.Is(err, ErrAuthenticatedPolicyDenied):
return status.Error(codes.PermissionDenied, "authenticated request rejected by edge policy")
case errors.Is(err, ErrAuthenticatedPolicyUnavailable):
return status.Error(codes.Unavailable, "authenticated request policy is unavailable")
default:
return status.Error(codes.Internal, "authenticated request policy evaluation failed")
}
}
func authenticatedRequestFromContext(ctx context.Context, rpcMethod string) (AuthenticatedRequest, error) {
envelope, ok := parsedEnvelopeFromContext(ctx)
if !ok {
return AuthenticatedRequest{}, status.Error(codes.Internal, "authenticated request context is incomplete")
}
record, ok := resolvedSessionFromContext(ctx)
if !ok {
return AuthenticatedRequest{}, status.Error(codes.Internal, "authenticated request context is incomplete")
}
return AuthenticatedRequest{
RPCMethod: rpcMethod,
PeerIP: peerIPFromContext(ctx),
MessageClass: authenticatedMessageClass(envelope.MessageType),
Envelope: AuthenticatedRequestEnvelope{
ProtocolVersion: envelope.ProtocolVersion,
DeviceSessionID: envelope.DeviceSessionID,
MessageType: envelope.MessageType,
TimestampMS: envelope.TimestampMS,
RequestID: envelope.RequestID,
TraceID: envelope.TraceID,
},
Session: record,
}, nil
}
func authenticatedGRPCIPBucketKey(peerIP string) string {
return authenticatedGRPCIPBucketKeySegment + peerIP
}
func authenticatedGRPCSessionBucketKey(deviceSessionID string) string {
return authenticatedGRPCSessionBucketKeySegment + deviceSessionID
}
func authenticatedGRPCUserBucketKey(userID string) string {
return authenticatedGRPCUserBucketKeySegment + userID
}
func authenticatedGRPCMessageClassBucketKey(messageClass string) string {
return authenticatedGRPCMessageClassBucketKeySegment + messageClass
}
func authenticatedMessageClass(messageType string) string {
return messageType
}
func peerIPFromContext(ctx context.Context) string {
peerInfo, ok := peer.FromContext(ctx)
if !ok || peerInfo.Addr == nil {
return unknownAuthenticatedPeerIP
}
value := strings.TrimSpace(peerInfo.Addr.String())
if value == "" {
return unknownAuthenticatedPeerIP
}
host, _, err := net.SplitHostPort(value)
if err == nil && host != "" {
return host
}
return value
}
type noopAuthenticatedRequestPolicy struct{}
func (noopAuthenticatedRequestPolicy) Evaluate(context.Context, AuthenticatedRequest) error {
return nil
}
var _ gatewayv1.EdgeGatewayServer = authenticatedRateLimitService{}
@@ -0,0 +1,497 @@
package grpcapi
import (
"context"
"fmt"
"io"
"net"
"net/http"
"strings"
"testing"
"time"
"galaxy/gateway/internal/app"
"galaxy/gateway/internal/config"
"galaxy/gateway/internal/ratelimit"
"galaxy/gateway/internal/restapi"
"galaxy/gateway/internal/session"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestExecuteCommandRateLimitsByIP(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGatewayWithGRPCConfig(t, newAuthenticatedGRPCConfigForTest(func(cfg *config.AuthenticatedGRPCConfig) {
cfg.AntiAbuse.IP = config.AuthenticatedRateLimitConfig{
Requests: 1,
Window: time.Hour,
Burst: 1,
}
}), ServerDependencies{
Service: delegate,
SessionCache: userMappedSessionCache(map[string]string{"device-session-1": "user-1", "device-session-2": "user-2"}),
ReplayStore: staticReplayStore{},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequestWithSessionAndRequestID("device-session-1", "request-1"))
require.NoError(t, err)
_, err = client.ExecuteCommand(context.Background(), newValidExecuteCommandRequestWithSessionAndRequestID("device-session-2", "request-2"))
require.Error(t, err)
assert.Equal(t, codes.ResourceExhausted, status.Code(err))
assert.Equal(t, "authenticated request rate limit exceeded", status.Convert(err).Message())
assert.Equal(t, 1, delegate.executeCalls)
}
func TestExecuteCommandRateLimitsBySession(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGatewayWithGRPCConfig(t, newAuthenticatedGRPCConfigForTest(func(cfg *config.AuthenticatedGRPCConfig) {
cfg.AntiAbuse.Session = config.AuthenticatedRateLimitConfig{
Requests: 1,
Window: time.Hour,
Burst: 1,
}
}), ServerDependencies{
Service: delegate,
SessionCache: userMappedSessionCache(map[string]string{"device-session-1": "user-1", "device-session-2": "user-1"}),
ReplayStore: staticReplayStore{},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequestWithSessionAndRequestID("device-session-1", "request-1"))
require.NoError(t, err)
_, err = client.ExecuteCommand(context.Background(), newValidExecuteCommandRequestWithSessionAndRequestID("device-session-1", "request-2"))
require.Error(t, err)
assert.Equal(t, codes.ResourceExhausted, status.Code(err))
_, err = client.ExecuteCommand(context.Background(), newValidExecuteCommandRequestWithSessionAndRequestID("device-session-2", "request-3"))
require.NoError(t, err)
assert.Equal(t, 2, delegate.executeCalls)
}
func TestExecuteCommandRateLimitsByUser(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGatewayWithGRPCConfig(t, newAuthenticatedGRPCConfigForTest(func(cfg *config.AuthenticatedGRPCConfig) {
cfg.AntiAbuse.User = config.AuthenticatedRateLimitConfig{
Requests: 1,
Window: time.Hour,
Burst: 1,
}
}), ServerDependencies{
Service: delegate,
SessionCache: userMappedSessionCache(map[string]string{
"device-session-1": "user-shared",
"device-session-2": "user-shared",
"device-session-3": "user-other",
}),
ReplayStore: staticReplayStore{},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequestWithSessionAndRequestID("device-session-1", "request-1"))
require.NoError(t, err)
_, err = client.ExecuteCommand(context.Background(), newValidExecuteCommandRequestWithSessionAndRequestID("device-session-2", "request-2"))
require.Error(t, err)
assert.Equal(t, codes.ResourceExhausted, status.Code(err))
_, err = client.ExecuteCommand(context.Background(), newValidExecuteCommandRequestWithSessionAndRequestID("device-session-3", "request-3"))
require.NoError(t, err)
assert.Equal(t, 2, delegate.executeCalls)
}
func TestExecuteCommandRateLimitsByMessageClass(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGatewayWithGRPCConfig(t, newAuthenticatedGRPCConfigForTest(func(cfg *config.AuthenticatedGRPCConfig) {
cfg.AntiAbuse.MessageClass = config.AuthenticatedRateLimitConfig{
Requests: 1,
Window: time.Hour,
Burst: 1,
}
}), ServerDependencies{
Service: delegate,
SessionCache: userMappedSessionCache(map[string]string{
"device-session-1": "user-1",
"device-session-2": "user-2",
}),
ReplayStore: staticReplayStore{},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequestWithMessageType("device-session-1", "request-1", "fleet.move"))
require.NoError(t, err)
_, err = client.ExecuteCommand(context.Background(), newValidExecuteCommandRequestWithMessageType("device-session-2", "request-2", "fleet.move"))
require.Error(t, err)
assert.Equal(t, codes.ResourceExhausted, status.Code(err))
_, err = client.ExecuteCommand(context.Background(), newValidExecuteCommandRequestWithMessageType("device-session-2", "request-3", "fleet.rename"))
require.NoError(t, err)
assert.Equal(t, 2, delegate.executeCalls)
}
func TestAuthenticatedPolicyHookReceivesVerifiedRequest(t *testing.T) {
t.Parallel()
policy := &recordingAuthenticatedRequestPolicy{}
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: userMappedSessionCache(map[string]string{"device-session-123": "user-123"}),
ReplayStore: staticReplayStore{},
Policy: policy,
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequest())
require.NoError(t, err)
require.Len(t, policy.requests, 1)
assert.Equal(t, authenticatedRPCExecuteCommand, policy.requests[0].RPCMethod)
assert.Equal(t, "127.0.0.1", policy.requests[0].PeerIP)
assert.Equal(t, "fleet.move", policy.requests[0].MessageClass)
assert.Equal(t, "device-session-123", policy.requests[0].Envelope.DeviceSessionID)
assert.Equal(t, "request-123", policy.requests[0].Envelope.RequestID)
assert.Equal(t, "trace-123", policy.requests[0].Envelope.TraceID)
assert.Equal(t, "user-123", policy.requests[0].Session.UserID)
assert.Equal(t, 1, delegate.executeCalls)
}
func TestExecuteCommandPolicyRejectMapsToPermissionDenied(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: userMappedSessionCache(map[string]string{"device-session-123": "user-123"}),
ReplayStore: staticReplayStore{},
Policy: authenticatedRequestPolicyFunc(func(context.Context, AuthenticatedRequest) error {
return fmt.Errorf("policy deny: %w", ErrAuthenticatedPolicyDenied)
}),
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequest())
require.Error(t, err)
assert.Equal(t, codes.PermissionDenied, status.Code(err))
assert.Equal(t, "authenticated request rejected by edge policy", status.Convert(err).Message())
assert.Zero(t, delegate.executeCalls)
}
func TestSubscribeEventsRateLimitRejectsStream(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGatewayWithGRPCConfig(t, newAuthenticatedGRPCConfigForTest(func(cfg *config.AuthenticatedGRPCConfig) {
cfg.AntiAbuse.IP = config.AuthenticatedRateLimitConfig{
Requests: 1,
Window: time.Hour,
Burst: 1,
}
}), ServerDependencies{
Service: delegate,
SessionCache: userMappedSessionCache(map[string]string{"device-session-1": "user-1", "device-session-2": "user-2"}),
ReplayStore: staticReplayStore{},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
stream, err := client.SubscribeEvents(context.Background(), newValidSubscribeEventsRequestWithSessionAndRequestID("device-session-1", "request-1"))
require.NoError(t, err)
event := recvBootstrapEvent(t, stream)
assertServerTimeBootstrapEvent(t, event, newTestResponseSignerPublicKey(), "request-1", "trace-123", testCurrentTime.UnixMilli())
_, err = stream.Recv()
require.ErrorIs(t, err, io.EOF)
err = subscribeEventsError(t, context.Background(), client, newValidSubscribeEventsRequestWithSessionAndRequestID("device-session-2", "request-2"))
require.Error(t, err)
assert.Equal(t, codes.ResourceExhausted, status.Code(err))
assert.Equal(t, "authenticated request rate limit exceeded", status.Convert(err).Message())
assert.Equal(t, 1, delegate.subscribeCalls)
}
func TestAuthenticatedRateLimitsStayIsolatedFromPublicREST(t *testing.T) {
t.Parallel()
sharedLimiter := ratelimit.NewInMemory()
publicCfg := config.DefaultPublicHTTPConfig()
publicCfg.Addr = unusedTCPAddr(t)
publicCfg.AntiAbuse.PublicAuth.RateLimit = config.PublicRateLimitConfig{
Requests: 1,
Window: time.Hour,
Burst: 1,
}
publicCfg.AntiAbuse.SendEmailCodeIdentity.RateLimit = config.PublicRateLimitConfig{
Requests: 100,
Window: time.Hour,
Burst: 100,
}
grpcCfg := newAuthenticatedGRPCConfigForTest(func(cfg *config.AuthenticatedGRPCConfig) {
cfg.Addr = unusedTCPAddr(t)
cfg.AntiAbuse.IP = config.AuthenticatedRateLimitConfig{
Requests: 1,
Window: time.Hour,
Burst: 1,
}
})
restServer := restapi.NewServer(publicCfg, restapi.ServerDependencies{
AuthService: staticAuthServiceClient{},
Limiter: publicLimiterAdapter{limiter: sharedLimiter},
})
delegate := &recordingEdgeGatewayService{}
grpcServer := NewServer(grpcCfg, ServerDependencies{
Service: delegate,
Router: executeCommandAdapterRouter{service: delegate},
ResponseSigner: newTestResponseSigner(),
SessionCache: userMappedSessionCache(map[string]string{"device-session-123": "user-123"}),
ReplayStore: staticReplayStore{},
Limiter: sharedLimiter,
Clock: fixedClock{now: testCurrentTime},
})
application := app.New(config.Config{ShutdownTimeout: time.Second}, restServer, grpcServer)
ctx, cancel := context.WithCancel(context.Background())
resultCh := make(chan error, 1)
go func() {
resultCh <- application.Run(ctx)
}()
runGateway := runningGateway{cancel: cancel, resultCh: resultCh}
defer runGateway.stop(t)
waitForHTTPHealthz(t, "http://"+publicCfg.Addr+"/healthz")
addr := waitForListenAddr(t, grpcServer)
firstPublic := sendPublicAuthRequest(t, "http://"+publicCfg.Addr+"/api/v1/public/auth/send-email-code")
secondPublic := sendPublicAuthRequest(t, "http://"+publicCfg.Addr+"/api/v1/public/auth/send-email-code")
assert.Equal(t, http.StatusOK, firstPublic.StatusCode)
assert.Equal(t, http.StatusTooManyRequests, secondPublic.StatusCode)
require.NoError(t, firstPublic.Body.Close())
require.NoError(t, secondPublic.Body.Close())
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequest())
require.NoError(t, err)
}
func newAuthenticatedGRPCConfigForTest(mutate func(*config.AuthenticatedGRPCConfig)) config.AuthenticatedGRPCConfig {
cfg := config.DefaultAuthenticatedGRPCConfig()
cfg.Addr = "127.0.0.1:0"
cfg.FreshnessWindow = testFreshnessWindow
cfg.AntiAbuse.IP = config.AuthenticatedRateLimitConfig{
Requests: 100,
Window: time.Hour,
Burst: 100,
}
cfg.AntiAbuse.Session = config.AuthenticatedRateLimitConfig{
Requests: 100,
Window: time.Hour,
Burst: 100,
}
cfg.AntiAbuse.User = config.AuthenticatedRateLimitConfig{
Requests: 100,
Window: time.Hour,
Burst: 100,
}
cfg.AntiAbuse.MessageClass = config.AuthenticatedRateLimitConfig{
Requests: 100,
Window: time.Hour,
Burst: 100,
}
if mutate != nil {
mutate(&cfg)
}
return cfg
}
func newValidExecuteCommandRequestWithMessageType(deviceSessionID string, requestID string, messageType string) *gatewayv1.ExecuteCommandRequest {
req := newValidExecuteCommandRequestWithSessionAndRequestID(deviceSessionID, requestID)
req.MessageType = messageType
req.Signature = signRequest(
req.GetProtocolVersion(),
req.GetDeviceSessionId(),
req.GetMessageType(),
req.GetTimestampMs(),
req.GetRequestId(),
req.GetPayloadHash(),
)
return req
}
func userMappedSessionCache(users map[string]string) staticSessionCache {
return staticSessionCache{
lookupFunc: func(_ context.Context, deviceSessionID string) (session.Record, error) {
userID, ok := users[deviceSessionID]
if !ok {
return session.Record{}, session.ErrNotFound
}
record := newActiveSessionRecordWithSessionID(deviceSessionID)
record.UserID = userID
return record, nil
},
}
}
type authenticatedRequestPolicyFunc func(context.Context, AuthenticatedRequest) error
func (f authenticatedRequestPolicyFunc) Evaluate(ctx context.Context, request AuthenticatedRequest) error {
return f(ctx, request)
}
type recordingAuthenticatedRequestPolicy struct {
requests []AuthenticatedRequest
}
func (p *recordingAuthenticatedRequestPolicy) Evaluate(_ context.Context, request AuthenticatedRequest) error {
p.requests = append(p.requests, request)
return nil
}
type publicLimiterAdapter struct {
limiter ratelimit.Limiter
}
func (a publicLimiterAdapter) Reserve(key string, policy config.PublicRateLimitConfig) restapi.PublicRateLimitDecision {
decision := a.limiter.Reserve(key, ratelimit.Policy{
Requests: policy.Requests,
Window: policy.Window,
Burst: policy.Burst,
})
return restapi.PublicRateLimitDecision{
Allowed: decision.Allowed,
RetryAfter: decision.RetryAfter,
}
}
type staticAuthServiceClient struct{}
func (staticAuthServiceClient) SendEmailCode(context.Context, restapi.SendEmailCodeInput) (restapi.SendEmailCodeResult, error) {
return restapi.SendEmailCodeResult{ChallengeID: "challenge-123"}, nil
}
func (staticAuthServiceClient) ConfirmEmailCode(context.Context, restapi.ConfirmEmailCodeInput) (restapi.ConfirmEmailCodeResult, error) {
return restapi.ConfirmEmailCodeResult{DeviceSessionID: "device-session-123"}, nil
}
func waitForHTTPHealthz(t *testing.T, url string) {
t.Helper()
client := &http.Client{Timeout: 200 * time.Millisecond}
require.Eventually(t, func() bool {
response, err := client.Get(url)
if err != nil {
return false
}
require.NoError(t, response.Body.Close())
return response.StatusCode == http.StatusOK
}, 2*time.Second, 10*time.Millisecond, "public REST server did not become healthy: %s", url)
}
func sendPublicAuthRequest(t *testing.T, url string) *http.Response {
t.Helper()
request, err := http.NewRequest(http.MethodPost, url, strings.NewReader(`{"email":"pilot@example.com"}`))
require.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
response, err := (&http.Client{Timeout: time.Second}).Do(request)
require.NoError(t, err)
return response
}
func unusedTCPAddr(t *testing.T) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
addr := listener.Addr().String()
require.NoError(t, listener.Close())
return addr
}
+260
View File
@@ -0,0 +1,260 @@
// Package grpcapi exposes the authenticated gRPC surface of the gateway.
package grpcapi
import (
"context"
"errors"
"fmt"
"net"
"sync"
"galaxy/gateway/internal/authn"
"galaxy/gateway/internal/clock"
"galaxy/gateway/internal/config"
"galaxy/gateway/internal/downstream"
"galaxy/gateway/internal/push"
"galaxy/gateway/internal/ratelimit"
"galaxy/gateway/internal/replay"
"galaxy/gateway/internal/session"
"galaxy/gateway/internal/telemetry"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"go.uber.org/zap"
"google.golang.org/grpc"
)
// ServerDependencies describes the optional collaborators used by the
// authenticated gRPC server. The zero value is valid and keeps the process
// runnable with the built-in unimplemented service stub.
type ServerDependencies struct {
// Service optionally handles the post-bootstrap SubscribeEvents lifecycle
// after the initial authenticated service event has been sent. When nil, the
// gateway keeps authenticated SubscribeEvents streams open until the client
// cancels them, the server shuts down, or a later stream send fails.
Service gatewayv1.EdgeGatewayServer
// Router resolves the exact downstream unary client for the verified
// message_type value. When nil, the authenticated unary surface uses an
// empty exact-match router and returns UNIMPLEMENTED for unrouted commands.
Router downstream.Router
// ResponseSigner signs authenticated unary responses after downstream
// execution succeeds. When nil, the unary surface fails closed once it needs
// to sign a routed response.
ResponseSigner authn.ResponseSigner
// SessionCache resolves authenticated device sessions after the envelope
// gate succeeds. When nil, the authenticated gRPC surface remains runnable
// but valid envelopes fail closed as session-cache unavailable.
SessionCache session.Cache
// Clock provides current server time for freshness checks. When nil, the
// authenticated gRPC surface uses the system clock.
Clock clock.Clock
// ReplayStore reserves authenticated request identifiers after signature
// verification. When nil, valid requests fail closed as replay-store
// unavailable.
ReplayStore replay.Store
// Limiter applies authenticated rate limits after the request passes the
// transport authenticity checks. When nil, the authenticated gRPC surface
// uses a process-local in-memory limiter.
Limiter AuthenticatedRequestLimiter
// Policy evaluates later authenticated edge policy after rate limits pass.
// When nil, the authenticated gRPC surface applies a no-op allow policy.
Policy AuthenticatedRequestPolicy
// Logger writes structured logs for authenticated gRPC traffic.
Logger *zap.Logger
// Telemetry records low-cardinality gRPC metrics.
Telemetry *telemetry.Runtime
// PushHub is the active authenticated push-stream hub. When present, the
// server closes active streams before GracefulStop during shutdown.
PushHub *push.Hub
}
// Server owns the authenticated gRPC listener exposed by the gateway.
type Server struct {
cfg config.AuthenticatedGRPCConfig
service gatewayv1.EdgeGatewayServer
logger *zap.Logger
pushHub *push.Hub
metrics *telemetry.Runtime
stateMu sync.RWMutex
server *grpc.Server
listener net.Listener
}
// NewServer constructs an authenticated gRPC server for the supplied listener
// configuration and dependency bundle. Nil dependencies are replaced with safe
// defaults so the gateway can expose the documented transport surface with the
// full auth pipeline wired from built-in fallbacks.
func NewServer(cfg config.AuthenticatedGRPCConfig, deps ServerDependencies) *Server {
deps = normalizeServerDependencies(deps)
finalService := newCommandRoutingService(
newAuthenticatedPushStreamService(deps.Service, deps.ResponseSigner, deps.Clock),
deps.Router,
deps.ResponseSigner,
deps.Clock,
cfg.DownstreamTimeout,
)
return &Server{
cfg: cfg,
service: newEnvelopeValidatingService(
newSessionLookupService(
newPayloadHashVerifyingService(
newSignatureVerifyingService(
newFreshnessAndReplayService(
newAuthenticatedRateLimitService(
finalService,
deps.Limiter,
deps.Policy,
cfg.AntiAbuse,
),
deps.Clock,
deps.ReplayStore,
cfg.FreshnessWindow,
),
),
),
deps.SessionCache,
),
),
logger: deps.Logger.Named("authenticated_grpc"),
pushHub: deps.PushHub,
metrics: deps.Telemetry,
}
}
// Run binds the configured listener and serves the authenticated gRPC surface
// until Shutdown closes the server.
func (s *Server) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run authenticated gRPC server: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
listener, err := net.Listen("tcp", s.cfg.Addr)
if err != nil {
return fmt.Errorf("run authenticated gRPC server: listen on %q: %w", s.cfg.Addr, err)
}
grpcServer := grpc.NewServer(
grpc.ConnectionTimeout(s.cfg.ConnectionTimeout),
grpc.StatsHandler(otelgrpc.NewServerHandler()),
grpc.ChainUnaryInterceptor(observabilityUnaryInterceptor(s.logger, s.metrics)),
grpc.ChainStreamInterceptor(observabilityStreamInterceptor(s.logger, s.metrics)),
)
gatewayv1.RegisterEdgeGatewayServer(grpcServer, s.service)
s.stateMu.Lock()
s.server = grpcServer
s.listener = listener
s.stateMu.Unlock()
s.logger.Info("authenticated gRPC server started", zap.String("addr", listener.Addr().String()))
defer func() {
s.stateMu.Lock()
s.server = nil
s.listener = nil
s.stateMu.Unlock()
}()
err = grpcServer.Serve(listener)
switch {
case err == nil:
return nil
case errors.Is(err, grpc.ErrServerStopped):
s.logger.Info("authenticated gRPC server stopped")
return nil
default:
return fmt.Errorf("run authenticated gRPC server: serve on %q: %w", s.cfg.Addr, err)
}
}
// Shutdown gracefully stops the authenticated gRPC server within ctx. When the
// graceful stop exceeds ctx, the server is force-stopped before returning the
// timeout to the caller.
func (s *Server) Shutdown(ctx context.Context) error {
if ctx == nil {
return errors.New("shutdown authenticated gRPC server: nil context")
}
s.stateMu.RLock()
server := s.server
s.stateMu.RUnlock()
if server == nil {
return nil
}
if s.pushHub != nil {
s.pushHub.Shutdown()
}
stopped := make(chan struct{})
go func() {
server.GracefulStop()
close(stopped)
}()
select {
case <-stopped:
return nil
case <-ctx.Done():
server.Stop()
<-stopped
return fmt.Errorf("shutdown authenticated gRPC server: %w", ctx.Err())
}
}
func (s *Server) listenAddr() string {
s.stateMu.RLock()
defer s.stateMu.RUnlock()
if s.listener == nil {
return ""
}
return s.listener.Addr().String()
}
func normalizeServerDependencies(deps ServerDependencies) ServerDependencies {
if deps.Router == nil {
deps.Router = downstream.NewStaticRouter(nil)
}
if deps.ResponseSigner == nil {
deps.ResponseSigner = unavailableResponseSigner{}
}
if deps.SessionCache == nil {
deps.SessionCache = unavailableSessionCache{}
}
if deps.Clock == nil {
deps.Clock = clock.System{}
}
if deps.ReplayStore == nil {
deps.ReplayStore = unavailableReplayStore{}
}
if deps.Limiter == nil {
deps.Limiter = ratelimit.NewInMemory()
}
if deps.Policy == nil {
deps.Policy = noopAuthenticatedRequestPolicy{}
}
if deps.Logger == nil {
deps.Logger = zap.NewNop()
}
return deps
}
+332
View File
@@ -0,0 +1,332 @@
package grpcapi
import (
"context"
"testing"
"time"
"galaxy/gateway/internal/app"
"galaxy/gateway/internal/config"
"galaxy/gateway/internal/session"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
)
func TestExecuteCommandRejectsMalformedEnvelope(t *testing.T) {
t.Parallel()
server, runGateway := newTestGateway(t, ServerDependencies{})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), &gatewayv1.ExecuteCommandRequest{})
require.Error(t, err)
assert.Equal(t, codes.InvalidArgument, status.Code(err))
}
func TestSubscribeEventsRejectsMalformedEnvelope(t *testing.T) {
t.Parallel()
server, runGateway := newTestGateway(t, ServerDependencies{})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
err := subscribeEventsError(t, context.Background(), client, &gatewayv1.SubscribeEventsRequest{})
require.Error(t, err)
assert.Equal(t, codes.InvalidArgument, status.Code(err))
}
func TestExecuteCommandRejectsUnsupportedProtocolVersion(t *testing.T) {
t.Parallel()
server, runGateway := newTestGateway(t, ServerDependencies{})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), &gatewayv1.ExecuteCommandRequest{
ProtocolVersion: "v2",
DeviceSessionId: "device-session-123",
MessageType: "fleet.move",
TimestampMs: 123456789,
RequestId: "request-123",
PayloadBytes: []byte("payload"),
PayloadHash: []byte("hash"),
Signature: []byte("signature"),
})
require.Error(t, err)
assert.Equal(t, codes.FailedPrecondition, status.Code(err))
assert.Equal(t, `unsupported protocol_version "v2"`, status.Convert(err).Message())
}
func TestExecuteCommandValidEnvelopeStillReturnsUnimplemented(t *testing.T) {
t.Parallel()
server, runGateway := newTestGateway(t, ServerDependencies{
SessionCache: staticSessionCache{
lookupFunc: func(context.Context, string) (session.Record, error) {
return newActiveSessionRecord(), nil
},
},
ReplayStore: staticReplayStore{},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequest())
require.Error(t, err)
assert.Equal(t, codes.Unimplemented, status.Code(err))
}
func TestExecuteCommandMissingReplayStoreFailsClosed(t *testing.T) {
t.Parallel()
server, runGateway := newTestGateway(t, ServerDependencies{
SessionCache: staticSessionCache{
lookupFunc: func(context.Context, string) (session.Record, error) {
return newActiveSessionRecord(), nil
},
},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequest())
require.Error(t, err)
assert.Equal(t, codes.Unavailable, status.Code(err))
assert.Equal(t, "replay store is unavailable", status.Convert(err).Message())
}
func TestSubscribeEventsValidEnvelopeSendsBootstrapEventAndWaitsForCancellation(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
server, runGateway := newTestGateway(t, ServerDependencies{
SessionCache: staticSessionCache{
lookupFunc: func(context.Context, string) (session.Record, error) {
return newActiveSessionRecord(), nil
},
},
ReplayStore: staticReplayStore{},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
stream, err := client.SubscribeEvents(ctx, newValidSubscribeEventsRequest())
require.NoError(t, err)
event := recvBootstrapEvent(t, stream)
assertServerTimeBootstrapEvent(t, event, newTestResponseSignerPublicKey(), "request-123", "trace-123", testCurrentTime.UnixMilli())
recvResult := make(chan error, 1)
go func() {
_, recvErr := stream.Recv()
recvResult <- recvErr
}()
require.Never(t, func() bool {
select {
case <-recvResult:
return true
default:
return false
}
}, 100*time.Millisecond, 10*time.Millisecond, "stream closed before cancellation")
cancel()
var recvErr error
require.Eventually(t, func() bool {
select {
case recvErr = <-recvResult:
return true
default:
return false
}
}, time.Second, 10*time.Millisecond, "stream did not stop after client cancellation")
require.Error(t, recvErr)
assert.Equal(t, codes.Canceled, status.Code(recvErr))
}
func TestSubscribeEventsMissingReplayStoreFailsClosed(t *testing.T) {
t.Parallel()
server, runGateway := newTestGateway(t, ServerDependencies{
SessionCache: staticSessionCache{
lookupFunc: func(context.Context, string) (session.Record, error) {
return newActiveSessionRecord(), nil
},
},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
err := subscribeEventsError(t, context.Background(), client, newValidSubscribeEventsRequest())
require.Error(t, err)
assert.Equal(t, codes.Unavailable, status.Code(err))
assert.Equal(t, "replay store is unavailable", status.Convert(err).Message())
}
func TestServerLifecycle(t *testing.T) {
t.Parallel()
server, runGateway := newTestGateway(t, ServerDependencies{})
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
require.NoError(t, conn.Close())
runGateway.stop(t)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, err := grpc.DialContext(
ctx,
addr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
require.Error(t, err)
}
type runningGateway struct {
cancel context.CancelFunc
resultCh chan error
}
func newTestGateway(t *testing.T, deps ServerDependencies) (*Server, runningGateway) {
t.Helper()
grpcCfg := config.DefaultAuthenticatedGRPCConfig()
grpcCfg.Addr = "127.0.0.1:0"
grpcCfg.FreshnessWindow = testFreshnessWindow
return newTestGatewayWithGRPCConfig(t, grpcCfg, deps)
}
func newTestGatewayWithGRPCConfig(t *testing.T, grpcCfg config.AuthenticatedGRPCConfig, deps ServerDependencies) (*Server, runningGateway) {
t.Helper()
cfg := config.Config{
ShutdownTimeout: time.Second,
AuthenticatedGRPC: grpcCfg,
}
if deps.Clock == nil {
deps.Clock = fixedClock{now: testCurrentTime}
}
if deps.ResponseSigner == nil {
deps.ResponseSigner = newTestResponseSigner()
}
if deps.Router == nil && deps.Service != nil {
deps.Router = executeCommandAdapterRouter{service: deps.Service}
}
server := NewServer(cfg.AuthenticatedGRPC, deps)
application := app.New(cfg, server)
ctx, cancel := context.WithCancel(context.Background())
resultCh := make(chan error, 1)
go func() {
resultCh <- application.Run(ctx)
}()
return server, runningGateway{
cancel: cancel,
resultCh: resultCh,
}
}
func (g runningGateway) stop(t *testing.T) {
t.Helper()
g.cancel()
var err error
require.Eventually(t, func() bool {
select {
case err = <-g.resultCh:
return true
default:
return false
}
}, 2*time.Second, 10*time.Millisecond, "gateway did not stop after cancellation")
require.NoError(t, err)
}
func waitForListenAddr(t *testing.T, server *Server) string {
t.Helper()
var addr string
require.Eventually(t, func() bool {
addr = server.listenAddr()
return addr != ""
}, time.Second, 10*time.Millisecond, "server did not start listening")
return addr
}
func dialGatewayClient(t *testing.T, addr string) *grpc.ClientConn {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
conn, err := grpc.DialContext(
ctx,
addr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
require.NoError(t, err)
return conn
}
+126
View File
@@ -0,0 +1,126 @@
package grpcapi
import (
"context"
"errors"
"galaxy/gateway/internal/session"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// resolvedSessionFromContext returns the session record previously attached to
// ctx by the session-lookup gateway wrapper.
func resolvedSessionFromContext(ctx context.Context) (session.Record, bool) {
if ctx == nil {
return session.Record{}, false
}
record, ok := ctx.Value(resolvedSessionContextKey{}).(session.Record)
if !ok {
return session.Record{}, false
}
return cloneSessionRecord(record), true
}
// sessionLookupService resolves the authenticated session from SessionCache
// after envelope parsing succeeds and before later auth steps run.
type sessionLookupService struct {
gatewayv1.UnimplementedEdgeGatewayServer
delegate gatewayv1.EdgeGatewayServer
cache session.Cache
}
// ExecuteCommand resolves the cached session for req and only then forwards it
// to the configured delegate with the resolved session attached to ctx.
func (s sessionLookupService) ExecuteCommand(ctx context.Context, req *gatewayv1.ExecuteCommandRequest) (*gatewayv1.ExecuteCommandResponse, error) {
record, err := s.lookupSession(ctx)
if err != nil {
return nil, err
}
return s.delegate.ExecuteCommand(context.WithValue(ctx, resolvedSessionContextKey{}, cloneSessionRecord(record)), req)
}
// SubscribeEvents resolves the cached session for req and only then forwards it
// to the configured delegate with the resolved session attached to the stream
// context.
func (s sessionLookupService) SubscribeEvents(req *gatewayv1.SubscribeEventsRequest, stream grpc.ServerStreamingServer[gatewayv1.GatewayEvent]) error {
record, err := s.lookupSession(stream.Context())
if err != nil {
return err
}
return s.delegate.SubscribeEvents(req, resolvedSessionContextStream{
ServerStreamingServer: stream,
ctx: context.WithValue(stream.Context(), resolvedSessionContextKey{}, cloneSessionRecord(record)),
})
}
// newSessionLookupService wraps delegate with the session-cache lookup gate.
func newSessionLookupService(delegate gatewayv1.EdgeGatewayServer, cache session.Cache) gatewayv1.EdgeGatewayServer {
return sessionLookupService{
delegate: delegate,
cache: cache,
}
}
func (s sessionLookupService) lookupSession(ctx context.Context) (session.Record, error) {
envelope, ok := parsedEnvelopeFromContext(ctx)
if !ok {
return session.Record{}, status.Error(codes.Internal, "authenticated request context is incomplete")
}
record, err := s.cache.Lookup(ctx, envelope.DeviceSessionID)
switch {
case err == nil:
case errors.Is(err, session.ErrNotFound):
return session.Record{}, status.Error(codes.Unauthenticated, "unknown device session")
default:
return session.Record{}, status.Error(codes.Unavailable, "session cache is unavailable")
}
if record.Status == session.StatusRevoked {
return session.Record{}, status.Error(codes.FailedPrecondition, "device session is revoked")
}
return cloneSessionRecord(record), nil
}
func cloneSessionRecord(record session.Record) session.Record {
cloned := record
if record.RevokedAtMS != nil {
value := *record.RevokedAtMS
cloned.RevokedAtMS = &value
}
return cloned
}
type resolvedSessionContextKey struct{}
type resolvedSessionContextStream struct {
grpc.ServerStreamingServer[gatewayv1.GatewayEvent]
ctx context.Context
}
func (s resolvedSessionContextStream) Context() context.Context {
if s.ctx == nil {
return context.Background()
}
return s.ctx
}
type unavailableSessionCache struct{}
func (unavailableSessionCache) Lookup(context.Context, string) (session.Record, error) {
return session.Record{}, errors.New("session cache is unavailable")
}
var _ gatewayv1.EdgeGatewayServer = sessionLookupService{}
@@ -0,0 +1,294 @@
package grpcapi
import (
"context"
"errors"
"io"
"testing"
"galaxy/gateway/internal/session"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestExecuteCommandRejectsUnknownSession(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{
lookupFunc: func(context.Context, string) (session.Record, error) {
return session.Record{}, session.ErrNotFound
},
},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequest())
require.Error(t, err)
assert.Equal(t, codes.Unauthenticated, status.Code(err))
assert.Equal(t, "unknown device session", status.Convert(err).Message())
assert.Zero(t, delegate.executeCalls)
}
func TestSubscribeEventsRejectsUnknownSession(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{
lookupFunc: func(context.Context, string) (session.Record, error) {
return session.Record{}, session.ErrNotFound
},
},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
err := subscribeEventsError(t, context.Background(), client, newValidSubscribeEventsRequest())
require.Error(t, err)
assert.Equal(t, codes.Unauthenticated, status.Code(err))
assert.Equal(t, "unknown device session", status.Convert(err).Message())
assert.Zero(t, delegate.subscribeCalls)
}
func TestExecuteCommandRejectsRevokedSession(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{lookupFunc: func(context.Context, string) (session.Record, error) { return newRevokedSessionRecord(), nil }},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequest())
require.Error(t, err)
assert.Equal(t, codes.FailedPrecondition, status.Code(err))
assert.Equal(t, "device session is revoked", status.Convert(err).Message())
assert.Zero(t, delegate.executeCalls)
}
func TestSubscribeEventsRejectsRevokedSession(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{lookupFunc: func(context.Context, string) (session.Record, error) { return newRevokedSessionRecord(), nil }},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
err := subscribeEventsError(t, context.Background(), client, newValidSubscribeEventsRequest())
require.Error(t, err)
assert.Equal(t, codes.FailedPrecondition, status.Code(err))
assert.Equal(t, "device session is revoked", status.Convert(err).Message())
assert.Zero(t, delegate.subscribeCalls)
}
func TestExecuteCommandRejectsSessionCacheUnavailable(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{
lookupFunc: func(context.Context, string) (session.Record, error) {
return session.Record{}, errors.New("redis down")
},
},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequest())
require.Error(t, err)
assert.Equal(t, codes.Unavailable, status.Code(err))
assert.Equal(t, "session cache is unavailable", status.Convert(err).Message())
assert.Zero(t, delegate.executeCalls)
}
func TestSubscribeEventsRejectsSessionCacheUnavailable(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{
lookupFunc: func(context.Context, string) (session.Record, error) {
return session.Record{}, errors.New("redis down")
},
},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
err := subscribeEventsError(t, context.Background(), client, newValidSubscribeEventsRequest())
require.Error(t, err)
assert.Equal(t, codes.Unavailable, status.Code(err))
assert.Equal(t, "session cache is unavailable", status.Convert(err).Message())
assert.Zero(t, delegate.subscribeCalls)
}
func TestExecuteCommandAttachesResolvedSession(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{
executeCommandFunc: func(ctx context.Context, req *gatewayv1.ExecuteCommandRequest) (*gatewayv1.ExecuteCommandResponse, error) {
record, ok := resolvedSessionFromContext(ctx)
require.True(t, ok)
assert.Equal(t, newActiveSessionRecord(), record)
return &gatewayv1.ExecuteCommandResponse{RequestId: req.GetRequestId()}, nil
},
}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{lookupFunc: func(context.Context, string) (session.Record, error) { return newActiveSessionRecord(), nil }},
ReplayStore: staticReplayStore{},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
response, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequest())
require.NoError(t, err)
assert.Equal(t, "request-123", response.GetRequestId())
}
func TestSubscribeEventsAttachesResolvedSession(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{
subscribeEventsFunc: func(req *gatewayv1.SubscribeEventsRequest, stream grpc.ServerStreamingServer[gatewayv1.GatewayEvent]) error {
record, ok := resolvedSessionFromContext(stream.Context())
require.True(t, ok)
assert.Equal(t, newActiveSessionRecord(), record)
return nil
},
}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{lookupFunc: func(context.Context, string) (session.Record, error) { return newActiveSessionRecord(), nil }},
ReplayStore: staticReplayStore{},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
stream, err := client.SubscribeEvents(context.Background(), newValidSubscribeEventsRequest())
require.NoError(t, err)
event := recvBootstrapEvent(t, stream)
assertServerTimeBootstrapEvent(t, event, newTestResponseSignerPublicKey(), "request-123", "trace-123", testCurrentTime.UnixMilli())
_, err = stream.Recv()
require.ErrorIs(t, err, io.EOF)
}
func TestSubscribeEventsAttachesAuthenticatedStreamBinding(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{
subscribeEventsFunc: func(req *gatewayv1.SubscribeEventsRequest, stream grpc.ServerStreamingServer[gatewayv1.GatewayEvent]) error {
binding, ok := authenticatedStreamBindingFromContext(stream.Context())
require.True(t, ok)
assert.Equal(t, authenticatedStreamBinding{
UserID: "user-123",
DeviceSessionID: "device-session-123",
MessageType: "gateway.subscribe",
RequestID: "request-123",
TraceID: "trace-123",
}, binding)
return nil
},
}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{lookupFunc: func(context.Context, string) (session.Record, error) { return newActiveSessionRecord(), nil }},
ReplayStore: staticReplayStore{},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
stream, err := client.SubscribeEvents(context.Background(), newValidSubscribeEventsRequest())
require.NoError(t, err)
event := recvBootstrapEvent(t, stream)
assertServerTimeBootstrapEvent(t, event, newTestResponseSignerPublicKey(), "request-123", "trace-123", testCurrentTime.UnixMilli())
_, err = stream.Recv()
require.ErrorIs(t, err, io.EOF)
}
type staticSessionCache struct {
lookupFunc func(context.Context, string) (session.Record, error)
}
func (c staticSessionCache) Lookup(ctx context.Context, deviceSessionID string) (session.Record, error) {
return c.lookupFunc(ctx, deviceSessionID)
}
+80
View File
@@ -0,0 +1,80 @@
package grpcapi
import (
"context"
"errors"
"galaxy/gateway/internal/authn"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// signatureVerifyingService applies client-signature verification after
// payload integrity checks and before later auth or routing steps run.
type signatureVerifyingService struct {
gatewayv1.UnimplementedEdgeGatewayServer
delegate gatewayv1.EdgeGatewayServer
}
// ExecuteCommand verifies req client signature before delegating to the
// configured service implementation.
func (s signatureVerifyingService) ExecuteCommand(ctx context.Context, req *gatewayv1.ExecuteCommandRequest) (*gatewayv1.ExecuteCommandResponse, error) {
if err := verifyRequestSignature(ctx); err != nil {
return nil, err
}
return s.delegate.ExecuteCommand(ctx, req)
}
// SubscribeEvents verifies req client signature before delegating to the
// configured service implementation.
func (s signatureVerifyingService) SubscribeEvents(req *gatewayv1.SubscribeEventsRequest, stream grpc.ServerStreamingServer[gatewayv1.GatewayEvent]) error {
if err := verifyRequestSignature(stream.Context()); err != nil {
return err
}
return s.delegate.SubscribeEvents(req, stream)
}
// newSignatureVerifyingService wraps delegate with the client-signature
// verification gate.
func newSignatureVerifyingService(delegate gatewayv1.EdgeGatewayServer) gatewayv1.EdgeGatewayServer {
return signatureVerifyingService{delegate: delegate}
}
func verifyRequestSignature(ctx context.Context) error {
envelope, ok := parsedEnvelopeFromContext(ctx)
if !ok {
return status.Error(codes.Internal, "authenticated request context is incomplete")
}
record, ok := resolvedSessionFromContext(ctx)
if !ok {
return status.Error(codes.Internal, "authenticated request context is incomplete")
}
err := authn.VerifyRequestSignature(record.ClientPublicKey, envelope.Signature, authn.RequestSigningFields{
ProtocolVersion: envelope.ProtocolVersion,
DeviceSessionID: envelope.DeviceSessionID,
MessageType: envelope.MessageType,
TimestampMS: envelope.TimestampMS,
RequestID: envelope.RequestID,
PayloadHash: envelope.PayloadHash,
})
switch {
case err == nil:
return nil
case errors.Is(err, authn.ErrInvalidClientPublicKey):
return status.Error(codes.Unavailable, "session cache is unavailable")
case errors.Is(err, authn.ErrInvalidRequestSignature):
return status.Error(codes.Unauthenticated, "invalid request signature")
default:
return status.Error(codes.Internal, "request signature verification failed")
}
}
var _ gatewayv1.EdgeGatewayServer = signatureVerifyingService{}
@@ -0,0 +1,188 @@
package grpcapi
import (
"context"
"testing"
"galaxy/gateway/internal/session"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestExecuteCommandRejectsInvalidSignature(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{lookupFunc: func(context.Context, string) (session.Record, error) { return newActiveSessionRecord(), nil }},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
req := newValidExecuteCommandRequest()
req.Signature[0] ^= 0xff
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), req)
require.Error(t, err)
assert.Equal(t, codes.Unauthenticated, status.Code(err))
assert.Equal(t, "invalid request signature", status.Convert(err).Message())
assert.Zero(t, delegate.executeCalls)
}
func TestExecuteCommandRejectsWrongKey(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{
lookupFunc: func(context.Context, string) (session.Record, error) {
record := newActiveSessionRecord()
record.ClientPublicKey = alternateTestClientPublicKeyBase64()
return record, nil
},
},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequest())
require.Error(t, err)
assert.Equal(t, codes.Unauthenticated, status.Code(err))
assert.Equal(t, "invalid request signature", status.Convert(err).Message())
assert.Zero(t, delegate.executeCalls)
}
func TestExecuteCommandRejectsInvalidCachedPublicKey(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{
lookupFunc: func(context.Context, string) (session.Record, error) {
record := newActiveSessionRecord()
record.ClientPublicKey = "%%%not-base64%%%"
return record, nil
},
},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
_, err := client.ExecuteCommand(context.Background(), newValidExecuteCommandRequest())
require.Error(t, err)
assert.Equal(t, codes.Unavailable, status.Code(err))
assert.Equal(t, "session cache is unavailable", status.Convert(err).Message())
assert.Zero(t, delegate.executeCalls)
}
func TestSubscribeEventsRejectsInvalidSignature(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{lookupFunc: func(context.Context, string) (session.Record, error) { return newActiveSessionRecord(), nil }},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
req := newValidSubscribeEventsRequest()
req.Signature[0] ^= 0xff
client := gatewayv1.NewEdgeGatewayClient(conn)
err := subscribeEventsError(t, context.Background(), client, req)
require.Error(t, err)
assert.Equal(t, codes.Unauthenticated, status.Code(err))
assert.Equal(t, "invalid request signature", status.Convert(err).Message())
assert.Zero(t, delegate.subscribeCalls)
}
func TestSubscribeEventsRejectsWrongKey(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{
lookupFunc: func(context.Context, string) (session.Record, error) {
record := newActiveSessionRecord()
record.ClientPublicKey = alternateTestClientPublicKeyBase64()
return record, nil
},
},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
err := subscribeEventsError(t, context.Background(), client, newValidSubscribeEventsRequest())
require.Error(t, err)
assert.Equal(t, codes.Unauthenticated, status.Code(err))
assert.Equal(t, "invalid request signature", status.Convert(err).Message())
assert.Zero(t, delegate.subscribeCalls)
}
func TestSubscribeEventsRejectsInvalidCachedPublicKey(t *testing.T) {
t.Parallel()
delegate := &recordingEdgeGatewayService{}
server, runGateway := newTestGateway(t, ServerDependencies{
Service: delegate,
SessionCache: staticSessionCache{
lookupFunc: func(context.Context, string) (session.Record, error) {
record := newActiveSessionRecord()
record.ClientPublicKey = "%%%not-base64%%%"
return record, nil
},
},
})
defer runGateway.stop(t)
addr := waitForListenAddr(t, server)
conn := dialGatewayClient(t, addr)
defer func() {
require.NoError(t, conn.Close())
}()
client := gatewayv1.NewEdgeGatewayClient(conn)
err := subscribeEventsError(t, context.Background(), client, newValidSubscribeEventsRequest())
require.Error(t, err)
assert.Equal(t, codes.Unavailable, status.Code(err))
assert.Equal(t, "session cache is unavailable", status.Convert(err).Message())
assert.Zero(t, delegate.subscribeCalls)
}
@@ -0,0 +1,298 @@
package grpcapi
import (
"context"
"crypto/ed25519"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"time"
"galaxy/gateway/internal/authn"
"galaxy/gateway/internal/downstream"
"galaxy/gateway/internal/session"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
gatewayfbs "galaxy/schema/fbs/gateway"
flatbuffers "github.com/google/flatbuffers/go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
)
var (
testCurrentTime = time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC)
testFreshnessWindow = 5 * time.Minute
)
func newValidExecuteCommandRequest() *gatewayv1.ExecuteCommandRequest {
return newValidExecuteCommandRequestWithSessionAndRequestID("device-session-123", "request-123")
}
func newValidExecuteCommandRequestWithSessionAndRequestID(deviceSessionID string, requestID string) *gatewayv1.ExecuteCommandRequest {
return newValidExecuteCommandRequestWithTimestamp(deviceSessionID, requestID, testCurrentTime.UnixMilli())
}
func newValidExecuteCommandRequestWithTimestamp(deviceSessionID string, requestID string, timestampMS int64) *gatewayv1.ExecuteCommandRequest {
payloadBytes := []byte("payload")
payloadHash := sha256.Sum256(payloadBytes)
req := &gatewayv1.ExecuteCommandRequest{
ProtocolVersion: supportedProtocolVersion,
DeviceSessionId: deviceSessionID,
MessageType: "fleet.move",
TimestampMs: timestampMS,
RequestId: requestID,
PayloadBytes: payloadBytes,
PayloadHash: payloadHash[:],
TraceId: "trace-123",
}
req.Signature = signRequest(req.GetProtocolVersion(), req.GetDeviceSessionId(), req.GetMessageType(), req.GetTimestampMs(), req.GetRequestId(), req.GetPayloadHash())
return req
}
func newValidSubscribeEventsRequest() *gatewayv1.SubscribeEventsRequest {
return newValidSubscribeEventsRequestWithSessionAndRequestID("device-session-123", "request-123")
}
func newValidSubscribeEventsRequestWithSessionAndRequestID(deviceSessionID string, requestID string) *gatewayv1.SubscribeEventsRequest {
return newValidSubscribeEventsRequestWithTimestamp(deviceSessionID, requestID, testCurrentTime.UnixMilli())
}
func newValidSubscribeEventsRequestWithTimestamp(deviceSessionID string, requestID string, timestampMS int64) *gatewayv1.SubscribeEventsRequest {
payloadHash := sha256.Sum256(nil)
req := &gatewayv1.SubscribeEventsRequest{
ProtocolVersion: supportedProtocolVersion,
DeviceSessionId: deviceSessionID,
MessageType: "gateway.subscribe",
TimestampMs: timestampMS,
RequestId: requestID,
PayloadHash: payloadHash[:],
TraceId: "trace-123",
}
req.Signature = signRequest(req.GetProtocolVersion(), req.GetDeviceSessionId(), req.GetMessageType(), req.GetTimestampMs(), req.GetRequestId(), req.GetPayloadHash())
return req
}
func newActiveSessionRecord() session.Record {
return newActiveSessionRecordWithSessionID("device-session-123")
}
func newActiveSessionRecordWithSessionID(deviceSessionID string) session.Record {
return session.Record{
DeviceSessionID: deviceSessionID,
UserID: "user-123",
ClientPublicKey: testClientPublicKeyBase64(),
Status: session.StatusActive,
}
}
func newRevokedSessionRecord() session.Record {
revokedAtMS := int64(123456789)
return session.Record{
DeviceSessionID: "device-session-123",
UserID: "user-123",
ClientPublicKey: testClientPublicKeyBase64(),
Status: session.StatusRevoked,
RevokedAtMS: &revokedAtMS,
}
}
func alternateTestClientPublicKeyBase64() string {
return base64.StdEncoding.EncodeToString(newTestPrivateKey("alternate").Public().(ed25519.PublicKey))
}
func testClientPublicKeyBase64() string {
return base64.StdEncoding.EncodeToString(newTestPrivateKey("primary").Public().(ed25519.PublicKey))
}
func signRequest(protocolVersion, deviceSessionID, messageType string, timestampMS int64, requestID string, payloadHash []byte) []byte {
return ed25519.Sign(newTestPrivateKey("primary"), authn.BuildRequestSigningInput(authn.RequestSigningFields{
ProtocolVersion: protocolVersion,
DeviceSessionID: deviceSessionID,
MessageType: messageType,
TimestampMS: timestampMS,
RequestID: requestID,
PayloadHash: payloadHash,
}))
}
func newTestPrivateKey(label string) ed25519.PrivateKey {
seed := sha256.Sum256([]byte("gateway-grpcapi-signature-test-" + label))
return ed25519.NewKeyFromSeed(seed[:])
}
func newTestEd25519ResponseSigner() *authn.Ed25519ResponseSigner {
pemBytes := pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: mustMarshalPKCS8PrivateKey(newTestPrivateKey("response-signer")),
})
signer, err := authn.ParseEd25519ResponseSignerPEM(pemBytes)
if err != nil {
panic(err)
}
return signer
}
func newTestResponseSigner() authn.ResponseSigner {
return newTestEd25519ResponseSigner()
}
func newTestResponseSignerPublicKey() ed25519.PublicKey {
return newTestEd25519ResponseSigner().PublicKey()
}
func mustMarshalPKCS8PrivateKey(privateKey ed25519.PrivateKey) []byte {
encoded, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
panic(err)
}
return encoded
}
type fixedClock struct {
now time.Time
}
func (c fixedClock) Now() time.Time {
return c.now
}
func recvBootstrapEvent(t interface {
require.TestingT
Helper()
}, stream grpc.ServerStreamingClient[gatewayv1.GatewayEvent]) *gatewayv1.GatewayEvent {
t.Helper()
event, err := stream.Recv()
require.NoError(t, err)
return event
}
func subscribeEventsError(t interface {
require.TestingT
Helper()
}, ctx context.Context, client gatewayv1.EdgeGatewayClient, req *gatewayv1.SubscribeEventsRequest) error {
t.Helper()
stream, err := client.SubscribeEvents(ctx, req)
if err != nil {
return err
}
_, err = stream.Recv()
return err
}
func assertServerTimeBootstrapEvent(t interface {
require.TestingT
Helper()
}, event *gatewayv1.GatewayEvent, publicKey ed25519.PublicKey, wantRequestID string, wantTraceID string, wantTimestampMS int64) {
t.Helper()
require.NotNil(t, event)
assert.Equal(t, serverTimeEventType, event.GetEventType())
assert.Equal(t, wantRequestID, event.GetEventId())
assert.Equal(t, wantRequestID, event.GetRequestId())
assert.Equal(t, wantTraceID, event.GetTraceId())
assert.Equal(t, wantTimestampMS, event.GetTimestampMs())
require.NoError(t, authn.VerifyPayloadHash(event.GetPayloadBytes(), event.GetPayloadHash()))
require.NoError(t, authn.VerifyEventSignature(publicKey, event.GetSignature(), authn.EventSigningFields{
EventType: event.GetEventType(),
EventID: event.GetEventId(),
TimestampMS: event.GetTimestampMs(),
RequestID: event.GetRequestId(),
TraceID: event.GetTraceId(),
PayloadHash: event.GetPayloadHash(),
}))
payload := gatewayfbs.GetRootAsServerTimeEvent(event.GetPayloadBytes(), flatbuffers.UOffsetT(0))
assert.Equal(t, wantTimestampMS, payload.ServerTimeMs())
}
type staticReplayStore struct {
reserveFunc func(context.Context, string, string, time.Duration) error
}
func (s staticReplayStore) Reserve(ctx context.Context, deviceSessionID string, requestID string, ttl time.Duration) error {
if s.reserveFunc != nil {
return s.reserveFunc(ctx, deviceSessionID, requestID, ttl)
}
return nil
}
type executeCommandAdapterRouter struct {
service gatewayv1.EdgeGatewayServer
}
func (r executeCommandAdapterRouter) Route(string) (downstream.Client, error) {
return executeCommandAdapterClient{service: r.service}, nil
}
type executeCommandAdapterClient struct {
service gatewayv1.EdgeGatewayServer
}
func (c executeCommandAdapterClient) ExecuteCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
response, err := c.service.ExecuteCommand(ctx, &gatewayv1.ExecuteCommandRequest{
ProtocolVersion: command.ProtocolVersion,
DeviceSessionId: command.DeviceSessionID,
MessageType: command.MessageType,
TimestampMs: command.TimestampMS,
RequestId: command.RequestID,
PayloadBytes: command.PayloadBytes,
TraceId: command.TraceID,
})
if err != nil {
return downstream.UnaryResult{}, err
}
resultCode := response.GetResultCode()
if resultCode == "" {
resultCode = "ok"
}
return downstream.UnaryResult{
ResultCode: resultCode,
PayloadBytes: response.GetPayloadBytes(),
}, nil
}
type recordingDownstreamClient struct {
executeCalls int
commands []downstream.AuthenticatedCommand
executeFunc func(context.Context, downstream.AuthenticatedCommand) (downstream.UnaryResult, error)
}
func (c *recordingDownstreamClient) ExecuteCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
c.executeCalls++
c.commands = append(c.commands, downstream.AuthenticatedCommand{
ProtocolVersion: command.ProtocolVersion,
UserID: command.UserID,
DeviceSessionID: command.DeviceSessionID,
MessageType: command.MessageType,
TimestampMS: command.TimestampMS,
RequestID: command.RequestID,
TraceID: command.TraceID,
PayloadBytes: append([]byte(nil), command.PayloadBytes...),
})
if c.executeFunc != nil {
return c.executeFunc(ctx, command)
}
return downstream.UnaryResult{
ResultCode: "ok",
PayloadBytes: []byte("response"),
}, nil
}
+84
View File
@@ -0,0 +1,84 @@
// Package logging configures the gateway structured logger and provides
// context-aware helpers for attaching OpenTelemetry trace identifiers.
package logging
import (
"context"
"strings"
"galaxy/gateway/internal/config"
"go.opentelemetry.io/otel/trace"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// New constructs the process-wide JSON logger from cfg.
func New(cfg config.LoggingConfig) (*zap.Logger, error) {
level := zap.NewAtomicLevel()
if err := level.UnmarshalText([]byte(strings.TrimSpace(cfg.Level))); err != nil {
return nil, err
}
zapCfg := zap.NewProductionConfig()
zapCfg.Level = level
zapCfg.Sampling = nil
zapCfg.Encoding = "json"
zapCfg.EncoderConfig.TimeKey = "timestamp"
zapCfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
zapCfg.OutputPaths = []string{"stdout"}
zapCfg.ErrorOutputPaths = []string{"stderr"}
return zapCfg.Build()
}
// TraceFieldsFromContext returns zap fields for the active OpenTelemetry span
// when ctx carries a valid span context.
func TraceFieldsFromContext(ctx context.Context) []zap.Field {
if ctx == nil {
return nil
}
spanContext := trace.SpanContextFromContext(ctx)
if !spanContext.IsValid() {
return nil
}
return []zap.Field{
zap.String("otel_trace_id", spanContext.TraceID().String()),
zap.String("otel_span_id", spanContext.SpanID().String()),
}
}
// Sync flushes logger and ignores the benign stdout or stderr sync errors
// commonly returned by containerized or redirected process outputs.
func Sync(logger *zap.Logger) error {
if logger == nil {
return nil
}
err := logger.Sync()
if err == nil || isIgnorableSyncError(err) {
return nil
}
return err
}
func isIgnorableSyncError(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
switch {
case strings.Contains(message, "invalid argument"):
return true
case strings.Contains(message, "bad file descriptor"):
return true
case strings.Contains(message, "inappropriate ioctl for device"):
return true
default:
return false
}
}
+385
View File
@@ -0,0 +1,385 @@
// Package push provides the in-memory hub used to fan out internal
// client-facing events to active authenticated push streams.
package push
import (
"bytes"
"errors"
"strings"
"sync"
)
const defaultSubscriptionQueueCapacity = 64
var (
// ErrSubscriptionOverflow reports that one push stream stopped consuming
// events quickly enough and its bounded queue overflowed.
ErrSubscriptionOverflow = errors.New("push stream overflowed")
// ErrSubscriptionRevoked reports that the authenticated device session bound
// to the push stream was revoked and the stream must terminate.
ErrSubscriptionRevoked = errors.New("device session is revoked")
// ErrHubShuttingDown reports that the gateway is shutting down and all
// active push streams must terminate promptly.
ErrHubShuttingDown = errors.New("gateway is shutting down")
)
// StreamBinding identifies one authenticated push stream tracked by Hub.
type StreamBinding struct {
// UserID is the verified authenticated user bound to the stream.
UserID string
// DeviceSessionID is the verified authenticated device session bound to the
// stream.
DeviceSessionID string
}
// Event is the internal client-facing event delivered from internal pub/sub to
// active push streams.
type Event struct {
// UserID identifies the authenticated user that should receive the event.
UserID string
// DeviceSessionID optionally narrows delivery to one device session.
DeviceSessionID string
// EventType identifies the stable client-facing event category.
EventType string
// EventID is the stable event correlation identifier.
EventID string
// PayloadBytes carries the opaque event payload bytes.
PayloadBytes []byte
// RequestID optionally correlates the event to an earlier client request.
RequestID string
// TraceID optionally carries tracing correlation.
TraceID string
}
// Subscription represents one active push stream registered in Hub.
type Subscription struct {
hub *Hub
id uint64
binding StreamBinding
events chan Event
done chan struct{}
closeOnce sync.Once
stateMu sync.RWMutex
err error
}
// Observer receives push stream lifecycle notifications suitable for metrics
// bookkeeping.
type Observer interface {
// Registered reports one active push stream binding.
Registered(binding StreamBinding)
// Unregistered reports that binding stopped with err. A nil err means the
// stream ended without a hub-enforced terminal reason.
Unregistered(binding StreamBinding, err error)
}
// Events returns the ordered event queue for the subscription.
func (s *Subscription) Events() <-chan Event {
if s == nil {
return nil
}
return s.events
}
// Done closes when the subscription has been removed from the hub.
func (s *Subscription) Done() <-chan struct{} {
if s == nil {
return nil
}
return s.done
}
// Err returns the terminal subscription error, if any.
func (s *Subscription) Err() error {
if s == nil {
return nil
}
s.stateMu.RLock()
defer s.stateMu.RUnlock()
return s.err
}
// Close unregisters the subscription from its hub.
func (s *Subscription) Close() {
if s == nil || s.hub == nil {
return
}
s.hub.unregister(s.id, nil)
}
func (s *Subscription) enqueue(event Event) bool {
if s == nil {
return true
}
cloned := cloneEvent(event)
select {
case <-s.done:
return true
default:
}
select {
case s.events <- cloned:
return true
case <-s.done:
return true
default:
return false
}
}
func (s *Subscription) closeWithError(err error) {
if s == nil {
return
}
s.closeOnce.Do(func() {
s.stateMu.Lock()
s.err = err
s.stateMu.Unlock()
close(s.done)
})
}
// Hub tracks active authenticated push streams and fans out client-facing
// events to the matching subscriptions.
type Hub struct {
mu sync.RWMutex
nextID uint64
queueCapacity int
observer Observer
byID map[uint64]*Subscription
byUser map[string]map[uint64]*Subscription
bySession map[string]map[uint64]*Subscription
}
// NewHub constructs a push hub with one bounded in-memory queue per
// subscription. Non-positive queueCapacity falls back to the package default.
func NewHub(queueCapacity int) *Hub {
return NewHubWithObserver(queueCapacity, nil)
}
// NewHubWithObserver constructs a push hub that also reports stream lifecycle
// changes to observer.
func NewHubWithObserver(queueCapacity int, observer Observer) *Hub {
if queueCapacity <= 0 {
queueCapacity = defaultSubscriptionQueueCapacity
}
return &Hub{
queueCapacity: queueCapacity,
observer: observer,
byID: make(map[uint64]*Subscription),
byUser: make(map[string]map[uint64]*Subscription),
bySession: make(map[string]map[uint64]*Subscription),
}
}
// Register adds one authenticated push stream to the hub and returns its
// subscription handle.
func (h *Hub) Register(binding StreamBinding) (*Subscription, error) {
if h == nil {
return nil, errors.New("register push subscription: nil hub")
}
userID := strings.TrimSpace(binding.UserID)
if userID == "" {
return nil, errors.New("register push subscription: user id must not be empty")
}
deviceSessionID := strings.TrimSpace(binding.DeviceSessionID)
if deviceSessionID == "" {
return nil, errors.New("register push subscription: device session id must not be empty")
}
h.mu.Lock()
h.nextID++
subscription := &Subscription{
hub: h,
id: h.nextID,
binding: StreamBinding{
UserID: userID,
DeviceSessionID: deviceSessionID,
},
events: make(chan Event, h.queueCapacity),
done: make(chan struct{}),
}
h.byID[subscription.id] = subscription
addIndexedSubscription(h.byUser, userID, subscription)
addIndexedSubscription(h.bySession, deviceSessionID, subscription)
h.mu.Unlock()
if h.observer != nil {
h.observer.Registered(subscription.binding)
}
return subscription, nil
}
// Publish fans out event to the matching active subscriptions. When one
// subscription queue overflows, only that subscription is closed.
func (h *Hub) Publish(event Event) {
if h == nil {
return
}
targets := h.targets(event)
for _, target := range targets {
if target.enqueue(event) {
continue
}
h.unregister(target.id, ErrSubscriptionOverflow)
}
}
// RevokeDeviceSession closes all active subscriptions bound to the exact
// authenticated device session identifier.
func (h *Hub) RevokeDeviceSession(deviceSessionID string) {
if h == nil {
return
}
deviceSessionID = strings.TrimSpace(deviceSessionID)
if deviceSessionID == "" {
return
}
h.mu.RLock()
targets := cloneSubscriptions(h.bySession[deviceSessionID])
h.mu.RUnlock()
for _, target := range targets {
h.unregister(target.id, ErrSubscriptionRevoked)
}
}
// Shutdown closes every active subscription because the gateway is shutting
// down.
func (h *Hub) Shutdown() {
if h == nil {
return
}
h.mu.RLock()
targets := cloneSubscriptions(h.byID)
h.mu.RUnlock()
for _, target := range targets {
h.unregister(target.id, ErrHubShuttingDown)
}
}
func (h *Hub) targets(event Event) []*Subscription {
userID := strings.TrimSpace(event.UserID)
eventType := strings.TrimSpace(event.EventType)
eventID := strings.TrimSpace(event.EventID)
if h == nil || userID == "" || eventType == "" || eventID == "" {
return nil
}
deviceSessionID := strings.TrimSpace(event.DeviceSessionID)
h.mu.RLock()
defer h.mu.RUnlock()
if deviceSessionID == "" {
return cloneSubscriptions(h.byUser[userID])
}
sessionMatches := cloneSubscriptions(h.bySession[deviceSessionID])
filtered := sessionMatches[:0]
for _, subscription := range sessionMatches {
if subscription.binding.UserID == userID {
filtered = append(filtered, subscription)
}
}
return filtered
}
func (h *Hub) unregister(id uint64, err error) {
if h == nil || id == 0 {
return
}
h.mu.Lock()
subscription, ok := h.byID[id]
if !ok {
h.mu.Unlock()
return
}
delete(h.byID, id)
removeIndexedSubscription(h.byUser, subscription.binding.UserID, id)
removeIndexedSubscription(h.bySession, subscription.binding.DeviceSessionID, id)
h.mu.Unlock()
subscription.closeWithError(err)
if h.observer != nil {
h.observer.Unregistered(subscription.binding, err)
}
}
func addIndexedSubscription(index map[string]map[uint64]*Subscription, key string, subscription *Subscription) {
if _, ok := index[key]; !ok {
index[key] = make(map[uint64]*Subscription)
}
index[key][subscription.id] = subscription
}
func removeIndexedSubscription(index map[string]map[uint64]*Subscription, key string, id uint64) {
bucket, ok := index[key]
if !ok {
return
}
delete(bucket, id)
if len(bucket) == 0 {
delete(index, key)
}
}
func cloneSubscriptions(bucket map[uint64]*Subscription) []*Subscription {
if len(bucket) == 0 {
return nil
}
cloned := make([]*Subscription, 0, len(bucket))
for _, subscription := range bucket {
cloned = append(cloned, subscription)
}
return cloned
}
func cloneEvent(event Event) Event {
return Event{
UserID: event.UserID,
DeviceSessionID: event.DeviceSessionID,
EventType: event.EventType,
EventID: event.EventID,
PayloadBytes: bytes.Clone(event.PayloadBytes),
RequestID: event.RequestID,
TraceID: event.TraceID,
}
}
@@ -0,0 +1,77 @@
package push_test
import (
"testing"
"time"
"galaxy/gateway/internal/push"
"galaxy/gateway/internal/telemetry"
"galaxy/gateway/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHubObserverClassifiesClosureReasons(t *testing.T) {
t.Parallel()
logger, _ := testutil.NewObservedLogger(t)
telemetryRuntime := testutil.NewTelemetryRuntime(t, logger)
hub := push.NewHubWithObserver(1, telemetry.NewPushObserver(telemetryRuntime))
overflow, err := hub.Register(push.StreamBinding{
UserID: "user-123",
DeviceSessionID: "device-session-overflow",
})
require.NoError(t, err)
revoked, err := hub.Register(push.StreamBinding{
UserID: "user-123",
DeviceSessionID: "device-session-revoked",
})
require.NoError(t, err)
shutdown, err := hub.Register(push.StreamBinding{
UserID: "user-123",
DeviceSessionID: "device-session-shutdown",
})
require.NoError(t, err)
hub.Publish(push.Event{
UserID: "user-123",
DeviceSessionID: "device-session-overflow",
EventType: "fleet.updated",
EventID: "event-1",
PayloadBytes: []byte("payload-1"),
})
hub.Publish(push.Event{
UserID: "user-123",
DeviceSessionID: "device-session-overflow",
EventType: "fleet.updated",
EventID: "event-2",
PayloadBytes: []byte("payload-2"),
})
hub.RevokeDeviceSession("device-session-revoked")
hub.Shutdown()
select {
case <-overflow.Done():
case <-time.After(time.Second):
require.FailNow(t, "overflow subscription did not close")
}
select {
case <-revoked.Done():
case <-time.After(time.Second):
require.FailNow(t, "revoked subscription did not close")
}
select {
case <-shutdown.Done():
case <-time.After(time.Second):
require.FailNow(t, "shutdown subscription did not close")
}
metricsText := testutil.ScrapeMetrics(t, telemetryRuntime.Handler())
assert.Contains(t, metricsText, `gateway_push_stream_closures_total`)
assert.Contains(t, metricsText, `reason="overflow"`)
assert.Contains(t, metricsText, `reason="revoked"`)
assert.Contains(t, metricsText, `reason="shutdown"`)
assert.Contains(t, metricsText, `gateway_push_active_streams`)
}
+270
View File
@@ -0,0 +1,270 @@
package push
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHubDeliversSessionTargetedEvent(t *testing.T) {
t.Parallel()
hub := NewHub(4)
target, err := hub.Register(StreamBinding{
UserID: "user-123",
DeviceSessionID: "device-session-1",
})
require.NoError(t, err)
otherSession, err := hub.Register(StreamBinding{
UserID: "user-123",
DeviceSessionID: "device-session-2",
})
require.NoError(t, err)
unrelatedUser, err := hub.Register(StreamBinding{
UserID: "user-999",
DeviceSessionID: "device-session-3",
})
require.NoError(t, err)
hub.Publish(Event{
UserID: "user-123",
DeviceSessionID: "device-session-1",
EventType: "fleet.updated",
EventID: "event-1",
PayloadBytes: []byte("payload-1"),
})
assertEvent(t, target.Events(), Event{
UserID: "user-123",
DeviceSessionID: "device-session-1",
EventType: "fleet.updated",
EventID: "event-1",
PayloadBytes: []byte("payload-1"),
})
assertNoEvent(t, otherSession.Events())
assertNoEvent(t, unrelatedUser.Events())
}
func TestHubDeliversUserTargetedEventToAllUserSessions(t *testing.T) {
t.Parallel()
hub := NewHub(4)
first, err := hub.Register(StreamBinding{
UserID: "user-123",
DeviceSessionID: "device-session-1",
})
require.NoError(t, err)
second, err := hub.Register(StreamBinding{
UserID: "user-123",
DeviceSessionID: "device-session-2",
})
require.NoError(t, err)
unrelated, err := hub.Register(StreamBinding{
UserID: "user-999",
DeviceSessionID: "device-session-3",
})
require.NoError(t, err)
hub.Publish(Event{
UserID: "user-123",
EventType: "fleet.updated",
EventID: "event-1",
PayloadBytes: []byte("payload-1"),
RequestID: "request-1",
TraceID: "trace-1",
})
want := Event{
UserID: "user-123",
EventType: "fleet.updated",
EventID: "event-1",
PayloadBytes: []byte("payload-1"),
RequestID: "request-1",
TraceID: "trace-1",
}
assertEvent(t, first.Events(), want)
assertEvent(t, second.Events(), want)
assertNoEvent(t, unrelated.Events())
}
func TestSubscriptionCloseUnregistersStream(t *testing.T) {
t.Parallel()
hub := NewHub(4)
subscription, err := hub.Register(StreamBinding{
UserID: "user-123",
DeviceSessionID: "device-session-1",
})
require.NoError(t, err)
subscription.Close()
select {
case <-subscription.Done():
case <-time.After(time.Second):
require.FailNow(t, "subscription did not close")
}
hub.Publish(Event{
UserID: "user-123",
EventType: "fleet.updated",
EventID: "event-1",
PayloadBytes: []byte("payload-1"),
})
assertNoEvent(t, subscription.Events())
assert.NoError(t, subscription.Err())
}
func TestHubOverflowClosesOnlySlowSubscription(t *testing.T) {
t.Parallel()
hub := NewHub(1)
slow, err := hub.Register(StreamBinding{
UserID: "user-123",
DeviceSessionID: "device-session-1",
})
require.NoError(t, err)
fast, err := hub.Register(StreamBinding{
UserID: "user-123",
DeviceSessionID: "device-session-2",
})
require.NoError(t, err)
hub.Publish(Event{
UserID: "user-123",
EventType: "fleet.updated",
EventID: "event-1",
PayloadBytes: []byte("payload-1"),
})
assertEvent(t, fast.Events(), Event{
UserID: "user-123",
EventType: "fleet.updated",
EventID: "event-1",
PayloadBytes: []byte("payload-1"),
})
hub.Publish(Event{
UserID: "user-123",
EventType: "fleet.updated",
EventID: "event-2",
PayloadBytes: []byte("payload-2"),
})
select {
case <-slow.Done():
case <-time.After(time.Second):
require.FailNow(t, "slow subscription did not close after overflow")
}
assert.ErrorIs(t, slow.Err(), ErrSubscriptionOverflow)
assertEvent(t, fast.Events(), Event{
UserID: "user-123",
EventType: "fleet.updated",
EventID: "event-2",
PayloadBytes: []byte("payload-2"),
})
}
func TestHubRevokeDeviceSessionClosesOnlyMatchingSubscriptions(t *testing.T) {
t.Parallel()
hub := NewHub(4)
targetOne, err := hub.Register(StreamBinding{
UserID: "user-123",
DeviceSessionID: "device-session-1",
})
require.NoError(t, err)
targetTwo, err := hub.Register(StreamBinding{
UserID: "user-456",
DeviceSessionID: "device-session-1",
})
require.NoError(t, err)
otherSession, err := hub.Register(StreamBinding{
UserID: "user-123",
DeviceSessionID: "device-session-2",
})
require.NoError(t, err)
hub.RevokeDeviceSession("device-session-1")
select {
case <-targetOne.Done():
case <-time.After(time.Second):
require.FailNow(t, "first matching subscription did not close after revoke")
}
select {
case <-targetTwo.Done():
case <-time.After(time.Second):
require.FailNow(t, "second matching subscription did not close after revoke")
}
assert.ErrorIs(t, targetOne.Err(), ErrSubscriptionRevoked)
assert.ErrorIs(t, targetTwo.Err(), ErrSubscriptionRevoked)
select {
case <-otherSession.Done():
require.FailNow(t, "unrelated session subscription closed after revoke")
case <-time.After(50 * time.Millisecond):
}
hub.Publish(Event{
UserID: "user-123",
DeviceSessionID: "device-session-2",
EventType: "fleet.updated",
EventID: "event-1",
PayloadBytes: []byte("payload-1"),
})
assertEvent(t, otherSession.Events(), Event{
UserID: "user-123",
DeviceSessionID: "device-session-2",
EventType: "fleet.updated",
EventID: "event-1",
PayloadBytes: []byte("payload-1"),
})
}
func TestHubRevokeDeviceSessionIgnoresUnknownOrEmptySession(t *testing.T) {
t.Parallel()
hub := NewHub(4)
subscription, err := hub.Register(StreamBinding{
UserID: "user-123",
DeviceSessionID: "device-session-1",
})
require.NoError(t, err)
hub.RevokeDeviceSession("")
hub.RevokeDeviceSession("missing-session")
select {
case <-subscription.Done():
require.FailNow(t, "subscription closed for empty or unknown session revoke")
case <-time.After(50 * time.Millisecond):
}
}
func assertEvent(t *testing.T, eventCh <-chan Event, want Event) {
t.Helper()
select {
case got := <-eventCh:
assert.Equal(t, want, got)
case <-time.After(time.Second):
require.FailNow(t, "event was not delivered")
}
}
func assertNoEvent(t *testing.T, eventCh <-chan Event) {
t.Helper()
select {
case got := <-eventCh:
require.FailNowf(t, "unexpected event delivered", "%+v", got)
case <-time.After(50 * time.Millisecond):
}
}
+136
View File
@@ -0,0 +1,136 @@
// Package ratelimit provides small process-local rate-limit primitives used by
// the gateway edge policy layers.
package ratelimit
import (
"sync"
"time"
"golang.org/x/time/rate"
)
// Policy describes one token-bucket budget enforced for a concrete key.
type Policy struct {
// Requests is the number of accepted requests replenished per Window.
Requests int
// Window is the interval over which Requests are replenished.
Window time.Duration
// Burst is the maximum number of immediately available tokens.
Burst int
}
// Decision describes the result of one limiter reservation attempt.
type Decision struct {
// Allowed reports whether the request may proceed immediately.
Allowed bool
// RetryAfter is the minimum delay the caller should wait before retrying
// when Allowed is false.
RetryAfter time.Duration
}
// Limiter applies a policy to one concrete key.
type Limiter interface {
// Reserve evaluates key under policy and reports whether the request may
// proceed immediately.
Reserve(key string, policy Policy) Decision
}
// InMemory is a process-local Limiter backed by x/time/rate token buckets.
type InMemory struct {
now func() time.Time
cleanupInterval time.Duration
mu sync.Mutex
entries map[string]*entry
nextCleanup time.Time
}
type entry struct {
limiter *rate.Limiter
limit rate.Limit
burst int
expiresAt time.Time
}
// NewInMemory constructs a process-local limiter suitable for one gateway
// process instance.
func NewInMemory() *InMemory {
return &InMemory{
now: time.Now,
cleanupInterval: time.Minute,
entries: make(map[string]*entry),
}
}
// Reserve evaluates key against policy and reports whether the request may
// proceed immediately.
func (l *InMemory) Reserve(key string, policy Policy) Decision {
if policy.Requests <= 0 || policy.Window <= 0 || policy.Burst <= 0 {
return Decision{}
}
now := l.now()
limit := rate.Limit(float64(policy.Requests) / policy.Window.Seconds())
l.mu.Lock()
defer l.mu.Unlock()
l.cleanupExpiredBucketsLocked(now)
current, ok := l.entries[key]
if !ok || current.limit != limit || current.burst != policy.Burst {
current = &entry{
limiter: rate.NewLimiter(limit, policy.Burst),
limit: limit,
burst: policy.Burst,
}
l.entries[key] = current
}
current.expiresAt = now.Add(entryTTL(policy.Window))
reservation := current.limiter.ReserveN(now, 1)
if !reservation.OK() {
return Decision{
Allowed: false,
RetryAfter: policy.Window,
}
}
retryAfter := reservation.DelayFrom(now)
if retryAfter > 0 {
return Decision{
Allowed: false,
RetryAfter: retryAfter,
}
}
return Decision{Allowed: true}
}
func (l *InMemory) cleanupExpiredBucketsLocked(now time.Time) {
if !l.nextCleanup.IsZero() && now.Before(l.nextCleanup) {
return
}
for key, current := range l.entries {
if !current.expiresAt.After(now) {
delete(l.entries, key)
}
}
l.nextCleanup = now.Add(l.cleanupInterval)
}
func entryTTL(window time.Duration) time.Duration {
if window < time.Minute {
return time.Minute
}
return 2 * window
}
var _ Limiter = (*InMemory)(nil)
@@ -0,0 +1,49 @@
package ratelimit
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestInMemoryReserve(t *testing.T) {
t.Parallel()
limiter := NewInMemory()
policy := Policy{
Requests: 1,
Window: time.Hour,
Burst: 1,
}
first := limiter.Reserve("bucket-1", policy)
second := limiter.Reserve("bucket-1", policy)
otherBucket := limiter.Reserve("bucket-2", policy)
assert.True(t, first.Allowed)
assert.False(t, second.Allowed)
assert.Positive(t, second.RetryAfter)
assert.True(t, otherBucket.Allowed)
}
func TestInMemoryReserveResetsOnPolicyChange(t *testing.T) {
t.Parallel()
limiter := NewInMemory()
initialPolicy := Policy{
Requests: 1,
Window: time.Hour,
Burst: 1,
}
updatedPolicy := Policy{
Requests: 2,
Window: time.Hour,
Burst: 2,
}
assert.True(t, limiter.Reserve("bucket-1", initialPolicy).Allowed)
assert.False(t, limiter.Reserve("bucket-1", initialPolicy).Allowed)
assert.True(t, limiter.Reserve("bucket-1", updatedPolicy).Allowed)
}
+131
View File
@@ -0,0 +1,131 @@
package replay
import (
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"galaxy/gateway/internal/config"
"github.com/redis/go-redis/v9"
)
// RedisStore implements Store with Redis SETNX reservations over a dedicated
// key namespace.
type RedisStore struct {
client *redis.Client
keyPrefix string
reserveTimeout time.Duration
}
// NewRedisStore constructs a Redis-backed replay store that reuses the
// SessionCache Redis deployment settings and applies the replay-specific key
// namespace and timeout controls from replayCfg.
func NewRedisStore(sessionCfg config.SessionCacheRedisConfig, replayCfg config.ReplayRedisConfig) (*RedisStore, error) {
if strings.TrimSpace(sessionCfg.Addr) == "" {
return nil, errors.New("new redis replay store: redis addr must not be empty")
}
if sessionCfg.DB < 0 {
return nil, errors.New("new redis replay store: redis db must not be negative")
}
if strings.TrimSpace(replayCfg.KeyPrefix) == "" {
return nil, errors.New("new redis replay store: replay key prefix must not be empty")
}
if replayCfg.ReserveTimeout <= 0 {
return nil, errors.New("new redis replay store: reserve timeout must be positive")
}
options := &redis.Options{
Addr: sessionCfg.Addr,
Username: sessionCfg.Username,
Password: sessionCfg.Password,
DB: sessionCfg.DB,
Protocol: 2,
DisableIdentity: true,
}
if sessionCfg.TLSEnabled {
options.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
}
return &RedisStore{
client: redis.NewClient(options),
keyPrefix: replayCfg.KeyPrefix,
reserveTimeout: replayCfg.ReserveTimeout,
}, nil
}
// Close releases the underlying Redis client resources.
func (s *RedisStore) Close() error {
if s == nil || s.client == nil {
return nil
}
return s.client.Close()
}
// Ping verifies that the configured Redis backend is reachable within the
// replay reserve timeout budget.
func (s *RedisStore) Ping(ctx context.Context) error {
if s == nil || s.client == nil {
return errors.New("ping redis replay store: nil store")
}
if ctx == nil {
return errors.New("ping redis replay store: nil context")
}
pingCtx, cancel := context.WithTimeout(ctx, s.reserveTimeout)
defer cancel()
if err := s.client.Ping(pingCtx).Err(); err != nil {
return fmt.Errorf("ping redis replay store: %w", err)
}
return nil
}
// Reserve records the authenticated deviceSessionID and requestID pair for
// ttl. It rejects duplicates while the reservation remains active.
func (s *RedisStore) Reserve(ctx context.Context, deviceSessionID string, requestID string, ttl time.Duration) error {
if s == nil || s.client == nil {
return errors.New("reserve replay request in redis: nil store")
}
if ctx == nil {
return errors.New("reserve replay request in redis: nil context")
}
if strings.TrimSpace(deviceSessionID) == "" {
return errors.New("reserve replay request in redis: empty device session id")
}
if strings.TrimSpace(requestID) == "" {
return errors.New("reserve replay request in redis: empty request id")
}
if ttl <= 0 {
return errors.New("reserve replay request in redis: ttl must be positive")
}
reserveCtx, cancel := context.WithTimeout(ctx, s.reserveTimeout)
defer cancel()
reserved, err := s.client.SetNX(reserveCtx, s.reservationKey(deviceSessionID, requestID), "1", ttl).Result()
if err != nil {
return fmt.Errorf("reserve replay request in redis: %w", err)
}
if !reserved {
return fmt.Errorf("reserve replay request in redis: %w", ErrDuplicate)
}
return nil
}
func (s *RedisStore) reservationKey(deviceSessionID string, requestID string) string {
return s.keyPrefix + encodeKeyComponent(deviceSessionID) + ":" + encodeKeyComponent(requestID)
}
func encodeKeyComponent(value string) string {
return base64.RawURLEncoding.EncodeToString([]byte(value))
}
var _ Store = (*RedisStore)(nil)
+254
View File
@@ -0,0 +1,254 @@
package replay
import (
"context"
"errors"
"net"
"testing"
"time"
"galaxy/gateway/internal/config"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewRedisStore(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
tests := []struct {
name string
sessionCfg config.SessionCacheRedisConfig
replayCfg config.ReplayRedisConfig
wantErr string
}{
{
name: "valid config",
sessionCfg: config.SessionCacheRedisConfig{
Addr: server.Addr(),
DB: 2,
},
replayCfg: config.ReplayRedisConfig{
KeyPrefix: "gateway:replay:",
ReserveTimeout: 250 * time.Millisecond,
},
},
{
name: "empty redis addr",
replayCfg: config.ReplayRedisConfig{
KeyPrefix: "gateway:replay:",
ReserveTimeout: 250 * time.Millisecond,
},
wantErr: "redis addr must not be empty",
},
{
name: "negative redis db",
sessionCfg: config.SessionCacheRedisConfig{
Addr: server.Addr(),
DB: -1,
},
replayCfg: config.ReplayRedisConfig{
KeyPrefix: "gateway:replay:",
ReserveTimeout: 250 * time.Millisecond,
},
wantErr: "redis db must not be negative",
},
{
name: "empty replay key prefix",
sessionCfg: config.SessionCacheRedisConfig{
Addr: server.Addr(),
},
replayCfg: config.ReplayRedisConfig{
ReserveTimeout: 250 * time.Millisecond,
},
wantErr: "replay key prefix must not be empty",
},
{
name: "non-positive reserve timeout",
sessionCfg: config.SessionCacheRedisConfig{
Addr: server.Addr(),
},
replayCfg: config.ReplayRedisConfig{
KeyPrefix: "gateway:replay:",
},
wantErr: "reserve timeout must be positive",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
store, err := NewRedisStore(tt.sessionCfg, tt.replayCfg)
if tt.wantErr != "" {
require.Error(t, err)
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
t.Cleanup(func() {
assert.NoError(t, store.Close())
})
})
}
}
func TestRedisStorePing(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
store := newTestRedisStore(t, server, config.SessionCacheRedisConfig{}, config.ReplayRedisConfig{})
require.NoError(t, store.Ping(context.Background()))
}
func TestRedisStoreReserve(t *testing.T) {
t.Parallel()
tests := []struct {
name string
sessionCfg config.SessionCacheRedisConfig
replayCfg config.ReplayRedisConfig
deviceSessionID string
requestID string
ttl time.Duration
secondReserve func(*testing.T, Store)
wantErrIs error
wantErrText string
}{
{
name: "first reservation succeeds",
deviceSessionID: "device-session-123",
requestID: "request-123",
ttl: 5 * time.Second,
},
{
name: "duplicate reservation is rejected",
deviceSessionID: "device-session-123",
requestID: "request-123",
ttl: 5 * time.Second,
secondReserve: func(t *testing.T, store Store) {
t.Helper()
err := store.Reserve(context.Background(), "device-session-123", "request-123", 5*time.Second)
require.ErrorIs(t, err, ErrDuplicate)
},
},
{
name: "same request id in distinct sessions does not collide",
deviceSessionID: "device-session-123",
requestID: "request-123",
ttl: 5 * time.Second,
secondReserve: func(t *testing.T, store Store) {
t.Helper()
require.NoError(t, store.Reserve(context.Background(), "device-session-456", "request-123", 5*time.Second))
},
},
{
name: "empty device session id",
requestID: "request-123",
ttl: 5 * time.Second,
wantErrText: "empty device session id",
},
{
name: "empty request id",
deviceSessionID: "device-session-123",
ttl: 5 * time.Second,
wantErrText: "empty request id",
},
{
name: "non-positive ttl",
deviceSessionID: "device-session-123",
requestID: "request-123",
wantErrText: "ttl must be positive",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
store := newTestRedisStore(t, server, tt.sessionCfg, tt.replayCfg)
err := store.Reserve(context.Background(), tt.deviceSessionID, tt.requestID, tt.ttl)
if tt.wantErrIs != nil || tt.wantErrText != "" {
require.Error(t, err)
if tt.wantErrIs != nil {
require.ErrorIs(t, err, tt.wantErrIs)
}
if tt.wantErrText != "" {
require.ErrorContains(t, err, tt.wantErrText)
}
return
}
require.NoError(t, err)
if tt.secondReserve != nil {
tt.secondReserve(t, store)
}
})
}
}
func TestRedisStoreReserveReturnsBackendError(t *testing.T) {
t.Parallel()
store, err := NewRedisStore(
config.SessionCacheRedisConfig{Addr: unusedTCPAddr(t)},
config.ReplayRedisConfig{
KeyPrefix: "gateway:replay:",
ReserveTimeout: 100 * time.Millisecond,
},
)
require.NoError(t, err)
t.Cleanup(func() {
assert.NoError(t, store.Close())
})
err = store.Reserve(context.Background(), "device-session-123", "request-123", 5*time.Second)
require.Error(t, err)
assert.False(t, errors.Is(err, ErrDuplicate))
assert.ErrorContains(t, err, "reserve replay request in redis")
}
func newTestRedisStore(t *testing.T, server *miniredis.Miniredis, sessionCfg config.SessionCacheRedisConfig, replayCfg config.ReplayRedisConfig) *RedisStore {
t.Helper()
if sessionCfg.Addr == "" {
sessionCfg.Addr = server.Addr()
}
if replayCfg.KeyPrefix == "" {
replayCfg.KeyPrefix = "gateway:replay:"
}
if replayCfg.ReserveTimeout == 0 {
replayCfg.ReserveTimeout = 250 * time.Millisecond
}
store, err := NewRedisStore(sessionCfg, replayCfg)
require.NoError(t, err)
t.Cleanup(func() {
assert.NoError(t, store.Close())
})
return store
}
func unusedTCPAddr(t *testing.T) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
addr := listener.Addr().String()
require.NoError(t, listener.Close())
return addr
}
+24
View File
@@ -0,0 +1,24 @@
// Package replay defines the authenticated replay-reservation contract used by
// the gateway transport pipeline.
package replay
import (
"context"
"errors"
"time"
)
var (
// ErrDuplicate reports that the request identifier has already been
// reserved for the same device session within the active replay window.
ErrDuplicate = errors.New("replay reservation already exists")
)
// Store reserves authenticated transport request identifiers for a bounded
// replay window.
type Store interface {
// Reserve marks the deviceSessionID and requestID pair as seen for ttl.
// Implementations must wrap ErrDuplicate when the same pair is reserved
// again before ttl expires.
Reserve(ctx context.Context, deviceSessionID string, requestID string, ttl time.Duration) error
}
+76
View File
@@ -0,0 +1,76 @@
package restapi
import (
"time"
"galaxy/gateway/internal/logging"
"galaxy/gateway/internal/telemetry"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/otel/attribute"
"go.uber.org/zap"
)
func withPublicObservability(logger *zap.Logger, metrics *telemetry.Runtime) gin.HandlerFunc {
if logger == nil {
logger = zap.NewNop()
}
return func(c *gin.Context) {
start := time.Now()
c.Next()
statusCode := c.Writer.Status()
route := c.FullPath()
if route == "" {
route = c.Request.URL.Path
}
class, ok := PublicRouteClassFromContext(c.Request.Context())
if !ok {
class = PublicRouteClassPublicMisc
}
errorCode, _ := c.Get(publicErrorCodeContextKey)
errorCodeValue, _ := errorCode.(string)
outcome := telemetry.OutcomeFromPublicErrorCode(statusCode, errorCodeValue)
rejectReason := telemetry.RejectReason(outcome)
duration := time.Since(start)
attrs := []attribute.KeyValue{
attribute.String("route_class", string(class)),
attribute.String("route", route),
attribute.String("method", c.Request.Method),
attribute.String("edge_outcome", string(outcome)),
}
if rejectReason != "" {
attrs = append(attrs, attribute.String("reject_reason", rejectReason))
}
metrics.RecordPublicRequest(c.Request.Context(), attrs, duration)
fields := []zap.Field{
zap.String("component", "public_http"),
zap.String("transport", "http"),
zap.String("route", route),
zap.String("route_class", string(class)),
zap.String("method", c.Request.Method),
zap.Int("status_code", statusCode),
zap.Float64("duration_ms", float64(duration.Microseconds())/1000),
zap.String("edge_outcome", string(outcome)),
}
if rejectReason != "" {
fields = append(fields, zap.String("reject_reason", rejectReason))
}
fields = append(fields, logging.TraceFieldsFromContext(c.Request.Context())...)
switch outcome {
case telemetry.EdgeOutcomeSuccess:
logger.Info("public request completed", fields...)
case telemetry.EdgeOutcomeBackendUnavailable, telemetry.EdgeOutcomeInternalError:
logger.Error("public request failed", fields...)
default:
logger.Warn("public request rejected", fields...)
}
}
}
+30
View File
@@ -0,0 +1,30 @@
package restapi
import (
"context"
"path/filepath"
"runtime"
"testing"
"github.com/getkin/kin-openapi/openapi3"
"github.com/stretchr/testify/require"
)
func TestPublicOpenAPISpecValidates(t *testing.T) {
t.Parallel()
_, thisFile, _, ok := runtime.Caller(0)
require.True(t, ok)
specPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "openapi.yaml")
ctx := context.Background()
loader := openapi3.NewLoader()
doc, err := loader.LoadFromFile(specPath)
require.NoError(t, err)
require.NotNil(t, doc)
require.NotNil(t, doc.Info)
require.Equal(t, "v1", doc.Info.Version)
require.NoError(t, doc.Validate(ctx))
}
@@ -0,0 +1,378 @@
package restapi
import (
"bytes"
"errors"
"io"
"math"
"net"
"net/http"
"path"
"strconv"
"strings"
"sync"
"time"
"galaxy/gateway/internal/config"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
const (
errorCodeRequestTooLarge = "request_too_large"
errorCodeRateLimited = "rate_limited"
publicRESTIPBucketKeySegment = "/ip="
)
var errRequestBodyTooLarge = errors.New("request body exceeds the configured limit")
// PublicMalformedRequestReason identifies the stable malformed-request counter
// dimension recorded by the public REST anti-abuse middleware.
type PublicMalformedRequestReason string
const (
// PublicMalformedRequestReasonEmptyBody records a missing request body.
PublicMalformedRequestReasonEmptyBody PublicMalformedRequestReason = "empty_body"
// PublicMalformedRequestReasonMalformedJSON records syntactically malformed
// JSON.
PublicMalformedRequestReasonMalformedJSON PublicMalformedRequestReason = "malformed_json"
// PublicMalformedRequestReasonInvalidJSONValue records JSON values whose
// types do not match the expected request schema.
PublicMalformedRequestReasonInvalidJSONValue PublicMalformedRequestReason = "invalid_json_value"
// PublicMalformedRequestReasonUnknownField records JSON objects with fields
// outside the documented schema.
PublicMalformedRequestReasonUnknownField PublicMalformedRequestReason = "unknown_field"
// PublicMalformedRequestReasonMultipleJSONObjects records requests that
// contain more than one JSON object.
PublicMalformedRequestReasonMultipleJSONObjects PublicMalformedRequestReason = "multiple_json_objects"
// PublicMalformedRequestReasonOversizedBody records requests whose bodies
// exceed the configured class limit.
PublicMalformedRequestReasonOversizedBody PublicMalformedRequestReason = "oversized_body"
)
// PublicRateLimitDecision describes the outcome returned by a public REST
// limiter for one request bucket reservation attempt.
type PublicRateLimitDecision struct {
// Allowed reports whether the request may proceed immediately.
Allowed bool
// RetryAfter is the minimum delay the client should wait before retrying
// when Allowed is false.
RetryAfter time.Duration
}
// PublicRequestLimiter applies public REST rate-limit policy to a concrete
// bucket key.
type PublicRequestLimiter interface {
// Reserve evaluates key under policy and returns whether the request may
// proceed immediately.
Reserve(key string, policy config.PublicRateLimitConfig) PublicRateLimitDecision
}
// PublicRequestObserver captures low-cardinality public REST anti-abuse
// telemetry.
type PublicRequestObserver interface {
// RecordMalformedRequest records one malformed request in class for reason.
RecordMalformedRequest(class PublicRouteClass, reason PublicMalformedRequestReason)
}
type noopPublicRequestObserver struct{}
func (noopPublicRequestObserver) RecordMalformedRequest(PublicRouteClass, PublicMalformedRequestReason) {
}
type inMemoryPublicRequestLimiter struct {
now func() time.Time
cleanupInterval time.Duration
mu sync.Mutex
entries map[string]*publicRateLimiterEntry
nextCleanup time.Time
}
type publicRateLimiterEntry struct {
limiter *rate.Limiter
limit rate.Limit
burst int
expiresAt time.Time
}
func newInMemoryPublicRequestLimiter() *inMemoryPublicRequestLimiter {
return &inMemoryPublicRequestLimiter{
now: time.Now,
cleanupInterval: time.Minute,
entries: make(map[string]*publicRateLimiterEntry),
}
}
func (l *inMemoryPublicRequestLimiter) Reserve(key string, policy config.PublicRateLimitConfig) PublicRateLimitDecision {
now := l.now()
limit := rate.Limit(float64(policy.Requests) / policy.Window.Seconds())
l.mu.Lock()
defer l.mu.Unlock()
l.cleanupExpiredBucketsLocked(now)
entry, ok := l.entries[key]
if !ok || entry.limit != limit || entry.burst != policy.Burst {
entry = &publicRateLimiterEntry{
limiter: rate.NewLimiter(limit, policy.Burst),
limit: limit,
burst: policy.Burst,
}
l.entries[key] = entry
}
entry.expiresAt = now.Add(publicRateLimiterEntryTTL(policy.Window))
reservation := entry.limiter.ReserveN(now, 1)
if !reservation.OK() {
return PublicRateLimitDecision{
Allowed: false,
RetryAfter: policy.Window,
}
}
retryAfter := reservation.DelayFrom(now)
if retryAfter > 0 {
return PublicRateLimitDecision{
Allowed: false,
RetryAfter: retryAfter,
}
}
return PublicRateLimitDecision{Allowed: true}
}
func (l *inMemoryPublicRequestLimiter) cleanupExpiredBucketsLocked(now time.Time) {
if !l.nextCleanup.IsZero() && now.Before(l.nextCleanup) {
return
}
for key, entry := range l.entries {
if !entry.expiresAt.After(now) {
delete(l.entries, key)
}
}
l.nextCleanup = now.Add(l.cleanupInterval)
}
func publicRateLimiterEntryTTL(window time.Duration) time.Duration {
if window < time.Minute {
return time.Minute
}
return 2 * window
}
func withPublicAntiAbuse(policy config.PublicHTTPAntiAbuseConfig, limiter PublicRequestLimiter, observer PublicRequestObserver) gin.HandlerFunc {
return func(c *gin.Context) {
class, ok := PublicRouteClassFromContext(c.Request.Context())
if !ok {
class = PublicRouteClassPublicMisc
}
allowedMethods := allowedMethodsForRequestShape(c.Request)
if len(allowedMethods) > 0 && !isAllowedMethod(c.Request.Method, allowedMethods) {
c.Header("Allow", strings.Join(allowedMethods, ", "))
abortWithError(c, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed, "request method is not allowed for this route")
return
}
classPolicy := publicRoutePolicyForClass(policy, class)
bodyBytes, err := bufferRequestBody(c.Request, classPolicy.MaxBodyBytes)
if err != nil {
switch {
case errors.Is(err, errRequestBodyTooLarge):
observer.RecordMalformedRequest(class, PublicMalformedRequestReasonOversizedBody)
abortWithError(c, http.StatusRequestEntityTooLarge, errorCodeRequestTooLarge, "request body exceeds the configured limit")
default:
abortWithError(c, http.StatusInternalServerError, errorCodeInternalError, "internal server error")
}
return
}
clientIP := clientIPFromRemoteAddr(c.Request.RemoteAddr)
if decision := limiter.Reserve(publicRESTIPBucketKey(class, clientIP), classPolicy.RateLimit); !decision.Allowed {
abortRateLimited(c, decision.RetryAfter)
return
}
identity, err := extractPublicAuthIdentity(c.Request.URL.Path, bodyBytes)
switch {
case err == nil:
identityPolicy := publicAuthIdentityPolicyForPath(c.Request.URL.Path, policy)
if decision := limiter.Reserve(publicAuthIdentityBucketKey(class, identity.kind, identity.value), identityPolicy.RateLimit); !decision.Allowed {
abortRateLimited(c, decision.RetryAfter)
return
}
case errors.Is(err, errPublicAuthIdentityNotApplicable):
default:
if reason, malformed := malformedRequestReasonFromError(err); malformed {
observer.RecordMalformedRequest(class, reason)
}
}
c.Next()
}
}
func publicRoutePolicyForClass(policy config.PublicHTTPAntiAbuseConfig, class PublicRouteClass) config.PublicRoutePolicyConfig {
switch class.Normalized() {
case PublicRouteClassPublicAuth:
return policy.PublicAuth
case PublicRouteClassBrowserBootstrap:
return policy.BrowserBootstrap
case PublicRouteClassBrowserAsset:
return policy.BrowserAsset
default:
return policy.PublicMisc
}
}
func publicAuthIdentityPolicyForPath(requestPath string, policy config.PublicHTTPAntiAbuseConfig) config.PublicAuthIdentityPolicyConfig {
switch requestPath {
case "/api/v1/public/auth/send-email-code":
return policy.SendEmailCodeIdentity
case "/api/v1/public/auth/confirm-email-code":
return policy.ConfirmEmailCodeIdentity
default:
return config.PublicAuthIdentityPolicyConfig{}
}
}
func allowedMethodsForRequestShape(r *http.Request) []string {
switch {
case isPublicAuthPath(r.URL.Path):
return []string{http.MethodPost}
case isProbePath(r.URL.Path):
return []string{http.MethodGet}
case matchesBrowserAssetRequestShape(r):
return []string{http.MethodGet, http.MethodHead}
case matchesBrowserBootstrapRequestShape(r):
return []string{http.MethodGet, http.MethodHead}
default:
return nil
}
}
func isAllowedMethod(method string, allowedMethods []string) bool {
for _, allowedMethod := range allowedMethods {
if method == allowedMethod {
return true
}
}
return false
}
func isPublicAuthPath(requestPath string) bool {
switch requestPath {
case "/api/v1/public/auth/send-email-code", "/api/v1/public/auth/confirm-email-code":
return true
default:
return false
}
}
func isProbePath(requestPath string) bool {
switch requestPath {
case "/healthz", "/readyz":
return true
default:
return false
}
}
func matchesBrowserBootstrapRequestShape(r *http.Request) bool {
if r.URL.Path == "/" {
return true
}
return strings.Contains(strings.ToLower(r.Header.Get("Accept")), "text/html")
}
func matchesBrowserAssetRequestShape(r *http.Request) bool {
if strings.HasPrefix(r.URL.Path, "/assets/") {
return true
}
switch strings.ToLower(path.Ext(r.URL.Path)) {
case ".js", ".mjs", ".css", ".map", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2", ".json", ".webmanifest":
return true
default:
return false
}
}
func bufferRequestBody(r *http.Request, maxBodyBytes int64) ([]byte, error) {
if r == nil {
return nil, nil
}
if r.Body == nil {
r.Body = io.NopCloser(bytes.NewReader(nil))
return nil, nil
}
bodyBytes, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1))
closeErr := r.Body.Close()
if err != nil {
return nil, err
}
if closeErr != nil {
return nil, closeErr
}
if int64(len(bodyBytes)) > maxBodyBytes {
return nil, errRequestBodyTooLarge
}
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
return bodyBytes, nil
}
func abortRateLimited(c *gin.Context, retryAfter time.Duration) {
c.Header("Retry-After", retryAfterHeaderValue(retryAfter))
abortWithError(c, http.StatusTooManyRequests, errorCodeRateLimited, "request rate limit exceeded")
}
func retryAfterHeaderValue(delay time.Duration) string {
seconds := int64(math.Ceil(delay.Seconds()))
if seconds < 1 {
seconds = 1
}
return strconv.FormatInt(seconds, 10)
}
func clientIPFromRemoteAddr(remoteAddr string) string {
host, _, err := net.SplitHostPort(strings.TrimSpace(remoteAddr))
if err == nil {
return host
}
remoteAddr = strings.TrimSpace(remoteAddr)
if remoteAddr == "" {
return "unknown"
}
return remoteAddr
}
func publicRESTIPBucketKey(class PublicRouteClass, clientIP string) string {
return class.BaseBucketKey() + publicRESTIPBucketKeySegment + clientIP
}
func publicAuthIdentityBucketKey(class PublicRouteClass, identityKind string, identityValue string) string {
return class.BaseBucketKey() + "/" + identityKind + "=" + identityValue
}
@@ -0,0 +1,455 @@
package restapi
import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"galaxy/gateway/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPublicAntiAbuseRejectsOversizedBodies(t *testing.T) {
t.Parallel()
oversizedJSONBody := `{"email":"` + strings.Repeat("a", 8200) + `@example.com"}`
oversizedConfirmJSONBody := `{"challenge_id":"` + strings.Repeat("c", 8300) + `","code":"123456","client_public_key":"key"}`
tests := []struct {
name string
method string
target string
body string
wantClass PublicRouteClass
}{
{
name: "send email",
method: http.MethodPost,
target: "/api/v1/public/auth/send-email-code",
body: oversizedJSONBody,
wantClass: PublicRouteClassPublicAuth,
},
{
name: "confirm email",
method: http.MethodPost,
target: "/api/v1/public/auth/confirm-email-code",
body: oversizedConfirmJSONBody,
wantClass: PublicRouteClassPublicAuth,
},
{
name: "healthz body",
method: http.MethodGet,
target: "/healthz",
body: `x`,
wantClass: PublicRouteClassPublicMisc,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
observer := &recordingPublicRequestObserver{}
authService := &recordingAuthServiceClient{
sendEmailCodeResult: SendEmailCodeResult{ChallengeID: "challenge-123"},
confirmEmailCodeResult: ConfirmEmailCodeResult{
DeviceSessionID: "device-session-123",
},
}
handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{
AuthService: authService,
Observer: observer,
})
req := httptest.NewRequest(tt.method, tt.target, strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code)
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
assert.Equal(t, `{"error":{"code":"request_too_large","message":"request body exceeds the configured limit"}}`, recorder.Body.String())
assert.Equal(t, 0, authService.sendEmailCodeCalls)
assert.Equal(t, 0, authService.confirmEmailCodeCalls)
assert.Equal(t, []malformedObservation{{
class: tt.wantClass,
reason: PublicMalformedRequestReasonOversizedBody,
}}, observer.snapshot())
})
}
}
func TestPublicAntiAbuseRejectsInvalidMethodsForBrowserShapes(t *testing.T) {
t.Parallel()
handler := newPublicHandler(ServerDependencies{})
tests := []struct {
name string
method string
target string
accept string
wantAllow string
}{
{
name: "asset path",
method: http.MethodPost,
target: "/assets/app.js",
wantAllow: "GET, HEAD",
},
{
name: "bootstrap request",
method: http.MethodPost,
target: "/",
accept: "text/html",
wantAllow: "GET, HEAD",
},
{
name: "head probe rejected",
method: http.MethodHead,
target: "/healthz",
wantAllow: http.MethodGet,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(tt.method, tt.target, nil)
if tt.accept != "" {
req.Header.Set("Accept", tt.accept)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusMethodNotAllowed, recorder.Code)
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
assert.Equal(t, tt.wantAllow, recorder.Header().Get("Allow"))
assert.Equal(t, `{"error":{"code":"method_not_allowed","message":"request method is not allowed for this route"}}`, recorder.Body.String())
})
}
}
func TestPublicAntiAbuseBrowserClassBucketsStayIsolatedFromPublicAuth(t *testing.T) {
t.Parallel()
tests := []struct {
name string
burstRequest *http.Request
}{
{
name: "browser asset",
burstRequest: httptest.NewRequest(http.MethodGet, "/assets/app.js", nil),
},
{
name: "browser bootstrap",
burstRequest: func() *http.Request {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Accept", "text/html")
return req
}(),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := config.DefaultPublicHTTPConfig()
cfg.AntiAbuse.BrowserAsset.RateLimit = config.PublicRateLimitConfig{
Requests: 1,
Window: time.Hour,
Burst: 1,
}
cfg.AntiAbuse.BrowserBootstrap.RateLimit = config.PublicRateLimitConfig{
Requests: 1,
Window: time.Hour,
Burst: 1,
}
cfg.AntiAbuse.PublicAuth.RateLimit = config.PublicRateLimitConfig{
Requests: 100,
Window: time.Hour,
Burst: 100,
}
authService := &recordingAuthServiceClient{
sendEmailCodeResult: SendEmailCodeResult{
ChallengeID: "challenge-123",
},
}
handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AuthService: authService})
tt.burstRequest.RemoteAddr = "192.0.2.10:1234"
firstBurst := httptest.NewRecorder()
handler.ServeHTTP(firstBurst, tt.burstRequest.Clone(tt.burstRequest.Context()))
secondBurst := httptest.NewRecorder()
handler.ServeHTTP(secondBurst, tt.burstRequest.Clone(tt.burstRequest.Context()))
authReq := httptest.NewRequest(http.MethodPost, "/api/v1/public/auth/send-email-code", strings.NewReader(`{"email":"pilot@example.com"}`))
authReq.Header.Set("Content-Type", "application/json")
authReq.RemoteAddr = "192.0.2.10:1234"
authResp := httptest.NewRecorder()
handler.ServeHTTP(authResp, authReq)
assert.Equal(t, http.StatusNotFound, firstBurst.Code)
assert.Equal(t, http.StatusTooManyRequests, secondBurst.Code)
assert.Equal(t, http.StatusOK, authResp.Code)
assert.Equal(t, `{"challenge_id":"challenge-123"}`, authResp.Body.String())
assert.Equal(t, 1, authService.sendEmailCodeCalls)
})
}
}
func TestPublicAntiAbuseSendEmailIdentityThrottle(t *testing.T) {
t.Parallel()
cfg := config.DefaultPublicHTTPConfig()
cfg.AntiAbuse.PublicAuth.RateLimit = config.PublicRateLimitConfig{
Requests: 100,
Window: time.Hour,
Burst: 100,
}
cfg.AntiAbuse.SendEmailCodeIdentity.RateLimit = config.PublicRateLimitConfig{
Requests: 1,
Window: time.Hour,
Burst: 1,
}
authService := &recordingAuthServiceClient{
sendEmailCodeResult: SendEmailCodeResult{
ChallengeID: "challenge-123",
},
}
handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AuthService: authService})
first := sendEmailCodeRequest(`{"email":"pilot@example.com"}`)
second := sendEmailCodeRequest(`{"email":"pilot@example.com"}`)
third := sendEmailCodeRequest(`{"email":"other@example.com"}`)
firstResp := httptest.NewRecorder()
handler.ServeHTTP(firstResp, first)
secondResp := httptest.NewRecorder()
handler.ServeHTTP(secondResp, second)
thirdResp := httptest.NewRecorder()
handler.ServeHTTP(thirdResp, third)
assert.Equal(t, http.StatusOK, firstResp.Code)
assert.Equal(t, http.StatusTooManyRequests, secondResp.Code)
assert.Equal(t, "3600", secondResp.Header().Get("Retry-After"))
assert.Equal(t, http.StatusOK, thirdResp.Code)
assert.Equal(t, 2, authService.sendEmailCodeCalls)
thirdInput := authService.sendEmailCodeInput
assert.Equal(t, "other@example.com", thirdInput.Email)
}
func TestPublicAntiAbuseConfirmEmailIdentityThrottle(t *testing.T) {
t.Parallel()
cfg := config.DefaultPublicHTTPConfig()
cfg.AntiAbuse.PublicAuth.RateLimit = config.PublicRateLimitConfig{
Requests: 100,
Window: time.Hour,
Burst: 100,
}
cfg.AntiAbuse.ConfirmEmailCodeIdentity.RateLimit = config.PublicRateLimitConfig{
Requests: 1,
Window: time.Hour,
Burst: 1,
}
authService := &recordingAuthServiceClient{
confirmEmailCodeResult: ConfirmEmailCodeResult{
DeviceSessionID: "device-session-123",
},
}
handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AuthService: authService})
first := confirmEmailCodeRequest(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`)
second := confirmEmailCodeRequest(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`)
third := confirmEmailCodeRequest(`{"challenge_id":"challenge-456","code":"123456","client_public_key":"public-key-material"}`)
firstResp := httptest.NewRecorder()
handler.ServeHTTP(firstResp, first)
secondResp := httptest.NewRecorder()
handler.ServeHTTP(secondResp, second)
thirdResp := httptest.NewRecorder()
handler.ServeHTTP(thirdResp, third)
assert.Equal(t, http.StatusOK, firstResp.Code)
assert.Equal(t, http.StatusTooManyRequests, secondResp.Code)
assert.Equal(t, "3600", secondResp.Header().Get("Retry-After"))
assert.Equal(t, http.StatusOK, thirdResp.Code)
assert.Equal(t, 2, authService.confirmEmailCodeCalls)
assert.Equal(t, "challenge-456", authService.confirmEmailCodeInput.ChallengeID)
}
func TestPublicAntiAbuseMalformedTelemetry(t *testing.T) {
t.Parallel()
tests := []struct {
name string
body string
wantReason PublicMalformedRequestReason
wantRecords int
}{
{
name: "empty body",
body: ``,
wantReason: PublicMalformedRequestReasonEmptyBody,
wantRecords: 1,
},
{
name: "malformed json",
body: `{"email":`,
wantReason: PublicMalformedRequestReasonMalformedJSON,
wantRecords: 1,
},
{
name: "invalid json value",
body: `{"email":123}`,
wantReason: PublicMalformedRequestReasonInvalidJSONValue,
wantRecords: 1,
},
{
name: "unknown field",
body: `{"email":"pilot@example.com","extra":"x"}`,
wantReason: PublicMalformedRequestReasonUnknownField,
wantRecords: 1,
},
{
name: "multiple objects",
body: `{"email":"pilot@example.com"}{"email":"pilot@example.com"}`,
wantReason: PublicMalformedRequestReasonMultipleJSONObjects,
wantRecords: 1,
},
{
name: "validation error does not count as malformed",
body: `{"email":"not-an-email"}`,
wantRecords: 0,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
observer := &recordingPublicRequestObserver{}
authService := &recordingAuthServiceClient{}
handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{
AuthService: authService,
Observer: observer,
})
req := sendEmailCodeRequest(tt.body)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
assert.Equal(t, tt.wantRecords, len(observer.snapshot()))
assert.Equal(t, 0, authService.sendEmailCodeCalls)
if tt.wantRecords == 1 {
assert.Equal(t, malformedObservation{
class: PublicRouteClassPublicAuth,
reason: tt.wantReason,
}, observer.snapshot()[0])
}
})
}
}
func TestInMemoryPublicRequestLimiterCleansExpiredBuckets(t *testing.T) {
t.Parallel()
now := time.Unix(1000, 0)
limiter := newInMemoryPublicRequestLimiter()
limiter.now = func() time.Time {
return now
}
limiter.cleanupInterval = time.Second
policy := config.PublicRateLimitConfig{
Requests: 1,
Window: time.Minute,
Burst: 1,
}
firstDecision := limiter.Reserve("bucket-1", policy)
secondDecision := limiter.Reserve("bucket-2", policy)
require.True(t, firstDecision.Allowed)
require.True(t, secondDecision.Allowed)
require.Len(t, limiter.entries, 2)
now = now.Add(3 * time.Minute)
thirdDecision := limiter.Reserve("bucket-3", policy)
require.True(t, thirdDecision.Allowed)
assert.Len(t, limiter.entries, 1)
_, exists := limiter.entries["bucket-3"]
assert.True(t, exists)
}
func sendEmailCodeRequest(body string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/auth/send-email-code", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.0.2.10:1234"
return req
}
func confirmEmailCodeRequest(body string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/auth/confirm-email-code", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.0.2.10:1234"
return req
}
type malformedObservation struct {
class PublicRouteClass
reason PublicMalformedRequestReason
}
type recordingPublicRequestObserver struct {
mu sync.Mutex
observations []malformedObservation
}
func (o *recordingPublicRequestObserver) RecordMalformedRequest(class PublicRouteClass, reason PublicMalformedRequestReason) {
o.mu.Lock()
defer o.mu.Unlock()
o.observations = append(o.observations, malformedObservation{
class: class,
reason: reason,
})
}
func (o *recordingPublicRequestObserver) snapshot() []malformedObservation {
o.mu.Lock()
defer o.mu.Unlock()
return append([]malformedObservation(nil), o.observations...)
}
+446
View File
@@ -0,0 +1,446 @@
package restapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/mail"
"strings"
"time"
"github.com/gin-gonic/gin"
)
var errPublicAuthIdentityNotApplicable = errors.New("public auth identity does not apply to this route")
type malformedJSONRequestError struct {
message string
reason PublicMalformedRequestReason
}
func (e *malformedJSONRequestError) Error() string {
if e == nil {
return ""
}
return e.message
}
type publicAuthIdentity struct {
kind string
value string
}
// AuthServiceClient defines the consumer-side contract used by public auth
// REST handlers to delegate unauthenticated authentication commands to the
// Auth / Session Service.
type AuthServiceClient interface {
// SendEmailCode starts a login challenge for input.Email and returns the
// challenge identifier that the client must later confirm.
SendEmailCode(ctx context.Context, input SendEmailCodeInput) (SendEmailCodeResult, error)
// ConfirmEmailCode completes a previously issued challenge, registers
// input.ClientPublicKey for the new device session, and returns the created
// device session identifier.
ConfirmEmailCode(ctx context.Context, input ConfirmEmailCodeInput) (ConfirmEmailCodeResult, error)
}
// SendEmailCodeInput describes the public REST and adapter payload used to
// request a login code for a single e-mail address.
type SendEmailCodeInput struct {
// Email is the single client e-mail address that should receive the login
// code challenge.
Email string `json:"email"`
}
// SendEmailCodeResult describes the public REST and adapter payload returned
// after the Auth / Session Service creates a login challenge.
type SendEmailCodeResult struct {
// ChallengeID identifies the issued challenge that must be confirmed by the
// client in the next public auth step.
ChallengeID string `json:"challenge_id"`
}
// ConfirmEmailCodeInput describes the public REST and adapter payload used to
// complete a previously issued login challenge.
type ConfirmEmailCodeInput struct {
// ChallengeID identifies the challenge previously returned by
// SendEmailCode.
ChallengeID string `json:"challenge_id"`
// Code is the verification code delivered to the client by the Auth /
// Session Service.
Code string `json:"code"`
// ClientPublicKey is the standard base64-encoded raw 32-byte Ed25519 public
// key that should be registered for the created device session.
ClientPublicKey string `json:"client_public_key"`
}
// ConfirmEmailCodeResult describes the public REST and adapter payload
// returned after the Auth / Session Service creates a device session.
type ConfirmEmailCodeResult struct {
// DeviceSessionID is the stable identifier of the created device session.
DeviceSessionID string `json:"device_session_id"`
}
// AuthServiceError allows an auth adapter to project a stable public REST
// error without teaching the gateway transport layer about upstream business
// rules.
type AuthServiceError struct {
// StatusCode is the HTTP status that the public REST handler should expose.
StatusCode int
// Code is the stable edge-level error code written into the JSON envelope.
Code string
// Message is the human-readable client-safe error description.
Message string
}
// Error returns a readable representation of the projected auth service error.
func (e *AuthServiceError) Error() string {
if e == nil {
return ""
}
switch {
case strings.TrimSpace(e.Code) == "" && strings.TrimSpace(e.Message) == "":
return http.StatusText(e.normalizedStatusCode())
case strings.TrimSpace(e.Code) == "":
return e.Message
case strings.TrimSpace(e.Message) == "":
return e.Code
default:
return e.Code + ": " + e.Message
}
}
func (e *AuthServiceError) normalizedStatusCode() int {
if e == nil || e.StatusCode < 400 || e.StatusCode > 599 {
return http.StatusInternalServerError
}
return e.StatusCode
}
func (e *AuthServiceError) normalizedCode() string {
if e == nil {
return errorCodeInternalError
}
code := strings.TrimSpace(e.Code)
if code == "" {
switch e.normalizedStatusCode() {
case http.StatusServiceUnavailable:
return errorCodeServiceUnavailable
case http.StatusBadRequest:
return errorCodeInvalidRequest
default:
return errorCodeInternalError
}
}
return code
}
func (e *AuthServiceError) normalizedMessage() string {
if e == nil {
return "internal server error"
}
message := strings.TrimSpace(e.Message)
if message == "" {
switch e.normalizedStatusCode() {
case http.StatusServiceUnavailable:
return "auth service is unavailable"
case http.StatusBadRequest:
return "request is invalid"
default:
return "internal server error"
}
}
return message
}
// unavailableAuthServiceClient keeps the public auth surface mounted until a
// concrete upstream adapter is wired into the gateway process.
type unavailableAuthServiceClient struct{}
func (unavailableAuthServiceClient) SendEmailCode(context.Context, SendEmailCodeInput) (SendEmailCodeResult, error) {
return SendEmailCodeResult{}, &AuthServiceError{
StatusCode: http.StatusServiceUnavailable,
Code: errorCodeServiceUnavailable,
Message: "auth service is unavailable",
}
}
func (unavailableAuthServiceClient) ConfirmEmailCode(context.Context, ConfirmEmailCodeInput) (ConfirmEmailCodeResult, error) {
return ConfirmEmailCodeResult{}, &AuthServiceError{
StatusCode: http.StatusServiceUnavailable,
Code: errorCodeServiceUnavailable,
Message: "auth service is unavailable",
}
}
func handleSendEmailCode(authService AuthServiceClient, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var input SendEmailCodeInput
if err := decodeJSONRequest(c.Request, &input); err != nil {
abortInvalidRequest(c, err.Error())
return
}
if err := validateSendEmailCodeInput(&input); err != nil {
abortInvalidRequest(c, err.Error())
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := authService.SendEmailCode(callCtx, input)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
abortWithError(c, http.StatusServiceUnavailable, errorCodeServiceUnavailable, "auth service is unavailable")
return
}
abortWithAuthServiceError(c, err)
return
}
if err := validateSendEmailCodeResult(&result); err != nil {
abortWithError(c, http.StatusInternalServerError, errorCodeInternalError, "internal server error")
return
}
c.JSON(http.StatusOK, result)
}
}
func handleConfirmEmailCode(authService AuthServiceClient, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var input ConfirmEmailCodeInput
if err := decodeJSONRequest(c.Request, &input); err != nil {
abortInvalidRequest(c, err.Error())
return
}
if err := validateConfirmEmailCodeInput(&input); err != nil {
abortInvalidRequest(c, err.Error())
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := authService.ConfirmEmailCode(callCtx, input)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
abortWithError(c, http.StatusServiceUnavailable, errorCodeServiceUnavailable, "auth service is unavailable")
return
}
abortWithAuthServiceError(c, err)
return
}
if err := validateConfirmEmailCodeResult(&result); err != nil {
abortWithError(c, http.StatusInternalServerError, errorCodeInternalError, "internal server error")
return
}
c.JSON(http.StatusOK, result)
}
}
func abortInvalidRequest(c *gin.Context, message string) {
abortWithError(c, http.StatusBadRequest, errorCodeInvalidRequest, message)
}
func abortWithAuthServiceError(c *gin.Context, err error) {
var authErr *AuthServiceError
if errors.As(err, &authErr) {
abortWithError(c, authErr.normalizedStatusCode(), authErr.normalizedCode(), authErr.normalizedMessage())
return
}
abortWithError(c, http.StatusInternalServerError, errorCodeInternalError, "internal server error")
}
func decodeJSONRequest(r *http.Request, target any) error {
if r == nil || r.Body == nil {
return &malformedJSONRequestError{
message: "request body must not be empty",
reason: PublicMalformedRequestReasonEmptyBody,
}
}
return decodeJSONReader(r.Body, target)
}
func decodeJSONBytes(bodyBytes []byte, target any) error {
return decodeJSONReader(bytes.NewReader(bodyBytes), target)
}
func decodeJSONReader(reader io.Reader, target any) error {
decoder := json.NewDecoder(reader)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return describeJSONDecodeError(err)
}
if err := decoder.Decode(&struct{}{}); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return &malformedJSONRequestError{
message: "request body must contain a single JSON object",
reason: PublicMalformedRequestReasonMultipleJSONObjects,
}
}
return &malformedJSONRequestError{
message: "request body must contain a single JSON object",
reason: PublicMalformedRequestReasonMultipleJSONObjects,
}
}
func describeJSONDecodeError(err error) error {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
switch {
case errors.Is(err, io.EOF):
return &malformedJSONRequestError{
message: "request body must not be empty",
reason: PublicMalformedRequestReasonEmptyBody,
}
case errors.As(err, &syntaxErr):
return &malformedJSONRequestError{
message: "request body contains malformed JSON",
reason: PublicMalformedRequestReasonMalformedJSON,
}
case errors.Is(err, io.ErrUnexpectedEOF):
return &malformedJSONRequestError{
message: "request body contains malformed JSON",
reason: PublicMalformedRequestReasonMalformedJSON,
}
case errors.As(err, &typeErr):
if strings.TrimSpace(typeErr.Field) != "" {
return &malformedJSONRequestError{
message: fmt.Sprintf("request body contains an invalid value for %q", typeErr.Field),
reason: PublicMalformedRequestReasonInvalidJSONValue,
}
}
return &malformedJSONRequestError{
message: "request body contains an invalid JSON value",
reason: PublicMalformedRequestReasonInvalidJSONValue,
}
case strings.HasPrefix(err.Error(), "json: unknown field "):
return &malformedJSONRequestError{
message: fmt.Sprintf("request body contains unknown field %s", strings.TrimPrefix(err.Error(), "json: unknown field ")),
reason: PublicMalformedRequestReasonUnknownField,
}
default:
return &malformedJSONRequestError{
message: "request body contains invalid JSON",
reason: PublicMalformedRequestReasonMalformedJSON,
}
}
}
func validateSendEmailCodeInput(input *SendEmailCodeInput) error {
input.Email = strings.TrimSpace(input.Email)
if input.Email == "" {
return errors.New("email must not be empty")
}
parsedAddress, err := mail.ParseAddress(input.Email)
if err != nil || parsedAddress.Name != "" || parsedAddress.Address != input.Email {
return errors.New("email must be a single valid email address")
}
return nil
}
func validateSendEmailCodeResult(result *SendEmailCodeResult) error {
result.ChallengeID = strings.TrimSpace(result.ChallengeID)
if result.ChallengeID == "" {
return errors.New("auth service returned an empty challenge_id")
}
return nil
}
func validateConfirmEmailCodeInput(input *ConfirmEmailCodeInput) error {
input.ChallengeID = strings.TrimSpace(input.ChallengeID)
if input.ChallengeID == "" {
return errors.New("challenge_id must not be empty")
}
input.Code = strings.TrimSpace(input.Code)
if input.Code == "" {
return errors.New("code must not be empty")
}
input.ClientPublicKey = strings.TrimSpace(input.ClientPublicKey)
if input.ClientPublicKey == "" {
return errors.New("client_public_key must not be empty")
}
return nil
}
func validateConfirmEmailCodeResult(result *ConfirmEmailCodeResult) error {
result.DeviceSessionID = strings.TrimSpace(result.DeviceSessionID)
if result.DeviceSessionID == "" {
return errors.New("auth service returned an empty device_session_id")
}
return nil
}
func malformedRequestReasonFromError(err error) (PublicMalformedRequestReason, bool) {
var malformedErr *malformedJSONRequestError
if !errors.As(err, &malformedErr) {
return "", false
}
return malformedErr.reason, true
}
func extractPublicAuthIdentity(requestPath string, bodyBytes []byte) (publicAuthIdentity, error) {
switch requestPath {
case "/api/v1/public/auth/send-email-code":
var input SendEmailCodeInput
if err := decodeJSONBytes(bodyBytes, &input); err != nil {
return publicAuthIdentity{}, err
}
if err := validateSendEmailCodeInput(&input); err != nil {
return publicAuthIdentity{}, err
}
return publicAuthIdentity{
kind: "email",
value: input.Email,
}, nil
case "/api/v1/public/auth/confirm-email-code":
var input ConfirmEmailCodeInput
if err := decodeJSONBytes(bodyBytes, &input); err != nil {
return publicAuthIdentity{}, err
}
if err := validateConfirmEmailCodeInput(&input); err != nil {
return publicAuthIdentity{}, err
}
return publicAuthIdentity{
kind: "challenge",
value: input.ChallengeID,
}, nil
default:
return publicAuthIdentity{}, errPublicAuthIdentityNotApplicable
}
}
@@ -0,0 +1,377 @@
package restapi
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"galaxy/gateway/internal/config"
"galaxy/gateway/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSendEmailCodeHandlerSuccess(t *testing.T) {
t.Parallel()
authService := &recordingAuthServiceClient{
sendEmailCodeResult: SendEmailCodeResult{
ChallengeID: "challenge-123",
},
}
handler := newPublicHandler(ServerDependencies{AuthService: authService})
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/public/auth/send-email-code",
strings.NewReader(`{"email":" pilot@example.com "}`),
)
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
assert.Equal(t, `{"challenge_id":"challenge-123"}`, recorder.Body.String())
assert.Equal(t, 1, authService.sendEmailCodeCalls)
assert.Equal(t, 0, authService.confirmEmailCodeCalls)
assert.Equal(t, SendEmailCodeInput{Email: "pilot@example.com"}, authService.sendEmailCodeInput)
assert.True(t, authService.sendEmailCodeRouteClassOK)
assert.Equal(t, PublicRouteClassPublicAuth, authService.sendEmailCodeRouteClass)
}
func TestConfirmEmailCodeHandlerSuccess(t *testing.T) {
t.Parallel()
authService := &recordingAuthServiceClient{
confirmEmailCodeResult: ConfirmEmailCodeResult{
DeviceSessionID: "device-session-123",
},
}
handler := newPublicHandler(ServerDependencies{AuthService: authService})
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/public/auth/confirm-email-code",
strings.NewReader(`{"challenge_id":" challenge-123 ","code":" 123456 ","client_public_key":" public-key-material "}`),
)
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
assert.Equal(t, `{"device_session_id":"device-session-123"}`, recorder.Body.String())
assert.Equal(t, 0, authService.sendEmailCodeCalls)
assert.Equal(t, 1, authService.confirmEmailCodeCalls)
assert.Equal(t, ConfirmEmailCodeInput{
ChallengeID: "challenge-123",
Code: "123456",
ClientPublicKey: "public-key-material",
}, authService.confirmEmailCodeInput)
assert.True(t, authService.confirmEmailCodeRouteClassOK)
assert.Equal(t, PublicRouteClassPublicAuth, authService.confirmEmailCodeRouteClass)
}
func TestPublicAuthHandlersRejectInvalidRequests(t *testing.T) {
t.Parallel()
tests := []struct {
name string
target string
body string
wantStatus int
wantBody string
wantSendCalls int
wantConfirmCalls int
}{
{
name: "send email malformed json",
target: "/api/v1/public/auth/send-email-code",
body: `{"email":`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body contains malformed JSON"}}`,
wantSendCalls: 0,
wantConfirmCalls: 0,
},
{
name: "send email validation error",
target: "/api/v1/public/auth/send-email-code",
body: `{"email":"not-an-email"}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"email must be a single valid email address"}}`,
wantSendCalls: 0,
wantConfirmCalls: 0,
},
{
name: "confirm email empty code",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":" ","client_public_key":"public-key-material"}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"code must not be empty"}}`,
wantSendCalls: 0,
wantConfirmCalls: 0,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
authService := &recordingAuthServiceClient{}
handler := newPublicHandler(ServerDependencies{AuthService: authService})
req := httptest.NewRequest(http.MethodPost, tt.target, strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, tt.wantStatus, recorder.Code)
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
assert.Equal(t, tt.wantBody, recorder.Body.String())
assert.Equal(t, tt.wantSendCalls, authService.sendEmailCodeCalls)
assert.Equal(t, tt.wantConfirmCalls, authService.confirmEmailCodeCalls)
})
}
}
func TestPublicAuthHandlersMapAdapterErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
target string
body string
authClient *recordingAuthServiceClient
wantStatus int
wantBody string
}{
{
name: "auth service projected bad request",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
authClient: &recordingAuthServiceClient{
confirmEmailCodeErr: &AuthServiceError{
StatusCode: http.StatusBadRequest,
Code: errorCodeInvalidRequest,
Message: "confirmation code is invalid",
},
},
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"confirmation code is invalid"}}`,
},
{
name: "auth service projected custom too many requests",
target: "/api/v1/public/auth/send-email-code",
body: `{"email":"pilot@example.com"}`,
authClient: &recordingAuthServiceClient{
sendEmailCodeErr: &AuthServiceError{
StatusCode: http.StatusTooManyRequests,
Code: "upstream_rate_limited",
Message: "too many attempts for this email",
},
},
wantStatus: http.StatusTooManyRequests,
wantBody: `{"error":{"code":"upstream_rate_limited","message":"too many attempts for this email"}}`,
},
{
name: "auth service projected gateway normalizes blank gateway error fields",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
authClient: &recordingAuthServiceClient{
confirmEmailCodeErr: &AuthServiceError{
StatusCode: http.StatusBadGateway,
},
},
wantStatus: http.StatusBadGateway,
wantBody: `{"error":{"code":"internal_error","message":"internal server error"}}`,
},
{
name: "unexpected auth service error",
target: "/api/v1/public/auth/send-email-code",
body: `{"email":"pilot@example.com"}`,
authClient: &recordingAuthServiceClient{
sendEmailCodeErr: errors.New("boom"),
},
wantStatus: http.StatusInternalServerError,
wantBody: `{"error":{"code":"internal_error","message":"internal server error"}}`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
handler := newPublicHandler(ServerDependencies{AuthService: tt.authClient})
req := httptest.NewRequest(http.MethodPost, tt.target, strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, tt.wantStatus, recorder.Code)
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
assert.Equal(t, tt.wantBody, recorder.Body.String())
})
}
}
func TestDefaultAuthServiceReturnsServiceUnavailable(t *testing.T) {
t.Parallel()
handler := newPublicHandler(ServerDependencies{})
tests := []struct {
name string
method string
target string
body string
wantStatus int
wantBody string
}{
{
name: "send email code",
method: http.MethodPost,
target: "/api/v1/public/auth/send-email-code",
body: `{"email":"pilot@example.com"}`,
wantStatus: http.StatusServiceUnavailable,
wantBody: `{"error":{"code":"service_unavailable","message":"auth service is unavailable"}}`,
},
{
name: "confirm email code",
method: http.MethodPost,
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
wantStatus: http.StatusServiceUnavailable,
wantBody: `{"error":{"code":"service_unavailable","message":"auth service is unavailable"}}`,
},
{
name: "healthz remains available",
method: http.MethodGet,
target: "/healthz",
wantStatus: http.StatusOK,
wantBody: `{"status":"ok"}`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(tt.method, tt.target, strings.NewReader(tt.body))
if tt.body != "" {
req.Header.Set("Content-Type", "application/json")
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, tt.wantStatus, recorder.Code)
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
assert.Equal(t, tt.wantBody, recorder.Body.String())
})
}
}
func TestPublicAuthHandlerTimeoutMapsToServiceUnavailable(t *testing.T) {
t.Parallel()
authService := &recordingAuthServiceClient{
sendEmailCodeErr: context.DeadlineExceeded,
}
cfg := config.DefaultPublicHTTPConfig()
cfg.AuthUpstreamTimeout = 5 * time.Millisecond
handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AuthService: authService})
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/public/auth/send-email-code",
strings.NewReader(`{"email":"pilot@example.com"}`),
)
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusServiceUnavailable, recorder.Code)
assert.Equal(t, `{"error":{"code":"service_unavailable","message":"auth service is unavailable"}}`, recorder.Body.String())
}
func TestPublicAuthLogsDoNotContainSensitiveFields(t *testing.T) {
t.Parallel()
logger, buffer := testutil.NewObservedLogger(t)
handler := newPublicHandler(ServerDependencies{
Logger: logger,
AuthService: &recordingAuthServiceClient{
confirmEmailCodeResult: ConfirmEmailCodeResult{DeviceSessionID: "device-session-123"},
},
})
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/public/auth/confirm-email-code",
strings.NewReader(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`),
)
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
require.Equal(t, http.StatusOK, recorder.Code)
logOutput := buffer.String()
assert.NotContains(t, logOutput, "challenge-123")
assert.NotContains(t, logOutput, "123456")
assert.NotContains(t, logOutput, "public-key-material")
assert.NotContains(t, logOutput, "pilot@example.com")
}
// recordingAuthServiceClient captures handler inputs and route classification
// so tests can assert the exact adapter delegation contract.
type recordingAuthServiceClient struct {
sendEmailCodeResult SendEmailCodeResult
sendEmailCodeErr error
sendEmailCodeInput SendEmailCodeInput
sendEmailCodeRouteClass PublicRouteClass
sendEmailCodeRouteClassOK bool
sendEmailCodeCalls int
confirmEmailCodeResult ConfirmEmailCodeResult
confirmEmailCodeErr error
confirmEmailCodeInput ConfirmEmailCodeInput
confirmEmailCodeRouteClass PublicRouteClass
confirmEmailCodeRouteClassOK bool
confirmEmailCodeCalls int
}
func (c *recordingAuthServiceClient) SendEmailCode(ctx context.Context, input SendEmailCodeInput) (SendEmailCodeResult, error) {
c.sendEmailCodeCalls++
c.sendEmailCodeInput = input
c.sendEmailCodeRouteClass, c.sendEmailCodeRouteClassOK = PublicRouteClassFromContext(ctx)
return c.sendEmailCodeResult, c.sendEmailCodeErr
}
func (c *recordingAuthServiceClient) ConfirmEmailCode(ctx context.Context, input ConfirmEmailCodeInput) (ConfirmEmailCodeResult, error) {
c.confirmEmailCodeCalls++
c.confirmEmailCodeInput = input
c.confirmEmailCodeRouteClass, c.confirmEmailCodeRouteClassOK = PublicRouteClassFromContext(ctx)
return c.confirmEmailCodeResult, c.confirmEmailCodeErr
}
+388
View File
@@ -0,0 +1,388 @@
// Package restapi exposes the unauthenticated public REST surface of the
// gateway.
package restapi
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"sync"
"galaxy/gateway/internal/config"
"galaxy/gateway/internal/telemetry"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"go.uber.org/zap"
)
const (
jsonContentType = "application/json; charset=utf-8"
errorCodeInvalidRequest = "invalid_request"
errorCodeNotFound = "not_found"
errorCodeMethodNotAllowed = "method_not_allowed"
errorCodeInternalError = "internal_error"
errorCodeServiceUnavailable = "service_unavailable"
publicRESTBaseBucketKeyPrefix = "public_rest/class="
)
// PublicRouteClass identifies the public traffic class assigned to an incoming
// REST request before route handling and edge policy evaluation.
type PublicRouteClass string
const (
// PublicRouteClassPublicAuth identifies public authentication commands.
PublicRouteClassPublicAuth PublicRouteClass = "public_auth"
// PublicRouteClassBrowserBootstrap identifies browser bootstrap traffic such
// as the main document request.
PublicRouteClassBrowserBootstrap PublicRouteClass = "browser_bootstrap"
// PublicRouteClassBrowserAsset identifies browser asset requests.
PublicRouteClassBrowserAsset PublicRouteClass = "browser_asset"
// PublicRouteClassPublicMisc identifies public traffic that does not match a
// more specific class.
PublicRouteClassPublicMisc PublicRouteClass = "public_misc"
)
var configureGinModeOnce sync.Once
// Normalized returns c when it belongs to the stable public route class set.
// Unknown or empty values collapse to PublicRouteClassPublicMisc so edge policy
// code can rely on a fixed anti-abuse namespace.
func (c PublicRouteClass) Normalized() PublicRouteClass {
switch c {
case PublicRouteClassPublicAuth,
PublicRouteClassBrowserBootstrap,
PublicRouteClassBrowserAsset,
PublicRouteClassPublicMisc:
return c
default:
return PublicRouteClassPublicMisc
}
}
// BaseBucketKey returns the canonical base rate-limit namespace for c. The key
// stays scoped only by the normalized public route class; callers may append
// subject dimensions such as IP or identity without redefining the class
// namespace.
func (c PublicRouteClass) BaseBucketKey() string {
return publicRESTBaseBucketKeyPrefix + string(c.Normalized())
}
// PublicTrafficClassifier maps public REST requests to the public anti-abuse
// class used by the gateway edge. The server normalizes classifier outputs to
// the stable class set before storing them in request context.
type PublicTrafficClassifier interface {
Classify(*http.Request) PublicRouteClass
}
// ServerDependencies describes the optional collaborators used by the public
// REST server. The zero value is valid and keeps the process runnable with the
// built-in defaults.
type ServerDependencies struct {
// Classifier assigns the public anti-abuse class before route handling.
// When nil, the gateway default classifier is used.
Classifier PublicTrafficClassifier
// AuthService delegates public auth commands to the Auth / Session Service.
// When nil, public auth routes remain mounted and return a stable
// service-unavailable response.
AuthService AuthServiceClient
// Limiter applies the public REST rate-limit policy. When nil, a default
// process-local in-memory limiter is used.
Limiter PublicRequestLimiter
// Observer records malformed-request telemetry for the public REST layer.
// When nil, a no-op observer is used.
Observer PublicRequestObserver
// Logger writes structured transport logs for public REST traffic. When nil,
// a no-op logger is used.
Logger *zap.Logger
// Telemetry records low-cardinality edge metrics. When nil, metrics are
// disabled.
Telemetry *telemetry.Runtime
}
// Server owns the public unauthenticated REST listener exposed by the gateway.
type Server struct {
cfg config.PublicHTTPConfig
handler http.Handler
logger *zap.Logger
stateMu sync.RWMutex
server *http.Server
listener net.Listener
}
// NewServer constructs a public REST server for the supplied listener
// configuration and dependency bundle. Nil dependencies are replaced with safe
// defaults so the gateway can still expose the documented public surface.
func NewServer(cfg config.PublicHTTPConfig, deps ServerDependencies) *Server {
deps = normalizeServerDependencies(deps)
return &Server{
cfg: cfg,
handler: newPublicHandlerWithConfig(cfg, deps),
logger: deps.Logger.Named("public_http"),
}
}
// Run binds the configured listener and serves the public REST surface until
// Shutdown closes the server.
func (s *Server) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run public REST server: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
listener, err := net.Listen("tcp", s.cfg.Addr)
if err != nil {
return fmt.Errorf("run public REST server: listen on %q: %w", s.cfg.Addr, err)
}
server := &http.Server{
Handler: s.handler,
ReadHeaderTimeout: s.cfg.ReadHeaderTimeout,
ReadTimeout: s.cfg.ReadTimeout,
IdleTimeout: s.cfg.IdleTimeout,
}
s.stateMu.Lock()
s.server = server
s.listener = listener
s.stateMu.Unlock()
s.logger.Info("public REST server started", zap.String("addr", listener.Addr().String()))
defer func() {
s.stateMu.Lock()
s.server = nil
s.listener = nil
s.stateMu.Unlock()
}()
err = server.Serve(listener)
switch {
case err == nil:
return nil
case errors.Is(err, http.ErrServerClosed):
s.logger.Info("public REST server stopped")
return nil
default:
return fmt.Errorf("run public REST server: serve on %q: %w", s.cfg.Addr, err)
}
}
// Shutdown gracefully stops the public REST server within ctx.
func (s *Server) Shutdown(ctx context.Context) error {
if ctx == nil {
return errors.New("shutdown public REST server: nil context")
}
s.stateMu.RLock()
server := s.server
s.stateMu.RUnlock()
if server == nil {
return nil
}
if err := server.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("shutdown public REST server: %w", err)
}
return nil
}
// PublicRouteClassFromContext returns the previously classified normalized
// public route class stored in ctx.
func PublicRouteClassFromContext(ctx context.Context) (PublicRouteClass, bool) {
if ctx == nil {
return "", false
}
class, ok := ctx.Value(publicRouteClassContextKey{}).(PublicRouteClass)
if !ok {
return "", false
}
return class.Normalized(), true
}
type publicRouteClassContextKey struct{}
type defaultPublicTrafficClassifier struct{}
// Classify maps the incoming request into a stable public route class that can
// later drive anti-abuse policy and rate limiting.
func (defaultPublicTrafficClassifier) Classify(r *http.Request) PublicRouteClass {
switch {
case isPublicAuthRequest(r):
return PublicRouteClassPublicAuth
case isBrowserBootstrapRequest(r):
return PublicRouteClassBrowserBootstrap
case isBrowserAssetRequest(r):
return PublicRouteClassBrowserAsset
default:
return PublicRouteClassPublicMisc
}
}
func normalizeServerDependencies(deps ServerDependencies) ServerDependencies {
if deps.Classifier == nil {
deps.Classifier = defaultPublicTrafficClassifier{}
}
if deps.AuthService == nil {
deps.AuthService = unavailableAuthServiceClient{}
}
if deps.Limiter == nil {
deps.Limiter = newInMemoryPublicRequestLimiter()
}
if deps.Observer == nil {
deps.Observer = noopPublicRequestObserver{}
}
if deps.Logger == nil {
deps.Logger = zap.NewNop()
}
return deps
}
func newPublicHandler(deps ServerDependencies) http.Handler {
return newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), deps)
}
func newPublicHandlerWithConfig(cfg config.PublicHTTPConfig, deps ServerDependencies) http.Handler {
configureGinModeOnce.Do(func() {
gin.SetMode(gin.ReleaseMode)
})
deps = normalizeServerDependencies(deps)
router := gin.New()
router.HandleMethodNotAllowed = true
router.Use(gin.CustomRecovery(func(c *gin.Context, _ any) {
abortWithError(c, http.StatusInternalServerError, errorCodeInternalError, "internal server error")
}))
router.Use(otelgin.Middleware("galaxy-edge-gateway-public"))
router.Use(withPublicObservability(deps.Logger.Named("public_http"), deps.Telemetry))
router.Use(withPublicRouteClass(deps.Classifier))
router.Use(withPublicAntiAbuse(cfg.AntiAbuse, deps.Limiter, deps.Observer))
router.GET("/healthz", handleHealthz)
router.GET("/readyz", handleReadyz)
router.POST("/api/v1/public/auth/send-email-code", handleSendEmailCode(deps.AuthService, cfg.AuthUpstreamTimeout))
router.POST("/api/v1/public/auth/confirm-email-code", handleConfirmEmailCode(deps.AuthService, cfg.AuthUpstreamTimeout))
router.NoMethod(func(c *gin.Context) {
allowMethods := allowedMethodsForPath(c.Request.URL.Path)
if allowMethods != "" {
c.Header("Allow", allowMethods)
}
abortWithError(c, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed, "request method is not allowed for this route")
})
router.NoRoute(func(c *gin.Context) {
abortWithError(c, http.StatusNotFound, errorCodeNotFound, "resource was not found")
})
return router
}
func handleHealthz(c *gin.Context) {
c.JSON(http.StatusOK, statusResponse{Status: "ok"})
}
func handleReadyz(c *gin.Context) {
c.JSON(http.StatusOK, statusResponse{Status: "ready"})
}
func withPublicRouteClass(classifier PublicTrafficClassifier) gin.HandlerFunc {
return func(c *gin.Context) {
class := classifier.Classify(c.Request).Normalized()
ctx := context.WithValue(c.Request.Context(), publicRouteClassContextKey{}, class)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
func isPublicAuthRequest(r *http.Request) bool {
return r.Method == http.MethodPost && isPublicAuthPath(r.URL.Path)
}
func isBrowserBootstrapRequest(r *http.Request) bool {
if r.Method == http.MethodGet && r.URL.Path == "/" {
return true
}
return matchesBrowserBootstrapRequestShape(r)
}
func isBrowserAssetRequest(r *http.Request) bool {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
return false
}
return matchesBrowserAssetRequestShape(r)
}
type statusResponse struct {
Status string `json:"status"`
}
type errorResponse struct {
Error errorBody `json:"error"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
func abortWithError(c *gin.Context, statusCode int, code string, message string) {
if c != nil {
c.Set(publicErrorCodeContextKey, code)
}
c.AbortWithStatusJSON(statusCode, errorResponse{
Error: errorBody{
Code: code,
Message: message,
},
})
}
const publicErrorCodeContextKey = "public_error_code"
func allowedMethodsForPath(requestPath string) string {
switch requestPath {
case "/healthz", "/readyz":
return http.MethodGet
case "/api/v1/public/auth/send-email-code", "/api/v1/public/auth/confirm-email-code":
return http.MethodPost
default:
return ""
}
}
func (s *Server) listenAddr() string {
s.stateMu.RLock()
defer s.stateMu.RUnlock()
if s.listener == nil {
return ""
}
return s.listener.Addr().String()
}
+459
View File
@@ -0,0 +1,459 @@
package restapi
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"galaxy/gateway/internal/app"
"galaxy/gateway/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPublicHandlerHealthEndpoints(t *testing.T) {
t.Parallel()
handler := newPublicHandler(ServerDependencies{})
tests := []struct {
name string
method string
target string
wantStatus int
wantType string
wantBody string
wantAllow string
}{
{
name: "healthz",
method: http.MethodGet,
target: "/healthz",
wantStatus: http.StatusOK,
wantType: jsonContentType,
wantBody: `{"status":"ok"}`,
},
{
name: "readyz",
method: http.MethodGet,
target: "/readyz",
wantStatus: http.StatusOK,
wantType: jsonContentType,
wantBody: `{"status":"ready"}`,
},
{
name: "wrong method on known route",
method: http.MethodPost,
target: "/healthz",
wantStatus: http.StatusMethodNotAllowed,
wantType: jsonContentType,
wantBody: `{"error":{"code":"method_not_allowed","message":"request method is not allowed for this route"}}`,
wantAllow: http.MethodGet,
},
{
name: "unknown route",
method: http.MethodGet,
target: "/unknown",
wantStatus: http.StatusNotFound,
wantType: jsonContentType,
wantBody: `{"error":{"code":"not_found","message":"resource was not found"}}`,
},
{
name: "wrong method on public auth route",
method: http.MethodGet,
target: "/api/v1/public/auth/send-email-code",
wantStatus: http.StatusMethodNotAllowed,
wantType: jsonContentType,
wantBody: `{"error":{"code":"method_not_allowed","message":"request method is not allowed for this route"}}`,
wantAllow: http.MethodPost,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(tt.method, tt.target, nil)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, tt.wantStatus, recorder.Code)
assert.Equal(t, tt.wantType, recorder.Header().Get("Content-Type"))
assert.Equal(t, tt.wantBody, recorder.Body.String())
assert.Equal(t, tt.wantAllow, recorder.Header().Get("Allow"))
})
}
}
func TestDefaultPublicTrafficClassifier(t *testing.T) {
t.Parallel()
classifier := defaultPublicTrafficClassifier{}
tests := []struct {
name string
method string
target string
accept string
wantClass PublicRouteClass
}{
{
name: "public auth route",
method: http.MethodPost,
target: "/api/v1/public/auth/send-email-code",
wantClass: PublicRouteClassPublicAuth,
},
{
name: "public auth confirm route",
method: http.MethodPost,
target: "/api/v1/public/auth/confirm-email-code",
wantClass: PublicRouteClassPublicAuth,
},
{
name: "browser bootstrap route",
method: http.MethodGet,
target: "/",
wantClass: PublicRouteClassBrowserBootstrap,
},
{
name: "browser asset route",
method: http.MethodGet,
target: "/assets/app.js",
wantClass: PublicRouteClassBrowserAsset,
},
{
name: "browser asset head request",
method: http.MethodHead,
target: "/assets/app.js",
wantClass: PublicRouteClassBrowserAsset,
},
{
name: "browser asset extension request",
method: http.MethodGet,
target: "/manifest.webmanifest",
wantClass: PublicRouteClassBrowserAsset,
},
{
name: "public misc route",
method: http.MethodPost,
target: "/api/v1/public/unknown",
wantClass: PublicRouteClassPublicMisc,
},
{
name: "html accept bootstrap route",
method: http.MethodGet,
target: "/app",
accept: "application/json, text/html;q=0.9",
wantClass: PublicRouteClassBrowserBootstrap,
},
{
name: "public auth wins over browser accept header",
method: http.MethodPost,
target: "/api/v1/public/auth/confirm-email-code",
accept: "text/html",
wantClass: PublicRouteClassPublicAuth,
},
{
name: "probe with html accept is bootstrap",
method: http.MethodGet,
target: "/healthz",
accept: "text/html",
wantClass: PublicRouteClassBrowserBootstrap,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(tt.method, tt.target, nil)
if tt.accept != "" {
req.Header.Set("Accept", tt.accept)
}
assert.Equal(t, tt.wantClass, classifier.Classify(req))
})
}
}
func TestPublicRouteClassNormalized(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input PublicRouteClass
want PublicRouteClass
}{
{
name: "public auth",
input: PublicRouteClassPublicAuth,
want: PublicRouteClassPublicAuth,
},
{
name: "browser bootstrap",
input: PublicRouteClassBrowserBootstrap,
want: PublicRouteClassBrowserBootstrap,
},
{
name: "browser asset",
input: PublicRouteClassBrowserAsset,
want: PublicRouteClassBrowserAsset,
},
{
name: "public misc",
input: PublicRouteClassPublicMisc,
want: PublicRouteClassPublicMisc,
},
{
name: "unknown collapses to misc",
input: PublicRouteClass("unexpected"),
want: PublicRouteClassPublicMisc,
},
{
name: "empty collapses to misc",
input: PublicRouteClass(""),
want: PublicRouteClassPublicMisc,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, tt.input.Normalized())
})
}
}
func TestPublicRouteClassBaseBucketKeyIsolation(t *testing.T) {
t.Parallel()
tests := []struct {
name string
class PublicRouteClass
wantKey string
}{
{
name: "public auth",
class: PublicRouteClassPublicAuth,
wantKey: "public_rest/class=public_auth",
},
{
name: "browser bootstrap",
class: PublicRouteClassBrowserBootstrap,
wantKey: "public_rest/class=browser_bootstrap",
},
{
name: "browser asset",
class: PublicRouteClassBrowserAsset,
wantKey: "public_rest/class=browser_asset",
},
{
name: "public misc",
class: PublicRouteClassPublicMisc,
wantKey: "public_rest/class=public_misc",
},
{
name: "unknown collapses to misc namespace",
class: PublicRouteClass("unexpected"),
wantKey: "public_rest/class=public_misc",
},
}
seenKeys := make(map[string]PublicRouteClass, len(tests))
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.wantKey, tt.class.BaseBucketKey())
})
normalizedClass := tt.class.Normalized()
if normalizedClass == PublicRouteClassPublicMisc && tt.class != PublicRouteClassPublicMisc {
continue
}
if previousClass, exists := seenKeys[tt.wantKey]; exists {
require.FailNowf(t, "bucket key collision", "class %q collides with %q on key %q", tt.class, previousClass, tt.wantKey)
}
seenKeys[tt.wantKey] = tt.class
}
assert.NotEqual(t, PublicRouteClassPublicAuth.BaseBucketKey(), PublicRouteClassBrowserBootstrap.BaseBucketKey())
assert.NotEqual(t, PublicRouteClassPublicAuth.BaseBucketKey(), PublicRouteClassBrowserAsset.BaseBucketKey())
}
func TestWithPublicRouteClassStoresClassInContext(t *testing.T) {
t.Parallel()
router := gin.New()
router.Use(withPublicRouteClass(staticClassifier{class: PublicRouteClassBrowserAsset}))
router.GET("/assets/app.js", func(c *gin.Context) {
class, ok := PublicRouteClassFromContext(c.Request.Context())
require.True(t, ok)
assert.Equal(t, PublicRouteClassBrowserAsset, class)
c.JSON(http.StatusOK, statusResponse{Status: "ok"})
})
req := httptest.NewRequest(http.MethodGet, "/assets/app.js", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, `{"status":"ok"}`, recorder.Body.String())
}
func TestWithPublicRouteClassNormalizesUnsupportedClassToPublicMisc(t *testing.T) {
t.Parallel()
tests := []struct {
name string
class PublicRouteClass
}{
{
name: "unknown class",
class: PublicRouteClass("unexpected"),
},
{
name: "empty class",
class: PublicRouteClass(""),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
router := gin.New()
router.Use(withPublicRouteClass(staticClassifier{class: tt.class}))
router.GET("/", func(c *gin.Context) {
class, ok := PublicRouteClassFromContext(c.Request.Context())
require.True(t, ok)
assert.Equal(t, PublicRouteClassPublicMisc, class)
c.JSON(http.StatusOK, statusResponse{Status: "ok"})
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, `{"status":"ok"}`, recorder.Body.String())
})
}
}
func TestServerLifecycle(t *testing.T) {
t.Parallel()
cfg := config.Config{
ShutdownTimeout: time.Second,
PublicHTTP: func() config.PublicHTTPConfig {
publicHTTPCfg := config.DefaultPublicHTTPConfig()
publicHTTPCfg.Addr = "127.0.0.1:0"
publicHTTPCfg.AntiAbuse.PublicMisc.RateLimit = config.PublicRateLimitConfig{
Requests: 1000,
Window: time.Minute,
Burst: 1000,
}
return publicHTTPCfg
}(),
}
server := NewServer(cfg.PublicHTTP, ServerDependencies{})
application := app.New(cfg, server)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
resultCh := make(chan error, 1)
go func() {
resultCh <- application.Run(ctx)
}()
addr := waitForListenAddr(t, server)
waitForHealthResponse(t, addr)
cancel()
select {
case err := <-resultCh:
require.NoError(t, err)
case <-time.After(2 * time.Second):
require.FailNow(t, "Run() did not return after cancellation")
}
}
type staticClassifier struct {
class PublicRouteClass
}
func (c staticClassifier) Classify(*http.Request) PublicRouteClass {
return c.class
}
func waitForListenAddr(t *testing.T, server *Server) string {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if addr := server.listenAddr(); addr != "" {
return addr
}
time.Sleep(10 * time.Millisecond)
}
require.FailNow(t, "server did not start listening")
return ""
}
func waitForHealthResponse(t *testing.T, addr string) {
t.Helper()
client := &http.Client{Timeout: 100 * time.Millisecond}
url := "http://" + addr + "/healthz"
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err != nil {
time.Sleep(10 * time.Millisecond)
continue
}
body, readErr := io.ReadAll(resp.Body)
closeErr := resp.Body.Close()
require.NoError(t, readErr)
require.NoError(t, closeErr)
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, `{"status":"ok"}`, strings.TrimSpace(string(body)))
return
}
require.FailNowf(t, "health check timed out", "url=%s", url)
}
+88
View File
@@ -0,0 +1,88 @@
package session
import (
"context"
"errors"
"fmt"
"strings"
"sync"
)
// MemoryCache stores session record snapshots in process-local memory. It is
// intended for the authenticated gateway hot path and deliberately keeps no
// TTL or size-based eviction policy.
type MemoryCache struct {
mu sync.RWMutex
records map[string]Record
}
// NewMemoryCache constructs an empty process-local session snapshot store.
func NewMemoryCache() *MemoryCache {
return &MemoryCache{
records: make(map[string]Record),
}
}
// Lookup resolves deviceSessionID from the process-local snapshot map.
func (c *MemoryCache) Lookup(ctx context.Context, deviceSessionID string) (Record, error) {
if c == nil {
return Record{}, errors.New("lookup session from in-memory cache: nil cache")
}
if ctx == nil || fmt.Sprint(ctx) == "context.TODO" {
return Record{}, errors.New("lookup session from in-memory cache: nil context")
}
if strings.TrimSpace(deviceSessionID) == "" {
return Record{}, errors.New("lookup session from in-memory cache: empty device session id")
}
c.mu.RLock()
record, ok := c.records[deviceSessionID]
c.mu.RUnlock()
if !ok {
return Record{}, fmt.Errorf("lookup session from in-memory cache: %w", ErrNotFound)
}
return cloneRecord(record), nil
}
// Upsert stores record in the process-local snapshot map after validating the
// same session invariants expected from the Redis-backed cache.
func (c *MemoryCache) Upsert(record Record) error {
if c == nil {
return errors.New("upsert session into in-memory cache: nil cache")
}
if err := validateRecord(record.DeviceSessionID, record); err != nil {
return fmt.Errorf("upsert session into in-memory cache: %w", err)
}
cloned := cloneRecord(record)
c.mu.Lock()
c.records[record.DeviceSessionID] = cloned
c.mu.Unlock()
return nil
}
// Delete removes the local snapshot for deviceSessionID when one exists.
func (c *MemoryCache) Delete(deviceSessionID string) {
if c == nil || strings.TrimSpace(deviceSessionID) == "" {
return
}
c.mu.Lock()
delete(c.records, deviceSessionID)
c.mu.Unlock()
}
func cloneRecord(record Record) Record {
cloned := record
if record.RevokedAtMS != nil {
value := *record.RevokedAtMS
cloned.RevokedAtMS = &value
}
return cloned
}
var _ SnapshotStore = (*MemoryCache)(nil)
+68
View File
@@ -0,0 +1,68 @@
package session
import (
"context"
"errors"
"fmt"
)
// ReadThroughCache resolves authenticated sessions from a process-local
// SnapshotStore first and falls back to another Cache only on a local miss.
type ReadThroughCache struct {
local SnapshotStore
fallback Cache
}
// NewReadThroughCache constructs a hot-path cache that seeds local snapshots
// from fallback on demand.
func NewReadThroughCache(local SnapshotStore, fallback Cache) (*ReadThroughCache, error) {
if local == nil {
return nil, errors.New("new read-through session cache: nil local cache")
}
if fallback == nil {
return nil, errors.New("new read-through session cache: nil fallback cache")
}
return &ReadThroughCache{
local: local,
fallback: fallback,
}, nil
}
// Lookup resolves deviceSessionID from local first, then performs one fallback
// lookup on a local miss and seeds the local cache with the returned snapshot.
func (c *ReadThroughCache) Lookup(ctx context.Context, deviceSessionID string) (Record, error) {
if c == nil {
return Record{}, errors.New("lookup session from read-through cache: nil cache")
}
record, err := c.local.Lookup(ctx, deviceSessionID)
switch {
case err == nil:
return record, nil
case !errors.Is(err, ErrNotFound):
return Record{}, fmt.Errorf("lookup session from read-through cache: %w", err)
}
record, err = c.fallback.Lookup(ctx, deviceSessionID)
if err != nil {
return Record{}, err
}
if err := c.local.Upsert(record); err != nil {
return Record{}, fmt.Errorf("lookup session from read-through cache: seed local cache: %w", err)
}
return cloneRecord(record), nil
}
// Local returns the mutable process-local snapshot store used by c.
func (c *ReadThroughCache) Local() SnapshotStore {
if c == nil {
return nil
}
return c.local
}
var _ Cache = (*ReadThroughCache)(nil)
@@ -0,0 +1,176 @@
package session
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMemoryCacheLookupReturnsClonedRecord(t *testing.T) {
t.Parallel()
cache := NewMemoryCache()
revokedAtMS := int64(123456789)
require.NoError(t, cache.Upsert(Record{
DeviceSessionID: "device-session-123",
UserID: "user-123",
ClientPublicKey: "public-key-123",
Status: StatusRevoked,
RevokedAtMS: &revokedAtMS,
}))
record, err := cache.Lookup(context.Background(), "device-session-123")
require.NoError(t, err)
require.NotNil(t, record.RevokedAtMS)
*record.RevokedAtMS = 1
stored, err := cache.Lookup(context.Background(), "device-session-123")
require.NoError(t, err)
require.NotNil(t, stored.RevokedAtMS)
assert.Equal(t, revokedAtMS, *stored.RevokedAtMS)
}
func TestReadThroughCacheLocalHitSkipsFallback(t *testing.T) {
t.Parallel()
local := NewMemoryCache()
require.NoError(t, local.Upsert(Record{
DeviceSessionID: "device-session-123",
UserID: "user-123",
ClientPublicKey: "public-key-123",
Status: StatusActive,
}))
fallback := &recordingCache{
lookupFunc: func(context.Context, string) (Record, error) {
return Record{}, errors.New("fallback should not be called")
},
}
cache, err := NewReadThroughCache(local, fallback)
require.NoError(t, err)
record, err := cache.Lookup(context.Background(), "device-session-123")
require.NoError(t, err)
assert.Equal(t, Record{
DeviceSessionID: "device-session-123",
UserID: "user-123",
ClientPublicKey: "public-key-123",
Status: StatusActive,
}, record)
assert.Equal(t, 0, fallback.lookupCalls)
}
func TestReadThroughCacheFallbackSeedsLocalCache(t *testing.T) {
t.Parallel()
local := NewMemoryCache()
fallback := &recordingCache{
lookupFunc: func(context.Context, string) (Record, error) {
return Record{
DeviceSessionID: "device-session-123",
UserID: "user-123",
ClientPublicKey: "public-key-123",
Status: StatusActive,
}, nil
},
}
cache, err := NewReadThroughCache(local, fallback)
require.NoError(t, err)
record, err := cache.Lookup(context.Background(), "device-session-123")
require.NoError(t, err)
assert.Equal(t, 1, fallback.lookupCalls)
assert.Equal(t, "user-123", record.UserID)
record, err = cache.Lookup(context.Background(), "device-session-123")
require.NoError(t, err)
assert.Equal(t, 1, fallback.lookupCalls)
assert.Equal(t, "user-123", record.UserID)
}
func TestReadThroughCacheKeepsRevokedSnapshotLocal(t *testing.T) {
t.Parallel()
revokedAtMS := int64(123456789)
local := NewMemoryCache()
fallback := &recordingCache{
lookupFunc: func(context.Context, string) (Record, error) {
return Record{
DeviceSessionID: "device-session-123",
UserID: "user-123",
ClientPublicKey: "public-key-123",
Status: StatusRevoked,
RevokedAtMS: &revokedAtMS,
}, nil
},
}
cache, err := NewReadThroughCache(local, fallback)
require.NoError(t, err)
record, err := cache.Lookup(context.Background(), "device-session-123")
require.NoError(t, err)
require.NotNil(t, record.RevokedAtMS)
assert.Equal(t, StatusRevoked, record.Status)
assert.Equal(t, 1, fallback.lookupCalls)
record, err = cache.Lookup(context.Background(), "device-session-123")
require.NoError(t, err)
require.NotNil(t, record.RevokedAtMS)
assert.Equal(t, StatusRevoked, record.Status)
assert.Equal(t, revokedAtMS, *record.RevokedAtMS)
assert.Equal(t, 1, fallback.lookupCalls)
}
func TestReadThroughCacheReturnsClonedFallbackRecord(t *testing.T) {
t.Parallel()
revokedAtMS := int64(123456789)
local := NewMemoryCache()
fallback := &recordingCache{
lookupFunc: func(context.Context, string) (Record, error) {
return Record{
DeviceSessionID: "device-session-123",
UserID: "user-123",
ClientPublicKey: "public-key-123",
Status: StatusRevoked,
RevokedAtMS: &revokedAtMS,
}, nil
},
}
cache, err := NewReadThroughCache(local, fallback)
require.NoError(t, err)
record, err := cache.Lookup(context.Background(), "device-session-123")
require.NoError(t, err)
require.NotNil(t, record.RevokedAtMS)
*record.RevokedAtMS = 1
stored, err := local.Lookup(context.Background(), "device-session-123")
require.NoError(t, err)
require.NotNil(t, stored.RevokedAtMS)
assert.Equal(t, revokedAtMS, *stored.RevokedAtMS)
}
type recordingCache struct {
lookupCalls int
lookupFunc func(context.Context, string) (Record, error)
}
func (c *recordingCache) Lookup(ctx context.Context, deviceSessionID string) (Record, error) {
c.lookupCalls++
if c.lookupFunc != nil {
return c.lookupFunc(ctx, deviceSessionID)
}
return Record{}, errors.New("lookup is not implemented")
}
+192
View File
@@ -0,0 +1,192 @@
package session
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"time"
"galaxy/gateway/internal/config"
"github.com/redis/go-redis/v9"
)
// RedisCache implements Cache with Redis GET lookups over strict JSON session
// records.
type RedisCache struct {
client *redis.Client
keyPrefix string
lookupTimeout time.Duration
}
type redisRecord struct {
DeviceSessionID string `json:"device_session_id"`
UserID string `json:"user_id"`
ClientPublicKey string `json:"client_public_key"`
Status Status `json:"status"`
RevokedAtMS *int64 `json:"revoked_at_ms,omitempty"`
}
// NewRedisCache constructs a Redis-backed SessionCache from cfg. The returned
// cache is read-only from the gateway perspective and does not write or mutate
// Redis state.
func NewRedisCache(cfg config.SessionCacheRedisConfig) (*RedisCache, error) {
if strings.TrimSpace(cfg.Addr) == "" {
return nil, errors.New("new redis session cache: redis addr must not be empty")
}
if cfg.DB < 0 {
return nil, errors.New("new redis session cache: redis db must not be negative")
}
if cfg.LookupTimeout <= 0 {
return nil, errors.New("new redis session cache: lookup timeout must be positive")
}
options := &redis.Options{
Addr: cfg.Addr,
Username: cfg.Username,
Password: cfg.Password,
DB: cfg.DB,
Protocol: 2,
DisableIdentity: true,
}
if cfg.TLSEnabled {
options.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
}
return &RedisCache{
client: redis.NewClient(options),
keyPrefix: cfg.KeyPrefix,
lookupTimeout: cfg.LookupTimeout,
}, nil
}
// Close releases the underlying Redis client resources.
func (c *RedisCache) Close() error {
if c == nil || c.client == nil {
return nil
}
return c.client.Close()
}
// Ping verifies that the configured Redis backend is reachable within the
// cache lookup timeout budget.
func (c *RedisCache) Ping(ctx context.Context) error {
if c == nil || c.client == nil {
return errors.New("ping redis session cache: nil cache")
}
if ctx == nil {
return errors.New("ping redis session cache: nil context")
}
pingCtx, cancel := context.WithTimeout(ctx, c.lookupTimeout)
defer cancel()
if err := c.client.Ping(pingCtx).Err(); err != nil {
return fmt.Errorf("ping redis session cache: %w", err)
}
return nil
}
// Lookup resolves deviceSessionID from Redis, validates the cached JSON
// payload strictly, and returns the decoded session record.
func (c *RedisCache) Lookup(ctx context.Context, deviceSessionID string) (Record, error) {
if c == nil || c.client == nil {
return Record{}, errors.New("lookup session from redis: nil cache")
}
if ctx == nil || fmt.Sprint(ctx) == "context.TODO" {
return Record{}, errors.New("lookup session from redis: nil context")
}
if strings.TrimSpace(deviceSessionID) == "" {
return Record{}, errors.New("lookup session from redis: empty device session id")
}
lookupCtx, cancel := context.WithTimeout(ctx, c.lookupTimeout)
defer cancel()
payload, err := c.client.Get(lookupCtx, c.lookupKey(deviceSessionID)).Bytes()
switch {
case errors.Is(err, redis.Nil):
return Record{}, fmt.Errorf("lookup session from redis: %w", ErrNotFound)
case err != nil:
return Record{}, fmt.Errorf("lookup session from redis: %w", err)
}
record, err := decodeRedisRecord(deviceSessionID, payload)
if err != nil {
return Record{}, fmt.Errorf("lookup session from redis: %w", err)
}
return record, nil
}
func (c *RedisCache) lookupKey(deviceSessionID string) string {
return c.keyPrefix + deviceSessionID
}
func decodeRedisRecord(expectedDeviceSessionID string, payload []byte) (Record, error) {
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
var stored redisRecord
if err := decoder.Decode(&stored); err != nil {
return Record{}, fmt.Errorf("decode redis session record: %w", err)
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return Record{}, errors.New("decode redis session record: unexpected trailing JSON input")
}
return Record{}, fmt.Errorf("decode redis session record: %w", err)
}
record := Record{
DeviceSessionID: stored.DeviceSessionID,
UserID: stored.UserID,
ClientPublicKey: stored.ClientPublicKey,
Status: stored.Status,
RevokedAtMS: cloneOptionalInt64(stored.RevokedAtMS),
}
if err := validateRecord(expectedDeviceSessionID, record); err != nil {
return Record{}, err
}
return record, nil
}
func validateRecord(expectedDeviceSessionID string, record Record) error {
if record.DeviceSessionID == "" {
return errors.New("session record device_session_id must not be empty")
}
if record.DeviceSessionID != expectedDeviceSessionID {
return fmt.Errorf("session record device_session_id %q does not match requested %q", record.DeviceSessionID, expectedDeviceSessionID)
}
if record.UserID == "" {
return errors.New("session record user_id must not be empty")
}
if record.ClientPublicKey == "" {
return errors.New("session record client_public_key must not be empty")
}
if !record.Status.IsKnown() {
return fmt.Errorf("session record status %q is unsupported", record.Status)
}
return nil
}
func cloneOptionalInt64(value *int64) *int64 {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
var _ Cache = (*RedisCache)(nil)
+331
View File
@@ -0,0 +1,331 @@
package session
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"galaxy/gateway/internal/config"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewRedisCache(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
tests := []struct {
name string
cfg config.SessionCacheRedisConfig
wantErr string
}{
{
name: "valid config",
cfg: config.SessionCacheRedisConfig{
Addr: server.Addr(),
DB: 2,
KeyPrefix: "gateway:session:",
LookupTimeout: 250 * time.Millisecond,
},
},
{
name: "empty addr",
cfg: config.SessionCacheRedisConfig{
LookupTimeout: 250 * time.Millisecond,
},
wantErr: "redis addr must not be empty",
},
{
name: "negative db",
cfg: config.SessionCacheRedisConfig{
Addr: server.Addr(),
DB: -1,
LookupTimeout: 250 * time.Millisecond,
},
wantErr: "redis db must not be negative",
},
{
name: "non-positive lookup timeout",
cfg: config.SessionCacheRedisConfig{
Addr: server.Addr(),
},
wantErr: "lookup timeout must be positive",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cache, err := NewRedisCache(tt.cfg)
if tt.wantErr != "" {
require.Error(t, err)
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
t.Cleanup(func() {
assert.NoError(t, cache.Close())
})
})
}
}
func TestRedisCachePing(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
cache := newTestRedisCache(t, server, config.SessionCacheRedisConfig{})
require.NoError(t, cache.Ping(context.Background()))
}
func TestRedisCacheLookup(t *testing.T) {
t.Parallel()
revokedAtMS := int64(123456789)
tests := []struct {
name string
cfg config.SessionCacheRedisConfig
requestID string
seed func(*testing.T, *miniredis.Miniredis, config.SessionCacheRedisConfig)
want Record
wantErrIs error
wantErrText string
assertErrText string
}{
{
name: "active cache hit",
requestID: "device-session-123",
cfg: config.SessionCacheRedisConfig{
KeyPrefix: "gateway:session:",
},
seed: func(t *testing.T, server *miniredis.Miniredis, cfg config.SessionCacheRedisConfig) {
t.Helper()
setRedisSessionRecord(t, server, cfg.KeyPrefix+"device-session-123", redisRecord{
DeviceSessionID: "device-session-123",
UserID: "user-123",
ClientPublicKey: "public-key-123",
Status: StatusActive,
})
},
want: Record{
DeviceSessionID: "device-session-123",
UserID: "user-123",
ClientPublicKey: "public-key-123",
Status: StatusActive,
},
},
{
name: "missing session",
requestID: "device-session-404",
cfg: config.SessionCacheRedisConfig{
KeyPrefix: "gateway:session:",
},
wantErrIs: ErrNotFound,
assertErrText: "session cache record not found",
},
{
name: "revoked session",
requestID: "device-session-revoked",
cfg: config.SessionCacheRedisConfig{
KeyPrefix: "gateway:session:",
},
seed: func(t *testing.T, server *miniredis.Miniredis, cfg config.SessionCacheRedisConfig) {
t.Helper()
setRedisSessionRecord(t, server, cfg.KeyPrefix+"device-session-revoked", redisRecord{
DeviceSessionID: "device-session-revoked",
UserID: "user-777",
ClientPublicKey: "public-key-777",
Status: StatusRevoked,
RevokedAtMS: &revokedAtMS,
})
},
want: Record{
DeviceSessionID: "device-session-revoked",
UserID: "user-777",
ClientPublicKey: "public-key-777",
Status: StatusRevoked,
RevokedAtMS: &revokedAtMS,
},
},
{
name: "malformed json",
requestID: "device-session-bad-json",
cfg: config.SessionCacheRedisConfig{
KeyPrefix: "gateway:session:",
},
seed: func(t *testing.T, server *miniredis.Miniredis, cfg config.SessionCacheRedisConfig) {
t.Helper()
server.Set(cfg.KeyPrefix+"device-session-bad-json", "{")
},
wantErrText: "decode redis session record",
},
{
name: "unknown status",
requestID: "device-session-unknown-status",
cfg: config.SessionCacheRedisConfig{
KeyPrefix: "gateway:session:",
},
seed: func(t *testing.T, server *miniredis.Miniredis, cfg config.SessionCacheRedisConfig) {
t.Helper()
setRedisSessionRecord(t, server, cfg.KeyPrefix+"device-session-unknown-status", redisRecord{
DeviceSessionID: "device-session-unknown-status",
UserID: "user-1",
ClientPublicKey: "public-key-1",
Status: Status("paused"),
})
},
wantErrText: `status "paused" is unsupported`,
},
{
name: "missing required field",
requestID: "device-session-missing-user",
cfg: config.SessionCacheRedisConfig{
KeyPrefix: "gateway:session:",
},
seed: func(t *testing.T, server *miniredis.Miniredis, cfg config.SessionCacheRedisConfig) {
t.Helper()
setRedisSessionRecord(t, server, cfg.KeyPrefix+"device-session-missing-user", redisRecord{
DeviceSessionID: "device-session-missing-user",
ClientPublicKey: "public-key-1",
Status: StatusActive,
})
},
wantErrText: "user_id must not be empty",
},
{
name: "device session id mismatch",
requestID: "device-session-requested",
cfg: config.SessionCacheRedisConfig{
KeyPrefix: "gateway:session:",
},
seed: func(t *testing.T, server *miniredis.Miniredis, cfg config.SessionCacheRedisConfig) {
t.Helper()
setRedisSessionRecord(t, server, cfg.KeyPrefix+"device-session-requested", redisRecord{
DeviceSessionID: "device-session-other",
UserID: "user-1",
ClientPublicKey: "public-key-1",
Status: StatusActive,
})
},
wantErrText: `does not match requested "device-session-requested"`,
},
{
name: "key prefix is honored",
requestID: "device-session-prefixed",
cfg: config.SessionCacheRedisConfig{
KeyPrefix: "custom:session:",
},
seed: func(t *testing.T, server *miniredis.Miniredis, cfg config.SessionCacheRedisConfig) {
t.Helper()
setRedisSessionRecord(t, server, cfg.KeyPrefix+"device-session-prefixed", redisRecord{
DeviceSessionID: "device-session-prefixed",
UserID: "user-prefixed",
ClientPublicKey: "public-key-prefixed",
Status: StatusActive,
})
setRedisSessionRecord(t, server, "gateway:session:device-session-prefixed", redisRecord{
DeviceSessionID: "device-session-prefixed",
UserID: "wrong-user",
ClientPublicKey: "wrong-key",
Status: StatusRevoked,
})
},
want: Record{
DeviceSessionID: "device-session-prefixed",
UserID: "user-prefixed",
ClientPublicKey: "public-key-prefixed",
Status: StatusActive,
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
cfg := tt.cfg
cfg.Addr = server.Addr()
cfg.DB = 0
cfg.LookupTimeout = 250 * time.Millisecond
if tt.seed != nil {
tt.seed(t, server, cfg)
}
cache := newTestRedisCache(t, server, cfg)
record, err := cache.Lookup(context.Background(), tt.requestID)
if tt.wantErrIs != nil || tt.wantErrText != "" {
require.Error(t, err)
if tt.wantErrIs != nil {
assert.ErrorIs(t, err, tt.wantErrIs)
}
if tt.wantErrText != "" {
assert.ErrorContains(t, err, tt.wantErrText)
}
if tt.assertErrText != "" {
assert.ErrorContains(t, err, tt.assertErrText)
}
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, record)
})
}
}
func newTestRedisCache(t *testing.T, server *miniredis.Miniredis, cfg config.SessionCacheRedisConfig) *RedisCache {
t.Helper()
if cfg.Addr == "" {
cfg.Addr = server.Addr()
}
if cfg.LookupTimeout == 0 {
cfg.LookupTimeout = 250 * time.Millisecond
}
cache, err := NewRedisCache(cfg)
require.NoError(t, err)
t.Cleanup(func() {
assert.NoError(t, cache.Close())
})
return cache
}
func setRedisSessionRecord(t *testing.T, server *miniredis.Miniredis, key string, record redisRecord) {
t.Helper()
payload, err := json.Marshal(record)
require.NoError(t, err)
server.Set(key, string(payload))
}
func TestRedisCacheLookupNilContext(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
cache := newTestRedisCache(t, server, config.SessionCacheRedisConfig{})
_, err := cache.Lookup(context.TODO(), "device-session-123")
require.Error(t, err)
assert.False(t, errors.Is(err, ErrNotFound))
assert.ErrorContains(t, err, "nil context")
}
+80
View File
@@ -0,0 +1,80 @@
// Package session defines the authenticated session-cache contract used by the
// gateway hot path.
package session
import (
"context"
"errors"
)
var (
// ErrNotFound reports that SessionCache does not currently contain the
// requested device session identifier.
ErrNotFound = errors.New("session cache record not found")
)
// Cache resolves authenticated device-session state from the gateway hot-path
// cache.
type Cache interface {
// Lookup returns the cached record for deviceSessionID. Implementations must
// wrap ErrNotFound when the cache does not contain the requested record.
Lookup(ctx context.Context, deviceSessionID string) (Record, error)
}
// SnapshotStore stores mutable session record snapshots inside one gateway
// process and exposes the same read contract as Cache for the hot path.
type SnapshotStore interface {
Cache
// Upsert stores record under record.DeviceSessionID, replacing any previous
// snapshot for that session.
Upsert(record Record) error
// Delete removes the local snapshot for deviceSessionID when it exists.
Delete(deviceSessionID string)
}
// Status identifies the cached lifecycle state of a device session.
type Status string
const (
// StatusActive reports that the cached device session may continue through
// later authenticated gateway checks.
StatusActive Status = "active"
// StatusRevoked reports that the cached device session has been revoked and
// must be rejected before later auth steps run.
StatusRevoked Status = "revoked"
)
// Record is the minimum authenticated session state required by the gateway
// before signature verification begins.
type Record struct {
// DeviceSessionID is the stable device-session identifier resolved from the
// hot-path cache.
DeviceSessionID string
// UserID is the authenticated user identity bound to DeviceSessionID.
UserID string
// ClientPublicKey is the standard base64-encoded raw Ed25519 public key
// material used for request-signature verification.
ClientPublicKey string
// Status reports whether the cached session is active or revoked.
Status Status
// RevokedAtMS optionally records when the device session was revoked.
RevokedAtMS *int64
}
// IsKnown reports whether s is one of the session states supported by the
// gateway.
func (s Status) IsKnown() bool {
switch s {
case StatusActive, StatusRevoked:
return true
default:
return false
}
}
+102
View File
@@ -0,0 +1,102 @@
// Package telemetry provides shared edge observability helpers used by the
// gateway transports and internal event consumers.
package telemetry
import (
"net/http"
"strings"
"google.golang.org/grpc/codes"
)
// EdgeOutcome is the stable low-cardinality outcome vocabulary shared by REST,
// gRPC, push shutdown, and observability backends.
type EdgeOutcome string
const (
EdgeOutcomeSuccess EdgeOutcome = "success"
EdgeOutcomeMalformedRequest EdgeOutcome = "malformed_request"
EdgeOutcomeRequestTooLarge EdgeOutcome = "request_too_large"
EdgeOutcomeUnsupportedProtocol EdgeOutcome = "unsupported_protocol"
EdgeOutcomeUnknownSession EdgeOutcome = "unknown_session"
EdgeOutcomeRevokedSession EdgeOutcome = "revoked_session"
EdgeOutcomeInvalidSignature EdgeOutcome = "invalid_signature"
EdgeOutcomeStaleRequest EdgeOutcome = "stale_request"
EdgeOutcomeReplayDetected EdgeOutcome = "replay_detected"
EdgeOutcomeRateLimited EdgeOutcome = "rate_limited"
EdgeOutcomePolicyDenied EdgeOutcome = "policy_denied"
EdgeOutcomeDownstreamUnavailable EdgeOutcome = "downstream_unavailable"
EdgeOutcomeBackendUnavailable EdgeOutcome = "backend_unavailable"
EdgeOutcomeInternalError EdgeOutcome = "internal_error"
EdgeOutcomeGatewayShuttingDown EdgeOutcome = "gateway_shutting_down"
)
// RejectReason returns the stable reject reason for outcome. Success does not
// produce a reject reason.
func RejectReason(outcome EdgeOutcome) string {
if outcome == EdgeOutcomeSuccess {
return ""
}
return string(outcome)
}
// OutcomeFromPublicErrorCode maps the stable public REST error envelope into
// the shared edge-outcome vocabulary.
func OutcomeFromPublicErrorCode(statusCode int, code string) EdgeOutcome {
switch strings.TrimSpace(code) {
case "":
if statusCode < http.StatusBadRequest {
return EdgeOutcomeSuccess
}
return EdgeOutcomeInternalError
case "invalid_request", "method_not_allowed", "not_found":
return EdgeOutcomeMalformedRequest
case "request_too_large":
return EdgeOutcomeRequestTooLarge
case "rate_limited":
return EdgeOutcomeRateLimited
case "service_unavailable":
return EdgeOutcomeBackendUnavailable
default:
if statusCode >= http.StatusInternalServerError {
return EdgeOutcomeInternalError
}
return EdgeOutcomeMalformedRequest
}
}
// OutcomeFromGRPCStatus maps the stable authenticated gRPC reject contract
// into the shared edge-outcome vocabulary.
func OutcomeFromGRPCStatus(code codes.Code, message string) EdgeOutcome {
switch {
case code == codes.OK:
return EdgeOutcomeSuccess
case code == codes.InvalidArgument:
return EdgeOutcomeMalformedRequest
case code == codes.FailedPrecondition && strings.Contains(message, "unsupported protocol_version"):
return EdgeOutcomeUnsupportedProtocol
case code == codes.Unauthenticated && message == "unknown device session":
return EdgeOutcomeUnknownSession
case code == codes.FailedPrecondition && message == "device session is revoked":
return EdgeOutcomeRevokedSession
case code == codes.Unauthenticated && message == "invalid request signature":
return EdgeOutcomeInvalidSignature
case code == codes.FailedPrecondition && message == "request timestamp is outside the freshness window":
return EdgeOutcomeStaleRequest
case code == codes.FailedPrecondition && message == "request replay detected":
return EdgeOutcomeReplayDetected
case code == codes.ResourceExhausted && message == "authenticated request rate limit exceeded":
return EdgeOutcomeRateLimited
case code == codes.PermissionDenied && message == "authenticated request rejected by edge policy":
return EdgeOutcomePolicyDenied
case code == codes.Unavailable && message == "downstream service is unavailable":
return EdgeOutcomeDownstreamUnavailable
case code == codes.Unavailable && message == "gateway is shutting down":
return EdgeOutcomeGatewayShuttingDown
case code == codes.Unavailable:
return EdgeOutcomeBackendUnavailable
default:
return EdgeOutcomeInternalError
}
}
+48
View File
@@ -0,0 +1,48 @@
package telemetry
import (
"context"
"errors"
"galaxy/gateway/internal/push"
"go.opentelemetry.io/otel/attribute"
)
// PushObserver adapts Runtime to the push.Observer interface.
type PushObserver struct {
runtime *Runtime
}
// NewPushObserver constructs a push stream observer backed by runtime.
func NewPushObserver(runtime *Runtime) *PushObserver {
return &PushObserver{runtime: runtime}
}
// Registered records one active push stream.
func (o *PushObserver) Registered(_ push.StreamBinding) {
if o == nil || o.runtime == nil {
return
}
o.runtime.AddActivePushStream(context.Background(), 1)
}
// Unregistered records one active-stream decrement and one closure reason for
// hub-enforced shutdown, overflow, or revocation.
func (o *PushObserver) Unregistered(_ push.StreamBinding, err error) {
if o == nil || o.runtime == nil {
return
}
o.runtime.AddActivePushStream(context.Background(), -1)
switch {
case errors.Is(err, push.ErrSubscriptionOverflow):
o.runtime.RecordPushStreamClosure(context.Background(), attribute.String("reason", "overflow"))
case errors.Is(err, push.ErrSubscriptionRevoked):
o.runtime.RecordPushStreamClosure(context.Background(), attribute.String("reason", "revoked"))
case errors.Is(err, push.ErrHubShuttingDown):
o.runtime.RecordPushStreamClosure(context.Background(), attribute.String("reason", "shutdown"))
}
}
+254
View File
@@ -0,0 +1,254 @@
package telemetry
import (
"context"
"errors"
"net/http"
"os"
"strings"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
otelprom "go.opentelemetry.io/otel/exporters/prometheus"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/propagation"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.uber.org/zap"
)
const defaultServiceName = "galaxy-edge-gateway"
// Runtime owns the shared OpenTelemetry providers, the Prometheus metrics
// handler, and the custom low-cardinality edge instruments.
type Runtime struct {
logger *zap.Logger
tracerProvider *sdktrace.TracerProvider
meterProvider *sdkmetric.MeterProvider
promHandler http.Handler
// Public REST instruments.
publicRequests metric.Int64Counter
publicDuration metric.Float64Histogram
// Authenticated gRPC instruments.
grpcRequests metric.Int64Counter
grpcDuration metric.Float64Histogram
// Push instruments.
pushActiveStreams metric.Int64UpDownCounter
pushStreamClosers metric.Int64Counter
// Internal event consumer instruments.
internalEventDrops metric.Int64Counter
}
// New constructs the gateway telemetry runtime, registers global providers,
// and returns the Prometheus handler used by the admin listener.
func New(ctx context.Context, logger *zap.Logger) (*Runtime, error) {
if logger == nil {
logger = zap.NewNop()
}
serviceName := strings.TrimSpace(os.Getenv("OTEL_SERVICE_NAME"))
if serviceName == "" {
serviceName = defaultServiceName
}
res, err := resource.New(
ctx,
resource.WithAttributes(attribute.String("service.name", serviceName)),
)
if err != nil {
return nil, err
}
tracerProvider, err := newTracerProvider(ctx, res)
if err != nil {
return nil, err
}
registry := prometheus.NewRegistry()
exporter, err := otelprom.New(otelprom.WithRegisterer(registry))
if err != nil {
return nil, err
}
meterProvider := sdkmetric.NewMeterProvider(
sdkmetric.WithResource(res),
sdkmetric.WithReader(exporter),
)
otel.SetTracerProvider(tracerProvider)
otel.SetMeterProvider(meterProvider)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
meter := meterProvider.Meter("galaxy/gateway")
publicRequests, err := meter.Int64Counter("gateway.public_http.requests")
if err != nil {
return nil, err
}
publicDuration, err := meter.Float64Histogram("gateway.public_http.duration", metric.WithUnit("ms"))
if err != nil {
return nil, err
}
grpcRequests, err := meter.Int64Counter("gateway.authenticated_grpc.requests")
if err != nil {
return nil, err
}
grpcDuration, err := meter.Float64Histogram("gateway.authenticated_grpc.duration", metric.WithUnit("ms"))
if err != nil {
return nil, err
}
pushActiveStreams, err := meter.Int64UpDownCounter("gateway.push.active_streams")
if err != nil {
return nil, err
}
pushStreamClosers, err := meter.Int64Counter("gateway.push.stream_closures")
if err != nil {
return nil, err
}
internalEventDrops, err := meter.Int64Counter("gateway.internal_event_drops")
if err != nil {
return nil, err
}
return &Runtime{
logger: logger,
tracerProvider: tracerProvider,
meterProvider: meterProvider,
promHandler: promhttp.HandlerFor(registry, promhttp.HandlerOpts{}),
publicRequests: publicRequests,
publicDuration: publicDuration,
grpcRequests: grpcRequests,
grpcDuration: grpcDuration,
pushActiveStreams: pushActiveStreams,
pushStreamClosers: pushStreamClosers,
internalEventDrops: internalEventDrops,
}, nil
}
// Handler returns the Prometheus handler that should be mounted on the admin
// listener.
func (r *Runtime) Handler() http.Handler {
if r == nil || r.promHandler == nil {
return http.NotFoundHandler()
}
return r.promHandler
}
// Shutdown flushes the configured telemetry providers.
func (r *Runtime) Shutdown(ctx context.Context) error {
if r == nil {
return nil
}
var shutdownErr error
if r.meterProvider != nil {
shutdownErr = errors.Join(shutdownErr, r.meterProvider.Shutdown(ctx))
}
if r.tracerProvider != nil {
shutdownErr = errors.Join(shutdownErr, r.tracerProvider.Shutdown(ctx))
}
return shutdownErr
}
// RecordPublicRequest records one public REST request outcome.
func (r *Runtime) RecordPublicRequest(ctx context.Context, attrs []attribute.KeyValue, duration time.Duration) {
if r == nil {
return
}
options := metric.WithAttributes(attrs...)
r.publicRequests.Add(ctx, 1, options)
r.publicDuration.Record(ctx, duration.Seconds()*1000, options)
}
// RecordAuthenticatedGRPC records one authenticated gRPC request or stream
// outcome.
func (r *Runtime) RecordAuthenticatedGRPC(ctx context.Context, attrs []attribute.KeyValue, duration time.Duration) {
if r == nil {
return
}
options := metric.WithAttributes(attrs...)
r.grpcRequests.Add(ctx, 1, options)
r.grpcDuration.Record(ctx, duration.Seconds()*1000, options)
}
// AddActivePushStream records one active-stream delta.
func (r *Runtime) AddActivePushStream(ctx context.Context, delta int64, attrs ...attribute.KeyValue) {
if r == nil {
return
}
r.pushActiveStreams.Add(ctx, delta, metric.WithAttributes(attrs...))
}
// RecordPushStreamClosure records one push-stream closure reason.
func (r *Runtime) RecordPushStreamClosure(ctx context.Context, attrs ...attribute.KeyValue) {
if r == nil {
return
}
r.pushStreamClosers.Add(ctx, 1, metric.WithAttributes(attrs...))
}
// RecordInternalEventDrop records one malformed or rejected internal event.
func (r *Runtime) RecordInternalEventDrop(ctx context.Context, attrs ...attribute.KeyValue) {
if r == nil {
return
}
r.internalEventDrops.Add(ctx, 1, metric.WithAttributes(attrs...))
}
func newTracerProvider(ctx context.Context, res *resource.Resource) (*sdktrace.TracerProvider, error) {
exporterName := strings.TrimSpace(os.Getenv("OTEL_TRACES_EXPORTER"))
if exporterName == "" || exporterName == "none" {
return sdktrace.NewTracerProvider(sdktrace.WithResource(res)), nil
}
if exporterName != "otlp" {
return nil, errors.New("unsupported OTEL_TRACES_EXPORTER value")
}
protocol := strings.TrimSpace(os.Getenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"))
if protocol == "" {
protocol = strings.TrimSpace(os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL"))
}
var (
exporter sdktrace.SpanExporter
err error
)
switch protocol {
case "", "http/protobuf":
exporter, err = otlptracehttp.New(ctx)
case "grpc":
exporter, err = otlptracegrpc.New(ctx)
default:
return nil, errors.New("unsupported OTEL exporter protocol")
}
if err != nil {
return nil, err
}
return sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(res),
), nil
}
@@ -0,0 +1,94 @@
package testutil
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"galaxy/gateway/internal/logging"
"galaxy/gateway/internal/telemetry"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// LogBuffer is a concurrency-safe in-memory buffer used by observability
// tests.
type LogBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
// Write appends p to the buffer.
func (b *LogBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
// String returns the current buffer contents.
func (b *LogBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// NewObservedLogger constructs a JSON zap logger that writes into an in-memory
// buffer suitable for log assertions.
func NewObservedLogger(t *testing.T) (*zap.Logger, *LogBuffer) {
t.Helper()
buffer := &LogBuffer{}
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.TimeKey = "timestamp"
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
core := zapcore.NewCore(
zapcore.NewJSONEncoder(encoderConfig),
zapcore.Lock(zapcore.AddSync(buffer)),
zap.DebugLevel,
)
logger := zap.New(core)
t.Cleanup(func() {
require.NoError(t, logging.Sync(logger))
})
return logger, buffer
}
// NewTelemetryRuntime constructs a telemetry runtime for tests and shuts it
// down automatically.
func NewTelemetryRuntime(t *testing.T, logger *zap.Logger) *telemetry.Runtime {
t.Helper()
runtime, err := telemetry.New(context.Background(), logger)
require.NoError(t, err)
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
require.NoError(t, runtime.Shutdown(ctx))
})
return runtime
}
// ScrapeMetrics returns the Prometheus exposition produced by handler.
func ScrapeMetrics(t *testing.T, handler http.Handler) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
require.Equal(t, http.StatusOK, recorder.Code)
return recorder.Body.String()
}
+462
View File
@@ -0,0 +1,462 @@
openapi: 3.0.3
info:
title: Galaxy Edge Gateway Public REST API
version: v1
description: |
This specification documents the implemented `galaxy/gateway` v1 public
REST surface.
Implemented endpoints:
- `GET /healthz`
- `GET /readyz`
- `POST /api/v1/public/auth/send-email-code`
- `POST /api/v1/public/auth/confirm-email-code`
This specification intentionally excludes the private operational admin
listener and its `GET /metrics` endpoint. That endpoint is documented in
`README.md` because it is not part of the public REST contract.
Common runtime behavior:
- requests are unauthenticated;
- unknown routes return `404` with the JSON error envelope;
- unsupported methods on implemented routes and browser-shaped public paths
return `405` with the same JSON error envelope and an `Allow` header;
- request classification happens before route handling and depends on the
incoming method, path, and selected headers;
- the only stable public route classes are `public_auth`,
`browser_bootstrap`, `browser_asset`, and `public_misc`;
- any unsupported or empty classifier result is normalized to
`public_misc`;
- public REST policy derives its base bucket namespace from the normalized
class as `public_rest/class=<class>`;
- per-IP public REST rate limits use only `RemoteAddr`; `X-Forwarded-For`
and `Forwarded` are intentionally ignored;
- `public_auth` additionally applies normalized identity buckets by
`email` for `send-email-code` and by `challenge_id` for
`confirm-email-code`;
- oversized request bodies are rejected with `413 request_too_large`;
- public REST rate limits reject with `429 rate_limited` and a
`Retry-After` header;
- public auth routes delegate through `AuthServiceClient`;
- the default `cmd/gateway` wiring keeps the auth routes mounted and
returns `503 service_unavailable` until a concrete upstream auth adapter
is configured;
- injected public auth adapters may also project client-safe `4xx/5xx`
`AuthServiceError` envelopes, which the gateway preserves after
normalizing blank or invalid fields.
servers:
- url: http://localhost:8080
description: |
Example local public REST listener. The actual address is configured by
`GATEWAY_PUBLIC_HTTP_ADDR`.
tags:
- name: Probes
description: Unauthenticated public probe endpoints served by the gateway.
- name: PublicAuth
description: |
Unauthenticated public auth endpoints delegated to the Auth / Session
Service through `AuthServiceClient`.
paths:
/healthz:
get:
tags:
- Probes
operationId: getHealthz
summary: Public liveness probe
description: |
Returns a deterministic JSON payload confirming that the public REST
listener is alive and able to answer requests.
security: []
x-public-route-classification-note: |
Typical probe requests are classified as `public_misc`.
Requests that match browser bootstrap rules, for example because they
advertise `Accept: text/html`, are classified as `browser_bootstrap`
before the route handler runs.
responses:
"200":
description: Public REST listener is alive.
content:
application/json:
schema:
$ref: "#/components/schemas/HealthzResponse"
examples:
ok:
value:
status: ok
"413":
$ref: "#/components/responses/RequestTooLargeError"
"429":
$ref: "#/components/responses/RateLimitedError"
"500":
$ref: "#/components/responses/InternalError"
/readyz:
get:
tags:
- Probes
operationId: getReadyz
summary: Public readiness probe
description: |
Returns a deterministic JSON payload confirming that the process is
ready to accept public REST traffic. Readiness is local-process only
and does not reflect downstream dependencies.
security: []
x-public-route-classification-note: |
Typical probe requests are classified as `public_misc`.
Requests that match browser bootstrap rules, for example because they
advertise `Accept: text/html`, are classified as `browser_bootstrap`
before the route handler runs.
responses:
"200":
description: Public REST listener is ready to accept traffic.
content:
application/json:
schema:
$ref: "#/components/schemas/ReadyzResponse"
examples:
ready:
value:
status: ready
"413":
$ref: "#/components/responses/RequestTooLargeError"
"429":
$ref: "#/components/responses/RateLimitedError"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/public/auth/send-email-code:
post:
tags:
- PublicAuth
operationId: sendEmailCode
summary: Start a public e-mail login challenge
description: |
Accepts a single client e-mail address and delegates the command to the
Auth / Session Service. The response returns an opaque `challenge_id`
that must later be confirmed through
`POST /api/v1/public/auth/confirm-email-code`.
This route is unauthenticated and classified as `public_auth`.
Public REST anti-abuse applies a per-IP bucket derived from
`RemoteAddr` and an additional normalized identity bucket derived from
`email`.
In the default `cmd/gateway` process wiring the upstream auth adapter
is intentionally absent, so this route returns `503
service_unavailable` until a concrete `AuthServiceClient` is injected.
When an injected adapter returns a client-safe `AuthServiceError`, the
gateway preserves that projected `4xx/5xx` status and serialized error
envelope after normalizing blank or invalid fields.
security: []
x-public-route-classification-note: |
This route is always classified as `public_auth`.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SendEmailCodeRequest"
examples:
default:
value:
email: pilot@example.com
responses:
"200":
description: The login challenge was accepted by the Auth / Session Service.
content:
application/json:
schema:
$ref: "#/components/schemas/SendEmailCodeResponse"
examples:
accepted:
value:
challenge_id: challenge-123
"400":
$ref: "#/components/responses/InvalidRequestError"
"413":
$ref: "#/components/responses/RequestTooLargeError"
"405":
$ref: "#/components/responses/MethodNotAllowedError"
"429":
$ref: "#/components/responses/RateLimitedError"
"500":
$ref: "#/components/responses/InternalError"
"503":
$ref: "#/components/responses/ServiceUnavailableError"
default:
$ref: "#/components/responses/ProjectedAuthServiceError"
/api/v1/public/auth/confirm-email-code:
post:
tags:
- PublicAuth
operationId: confirmEmailCode
summary: Confirm a public e-mail login challenge
description: |
Completes a previously issued `challenge_id`, sends the verification
`code`, and registers the standard base64-encoded raw 32-byte Ed25519
`client_public_key` for the new device session. The response returns
the created `device_session_id`.
This route is unauthenticated and classified as `public_auth`.
Public REST anti-abuse applies a per-IP bucket derived from
`RemoteAddr` and an additional normalized identity bucket derived from
`challenge_id`.
In the default `cmd/gateway` process wiring the upstream auth adapter
is intentionally absent, so this route returns `503
service_unavailable` until a concrete `AuthServiceClient` is injected.
When an injected adapter returns a client-safe `AuthServiceError`, the
gateway preserves that projected `4xx/5xx` status and serialized error
envelope after normalizing blank or invalid fields.
security: []
x-public-route-classification-note: |
This route is always classified as `public_auth`.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ConfirmEmailCodeRequest"
examples:
default:
value:
challenge_id: challenge-123
code: "123456"
client_public_key: base64-encoded-raw-ed25519-public-key
responses:
"200":
description: The device session was created by the Auth / Session Service.
content:
application/json:
schema:
$ref: "#/components/schemas/ConfirmEmailCodeResponse"
examples:
accepted:
value:
device_session_id: device-session-123
"400":
$ref: "#/components/responses/InvalidRequestError"
"413":
$ref: "#/components/responses/RequestTooLargeError"
"405":
$ref: "#/components/responses/MethodNotAllowedError"
"429":
$ref: "#/components/responses/RateLimitedError"
"500":
$ref: "#/components/responses/InternalError"
"503":
$ref: "#/components/responses/ServiceUnavailableError"
default:
$ref: "#/components/responses/ProjectedAuthServiceError"
components:
schemas:
HealthzResponse:
type: object
additionalProperties: false
required:
- status
properties:
status:
type: string
description: Deterministic liveness marker.
enum:
- ok
ReadyzResponse:
type: object
additionalProperties: false
required:
- status
properties:
status:
type: string
description: Deterministic readiness marker.
enum:
- ready
SendEmailCodeRequest:
type: object
additionalProperties: false
required:
- email
properties:
email:
type: string
description: Single client e-mail address that should receive the login code.
format: email
SendEmailCodeResponse:
type: object
additionalProperties: false
required:
- challenge_id
properties:
challenge_id:
type: string
description: Opaque challenge identifier returned by the Auth / Session Service.
ConfirmEmailCodeRequest:
type: object
additionalProperties: false
required:
- challenge_id
- code
- client_public_key
properties:
challenge_id:
type: string
description: Opaque challenge identifier previously returned by send-email-code.
code:
type: string
description: Verification code delivered to the client.
client_public_key:
type: string
description: Standard base64-encoded raw 32-byte Ed25519 public key registered for the new device session.
ConfirmEmailCodeResponse:
type: object
additionalProperties: false
required:
- device_session_id
properties:
device_session_id:
type: string
description: Stable identifier of the created device session.
ErrorResponse:
type: object
additionalProperties: false
required:
- error
properties:
error:
$ref: "#/components/schemas/ErrorBody"
ErrorBody:
type: object
additionalProperties: false
required:
- code
- message
properties:
code:
type: string
description: |
Stable gateway-generated or client-safe auth-adapter-projected
error code. Gateway-generated values include `invalid_request`,
`not_found`, `method_not_allowed`, `request_too_large`,
`rate_limited`, `internal_error`, and `service_unavailable`.
message:
type: string
description: Human-readable client-safe error description.
headers:
Allow:
description: Comma-separated list of allowed methods for the target route.
schema:
type: string
example: GET
Retry-After:
description: Seconds until the client should retry a rejected rate-limited request.
schema:
type: string
example: "3600"
responses:
InvalidRequestError:
description: Request body or field values are invalid for the target public auth route.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
examples:
invalidRequest:
value:
error:
code: invalid_request
message: email must be a single valid email address
NotFoundError:
description: Request path is not implemented on the current public REST surface.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
examples:
notFound:
value:
error:
code: not_found
message: resource was not found
MethodNotAllowedError:
description: Request method is not allowed for an implemented route.
headers:
Allow:
$ref: "#/components/headers/Allow"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
examples:
methodNotAllowed:
value:
error:
code: method_not_allowed
message: request method is not allowed for this route
RequestTooLargeError:
description: Request body exceeds the configured public REST body limit for the route class.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
examples:
requestTooLarge:
value:
error:
code: request_too_large
message: request body exceeds the configured limit
RateLimitedError:
description: Request is rejected by the public REST anti-abuse rate limiter.
headers:
Retry-After:
$ref: "#/components/headers/Retry-After"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
examples:
rateLimited:
value:
error:
code: rate_limited
message: request rate limit exceeded
InternalError:
description: Internal gateway error while processing the request.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
examples:
internalError:
value:
error:
code: internal_error
message: internal server error
ServiceUnavailableError:
description: |
The public route is mounted, but the configured or default auth adapter
cannot currently serve the request.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
examples:
unavailable:
value:
error:
code: service_unavailable
message: auth service is unavailable
ProjectedAuthServiceError:
description: |
Client-safe `4xx/5xx` error envelope projected by an injected public
auth adapter through `AuthServiceError`. The gateway preserves the
projected status and serialized envelope after normalizing blank or
invalid fields.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
examples:
projectedRateLimit:
value:
error:
code: upstream_rate_limited
message: too many attempts for this email
@@ -0,0 +1,545 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc (unknown)
// source: galaxy/gateway/v1/edge_gateway.proto
package gatewayv1
import (
_ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type ExecuteCommandRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// protocol_version identifies the request envelope version. The gateway
// accepts only the literal "v1" after required-field validation succeeds.
ProtocolVersion string `protobuf:"bytes,1,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"`
DeviceSessionId string `protobuf:"bytes,2,opt,name=device_session_id,json=deviceSessionId,proto3" json:"device_session_id,omitempty"`
MessageType string `protobuf:"bytes,3,opt,name=message_type,json=messageType,proto3" json:"message_type,omitempty"`
TimestampMs int64 `protobuf:"varint,4,opt,name=timestamp_ms,json=timestampMs,proto3" json:"timestamp_ms,omitempty"`
RequestId string `protobuf:"bytes,5,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
PayloadBytes []byte `protobuf:"bytes,6,opt,name=payload_bytes,json=payloadBytes,proto3" json:"payload_bytes,omitempty"`
// payload_hash is the raw 32-byte SHA-256 digest of payload_bytes.
PayloadHash []byte `protobuf:"bytes,7,opt,name=payload_hash,json=payloadHash,proto3" json:"payload_hash,omitempty"`
Signature []byte `protobuf:"bytes,8,opt,name=signature,proto3" json:"signature,omitempty"`
TraceId string `protobuf:"bytes,9,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExecuteCommandRequest) Reset() {
*x = ExecuteCommandRequest{}
mi := &file_galaxy_gateway_v1_edge_gateway_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExecuteCommandRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExecuteCommandRequest) ProtoMessage() {}
func (x *ExecuteCommandRequest) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_gateway_v1_edge_gateway_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExecuteCommandRequest.ProtoReflect.Descriptor instead.
func (*ExecuteCommandRequest) Descriptor() ([]byte, []int) {
return file_galaxy_gateway_v1_edge_gateway_proto_rawDescGZIP(), []int{0}
}
func (x *ExecuteCommandRequest) GetProtocolVersion() string {
if x != nil {
return x.ProtocolVersion
}
return ""
}
func (x *ExecuteCommandRequest) GetDeviceSessionId() string {
if x != nil {
return x.DeviceSessionId
}
return ""
}
func (x *ExecuteCommandRequest) GetMessageType() string {
if x != nil {
return x.MessageType
}
return ""
}
func (x *ExecuteCommandRequest) GetTimestampMs() int64 {
if x != nil {
return x.TimestampMs
}
return 0
}
func (x *ExecuteCommandRequest) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
func (x *ExecuteCommandRequest) GetPayloadBytes() []byte {
if x != nil {
return x.PayloadBytes
}
return nil
}
func (x *ExecuteCommandRequest) GetPayloadHash() []byte {
if x != nil {
return x.PayloadHash
}
return nil
}
func (x *ExecuteCommandRequest) GetSignature() []byte {
if x != nil {
return x.Signature
}
return nil
}
func (x *ExecuteCommandRequest) GetTraceId() string {
if x != nil {
return x.TraceId
}
return ""
}
type ExecuteCommandResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
ProtocolVersion string `protobuf:"bytes,1,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"`
RequestId string `protobuf:"bytes,2,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
TimestampMs int64 `protobuf:"varint,3,opt,name=timestamp_ms,json=timestampMs,proto3" json:"timestamp_ms,omitempty"`
ResultCode string `protobuf:"bytes,4,opt,name=result_code,json=resultCode,proto3" json:"result_code,omitempty"`
PayloadBytes []byte `protobuf:"bytes,5,opt,name=payload_bytes,json=payloadBytes,proto3" json:"payload_bytes,omitempty"`
PayloadHash []byte `protobuf:"bytes,6,opt,name=payload_hash,json=payloadHash,proto3" json:"payload_hash,omitempty"`
Signature []byte `protobuf:"bytes,7,opt,name=signature,proto3" json:"signature,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExecuteCommandResponse) Reset() {
*x = ExecuteCommandResponse{}
mi := &file_galaxy_gateway_v1_edge_gateway_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExecuteCommandResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExecuteCommandResponse) ProtoMessage() {}
func (x *ExecuteCommandResponse) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_gateway_v1_edge_gateway_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExecuteCommandResponse.ProtoReflect.Descriptor instead.
func (*ExecuteCommandResponse) Descriptor() ([]byte, []int) {
return file_galaxy_gateway_v1_edge_gateway_proto_rawDescGZIP(), []int{1}
}
func (x *ExecuteCommandResponse) GetProtocolVersion() string {
if x != nil {
return x.ProtocolVersion
}
return ""
}
func (x *ExecuteCommandResponse) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
func (x *ExecuteCommandResponse) GetTimestampMs() int64 {
if x != nil {
return x.TimestampMs
}
return 0
}
func (x *ExecuteCommandResponse) GetResultCode() string {
if x != nil {
return x.ResultCode
}
return ""
}
func (x *ExecuteCommandResponse) GetPayloadBytes() []byte {
if x != nil {
return x.PayloadBytes
}
return nil
}
func (x *ExecuteCommandResponse) GetPayloadHash() []byte {
if x != nil {
return x.PayloadHash
}
return nil
}
func (x *ExecuteCommandResponse) GetSignature() []byte {
if x != nil {
return x.Signature
}
return nil
}
type SubscribeEventsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// protocol_version identifies the request envelope version. The gateway
// accepts only the literal "v1" after required-field validation succeeds.
ProtocolVersion string `protobuf:"bytes,1,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"`
DeviceSessionId string `protobuf:"bytes,2,opt,name=device_session_id,json=deviceSessionId,proto3" json:"device_session_id,omitempty"`
MessageType string `protobuf:"bytes,3,opt,name=message_type,json=messageType,proto3" json:"message_type,omitempty"`
TimestampMs int64 `protobuf:"varint,4,opt,name=timestamp_ms,json=timestampMs,proto3" json:"timestamp_ms,omitempty"`
RequestId string `protobuf:"bytes,5,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
// payload_hash is the raw 32-byte SHA-256 digest of payload_bytes. Empty
// payloads must use the SHA-256 digest of the empty byte slice.
PayloadHash []byte `protobuf:"bytes,6,opt,name=payload_hash,json=payloadHash,proto3" json:"payload_hash,omitempty"`
Signature []byte `protobuf:"bytes,7,opt,name=signature,proto3" json:"signature,omitempty"`
PayloadBytes []byte `protobuf:"bytes,8,opt,name=payload_bytes,json=payloadBytes,proto3" json:"payload_bytes,omitempty"`
TraceId string `protobuf:"bytes,9,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SubscribeEventsRequest) Reset() {
*x = SubscribeEventsRequest{}
mi := &file_galaxy_gateway_v1_edge_gateway_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SubscribeEventsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SubscribeEventsRequest) ProtoMessage() {}
func (x *SubscribeEventsRequest) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_gateway_v1_edge_gateway_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SubscribeEventsRequest.ProtoReflect.Descriptor instead.
func (*SubscribeEventsRequest) Descriptor() ([]byte, []int) {
return file_galaxy_gateway_v1_edge_gateway_proto_rawDescGZIP(), []int{2}
}
func (x *SubscribeEventsRequest) GetProtocolVersion() string {
if x != nil {
return x.ProtocolVersion
}
return ""
}
func (x *SubscribeEventsRequest) GetDeviceSessionId() string {
if x != nil {
return x.DeviceSessionId
}
return ""
}
func (x *SubscribeEventsRequest) GetMessageType() string {
if x != nil {
return x.MessageType
}
return ""
}
func (x *SubscribeEventsRequest) GetTimestampMs() int64 {
if x != nil {
return x.TimestampMs
}
return 0
}
func (x *SubscribeEventsRequest) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
func (x *SubscribeEventsRequest) GetPayloadHash() []byte {
if x != nil {
return x.PayloadHash
}
return nil
}
func (x *SubscribeEventsRequest) GetSignature() []byte {
if x != nil {
return x.Signature
}
return nil
}
func (x *SubscribeEventsRequest) GetPayloadBytes() []byte {
if x != nil {
return x.PayloadBytes
}
return nil
}
func (x *SubscribeEventsRequest) GetTraceId() string {
if x != nil {
return x.TraceId
}
return ""
}
type GatewayEvent struct {
state protoimpl.MessageState `protogen:"open.v1"`
EventType string `protobuf:"bytes,1,opt,name=event_type,json=eventType,proto3" json:"event_type,omitempty"`
EventId string `protobuf:"bytes,2,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"`
TimestampMs int64 `protobuf:"varint,3,opt,name=timestamp_ms,json=timestampMs,proto3" json:"timestamp_ms,omitempty"`
PayloadBytes []byte `protobuf:"bytes,4,opt,name=payload_bytes,json=payloadBytes,proto3" json:"payload_bytes,omitempty"`
PayloadHash []byte `protobuf:"bytes,5,opt,name=payload_hash,json=payloadHash,proto3" json:"payload_hash,omitempty"`
Signature []byte `protobuf:"bytes,6,opt,name=signature,proto3" json:"signature,omitempty"`
RequestId string `protobuf:"bytes,7,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
TraceId string `protobuf:"bytes,8,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GatewayEvent) Reset() {
*x = GatewayEvent{}
mi := &file_galaxy_gateway_v1_edge_gateway_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GatewayEvent) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GatewayEvent) ProtoMessage() {}
func (x *GatewayEvent) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_gateway_v1_edge_gateway_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GatewayEvent.ProtoReflect.Descriptor instead.
func (*GatewayEvent) Descriptor() ([]byte, []int) {
return file_galaxy_gateway_v1_edge_gateway_proto_rawDescGZIP(), []int{3}
}
func (x *GatewayEvent) GetEventType() string {
if x != nil {
return x.EventType
}
return ""
}
func (x *GatewayEvent) GetEventId() string {
if x != nil {
return x.EventId
}
return ""
}
func (x *GatewayEvent) GetTimestampMs() int64 {
if x != nil {
return x.TimestampMs
}
return 0
}
func (x *GatewayEvent) GetPayloadBytes() []byte {
if x != nil {
return x.PayloadBytes
}
return nil
}
func (x *GatewayEvent) GetPayloadHash() []byte {
if x != nil {
return x.PayloadHash
}
return nil
}
func (x *GatewayEvent) GetSignature() []byte {
if x != nil {
return x.Signature
}
return nil
}
func (x *GatewayEvent) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
func (x *GatewayEvent) GetTraceId() string {
if x != nil {
return x.TraceId
}
return ""
}
var File_galaxy_gateway_v1_edge_gateway_proto protoreflect.FileDescriptor
const file_galaxy_gateway_v1_edge_gateway_proto_rawDesc = "" +
"\n" +
"$galaxy/gateway/v1/edge_gateway.proto\x12\x11galaxy.gateway.v1\x1a\x1bbuf/validate/validate.proto\"\x9c\x03\n" +
"\x15ExecuteCommandRequest\x122\n" +
"\x10protocol_version\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x0fprotocolVersion\x123\n" +
"\x11device_session_id\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x0fdeviceSessionId\x12*\n" +
"\fmessage_type\x18\x03 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\vmessageType\x12*\n" +
"\ftimestamp_ms\x18\x04 \x01(\x03B\a\xbaH\x04\"\x02 \x00R\vtimestampMs\x12&\n" +
"\n" +
"request_id\x18\x05 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\trequestId\x12,\n" +
"\rpayload_bytes\x18\x06 \x01(\fB\a\xbaH\x04z\x02\x10\x01R\fpayloadBytes\x12*\n" +
"\fpayload_hash\x18\a \x01(\fB\a\xbaH\x04z\x02\x10\x01R\vpayloadHash\x12%\n" +
"\tsignature\x18\b \x01(\fB\a\xbaH\x04z\x02\x10\x01R\tsignature\x12\x19\n" +
"\btrace_id\x18\t \x01(\tR\atraceId\"\x8c\x02\n" +
"\x16ExecuteCommandResponse\x12)\n" +
"\x10protocol_version\x18\x01 \x01(\tR\x0fprotocolVersion\x12\x1d\n" +
"\n" +
"request_id\x18\x02 \x01(\tR\trequestId\x12!\n" +
"\ftimestamp_ms\x18\x03 \x01(\x03R\vtimestampMs\x12\x1f\n" +
"\vresult_code\x18\x04 \x01(\tR\n" +
"resultCode\x12#\n" +
"\rpayload_bytes\x18\x05 \x01(\fR\fpayloadBytes\x12!\n" +
"\fpayload_hash\x18\x06 \x01(\fR\vpayloadHash\x12\x1c\n" +
"\tsignature\x18\a \x01(\fR\tsignature\"\x94\x03\n" +
"\x16SubscribeEventsRequest\x122\n" +
"\x10protocol_version\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x0fprotocolVersion\x123\n" +
"\x11device_session_id\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x0fdeviceSessionId\x12*\n" +
"\fmessage_type\x18\x03 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\vmessageType\x12*\n" +
"\ftimestamp_ms\x18\x04 \x01(\x03B\a\xbaH\x04\"\x02 \x00R\vtimestampMs\x12&\n" +
"\n" +
"request_id\x18\x05 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\trequestId\x12*\n" +
"\fpayload_hash\x18\x06 \x01(\fB\a\xbaH\x04z\x02\x10\x01R\vpayloadHash\x12%\n" +
"\tsignature\x18\a \x01(\fB\a\xbaH\x04z\x02\x10\x01R\tsignature\x12#\n" +
"\rpayload_bytes\x18\b \x01(\fR\fpayloadBytes\x12\x19\n" +
"\btrace_id\x18\t \x01(\tR\atraceId\"\x8b\x02\n" +
"\fGatewayEvent\x12\x1d\n" +
"\n" +
"event_type\x18\x01 \x01(\tR\teventType\x12\x19\n" +
"\bevent_id\x18\x02 \x01(\tR\aeventId\x12!\n" +
"\ftimestamp_ms\x18\x03 \x01(\x03R\vtimestampMs\x12#\n" +
"\rpayload_bytes\x18\x04 \x01(\fR\fpayloadBytes\x12!\n" +
"\fpayload_hash\x18\x05 \x01(\fR\vpayloadHash\x12\x1c\n" +
"\tsignature\x18\x06 \x01(\fR\tsignature\x12\x1d\n" +
"\n" +
"request_id\x18\a \x01(\tR\trequestId\x12\x19\n" +
"\btrace_id\x18\b \x01(\tR\atraceId2\xd5\x01\n" +
"\vEdgeGateway\x12e\n" +
"\x0eExecuteCommand\x12(.galaxy.gateway.v1.ExecuteCommandRequest\x1a).galaxy.gateway.v1.ExecuteCommandResponse\x12_\n" +
"\x0fSubscribeEvents\x12).galaxy.gateway.v1.SubscribeEventsRequest\x1a\x1f.galaxy.gateway.v1.GatewayEvent0\x01B2Z0galaxy/gateway/proto/galaxy/gateway/v1;gatewayv1b\x06proto3"
var (
file_galaxy_gateway_v1_edge_gateway_proto_rawDescOnce sync.Once
file_galaxy_gateway_v1_edge_gateway_proto_rawDescData []byte
)
func file_galaxy_gateway_v1_edge_gateway_proto_rawDescGZIP() []byte {
file_galaxy_gateway_v1_edge_gateway_proto_rawDescOnce.Do(func() {
file_galaxy_gateway_v1_edge_gateway_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_galaxy_gateway_v1_edge_gateway_proto_rawDesc), len(file_galaxy_gateway_v1_edge_gateway_proto_rawDesc)))
})
return file_galaxy_gateway_v1_edge_gateway_proto_rawDescData
}
var file_galaxy_gateway_v1_edge_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
var file_galaxy_gateway_v1_edge_gateway_proto_goTypes = []any{
(*ExecuteCommandRequest)(nil), // 0: galaxy.gateway.v1.ExecuteCommandRequest
(*ExecuteCommandResponse)(nil), // 1: galaxy.gateway.v1.ExecuteCommandResponse
(*SubscribeEventsRequest)(nil), // 2: galaxy.gateway.v1.SubscribeEventsRequest
(*GatewayEvent)(nil), // 3: galaxy.gateway.v1.GatewayEvent
}
var file_galaxy_gateway_v1_edge_gateway_proto_depIdxs = []int32{
0, // 0: galaxy.gateway.v1.EdgeGateway.ExecuteCommand:input_type -> galaxy.gateway.v1.ExecuteCommandRequest
2, // 1: galaxy.gateway.v1.EdgeGateway.SubscribeEvents:input_type -> galaxy.gateway.v1.SubscribeEventsRequest
1, // 2: galaxy.gateway.v1.EdgeGateway.ExecuteCommand:output_type -> galaxy.gateway.v1.ExecuteCommandResponse
3, // 3: galaxy.gateway.v1.EdgeGateway.SubscribeEvents:output_type -> galaxy.gateway.v1.GatewayEvent
2, // [2:4] is the sub-list for method output_type
0, // [0:2] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_galaxy_gateway_v1_edge_gateway_proto_init() }
func file_galaxy_gateway_v1_edge_gateway_proto_init() {
if File_galaxy_gateway_v1_edge_gateway_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_galaxy_gateway_v1_edge_gateway_proto_rawDesc), len(file_galaxy_gateway_v1_edge_gateway_proto_rawDesc)),
NumEnums: 0,
NumMessages: 4,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_galaxy_gateway_v1_edge_gateway_proto_goTypes,
DependencyIndexes: file_galaxy_gateway_v1_edge_gateway_proto_depIdxs,
MessageInfos: file_galaxy_gateway_v1_edge_gateway_proto_msgTypes,
}.Build()
File_galaxy_gateway_v1_edge_gateway_proto = out.File
file_galaxy_gateway_v1_edge_gateway_proto_goTypes = nil
file_galaxy_gateway_v1_edge_gateway_proto_depIdxs = nil
}
@@ -0,0 +1,64 @@
syntax = "proto3";
package galaxy.gateway.v1;
option go_package = "galaxy/gateway/proto/galaxy/gateway/v1;gatewayv1";
import "buf/validate/validate.proto";
service EdgeGateway {
rpc ExecuteCommand(ExecuteCommandRequest) returns (ExecuteCommandResponse);
rpc SubscribeEvents(SubscribeEventsRequest) returns (stream GatewayEvent);
}
message ExecuteCommandRequest {
// protocol_version identifies the request envelope version. The gateway
// accepts only the literal "v1" after required-field validation succeeds.
string protocol_version = 1 [(buf.validate.field).string.min_len = 1];
string device_session_id = 2 [(buf.validate.field).string.min_len = 1];
string message_type = 3 [(buf.validate.field).string.min_len = 1];
int64 timestamp_ms = 4 [(buf.validate.field).int64.gt = 0];
string request_id = 5 [(buf.validate.field).string.min_len = 1];
bytes payload_bytes = 6 [(buf.validate.field).bytes.min_len = 1];
// payload_hash is the raw 32-byte SHA-256 digest of payload_bytes.
bytes payload_hash = 7 [(buf.validate.field).bytes.min_len = 1];
bytes signature = 8 [(buf.validate.field).bytes.min_len = 1];
string trace_id = 9;
}
message ExecuteCommandResponse {
string protocol_version = 1;
string request_id = 2;
int64 timestamp_ms = 3;
string result_code = 4;
bytes payload_bytes = 5;
bytes payload_hash = 6;
bytes signature = 7;
}
message SubscribeEventsRequest {
// protocol_version identifies the request envelope version. The gateway
// accepts only the literal "v1" after required-field validation succeeds.
string protocol_version = 1 [(buf.validate.field).string.min_len = 1];
string device_session_id = 2 [(buf.validate.field).string.min_len = 1];
string message_type = 3 [(buf.validate.field).string.min_len = 1];
int64 timestamp_ms = 4 [(buf.validate.field).int64.gt = 0];
string request_id = 5 [(buf.validate.field).string.min_len = 1];
// payload_hash is the raw 32-byte SHA-256 digest of payload_bytes. Empty
// payloads must use the SHA-256 digest of the empty byte slice.
bytes payload_hash = 6 [(buf.validate.field).bytes.min_len = 1];
bytes signature = 7 [(buf.validate.field).bytes.min_len = 1];
bytes payload_bytes = 8;
string trace_id = 9;
}
message GatewayEvent {
string event_type = 1;
string event_id = 2;
int64 timestamp_ms = 3;
bytes payload_bytes = 4;
bytes payload_hash = 5;
bytes signature = 6;
string request_id = 7;
string trace_id = 8;
}
@@ -0,0 +1,163 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc (unknown)
// source: galaxy/gateway/v1/edge_gateway.proto
package gatewayv1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
EdgeGateway_ExecuteCommand_FullMethodName = "/galaxy.gateway.v1.EdgeGateway/ExecuteCommand"
EdgeGateway_SubscribeEvents_FullMethodName = "/galaxy.gateway.v1.EdgeGateway/SubscribeEvents"
)
// EdgeGatewayClient is the client API for EdgeGateway service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type EdgeGatewayClient interface {
ExecuteCommand(ctx context.Context, in *ExecuteCommandRequest, opts ...grpc.CallOption) (*ExecuteCommandResponse, error)
SubscribeEvents(ctx context.Context, in *SubscribeEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[GatewayEvent], error)
}
type edgeGatewayClient struct {
cc grpc.ClientConnInterface
}
func NewEdgeGatewayClient(cc grpc.ClientConnInterface) EdgeGatewayClient {
return &edgeGatewayClient{cc}
}
func (c *edgeGatewayClient) ExecuteCommand(ctx context.Context, in *ExecuteCommandRequest, opts ...grpc.CallOption) (*ExecuteCommandResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ExecuteCommandResponse)
err := c.cc.Invoke(ctx, EdgeGateway_ExecuteCommand_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *edgeGatewayClient) SubscribeEvents(ctx context.Context, in *SubscribeEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[GatewayEvent], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &EdgeGateway_ServiceDesc.Streams[0], EdgeGateway_SubscribeEvents_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[SubscribeEventsRequest, GatewayEvent]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type EdgeGateway_SubscribeEventsClient = grpc.ServerStreamingClient[GatewayEvent]
// EdgeGatewayServer is the server API for EdgeGateway service.
// All implementations must embed UnimplementedEdgeGatewayServer
// for forward compatibility.
type EdgeGatewayServer interface {
ExecuteCommand(context.Context, *ExecuteCommandRequest) (*ExecuteCommandResponse, error)
SubscribeEvents(*SubscribeEventsRequest, grpc.ServerStreamingServer[GatewayEvent]) error
mustEmbedUnimplementedEdgeGatewayServer()
}
// UnimplementedEdgeGatewayServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedEdgeGatewayServer struct{}
func (UnimplementedEdgeGatewayServer) ExecuteCommand(context.Context, *ExecuteCommandRequest) (*ExecuteCommandResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ExecuteCommand not implemented")
}
func (UnimplementedEdgeGatewayServer) SubscribeEvents(*SubscribeEventsRequest, grpc.ServerStreamingServer[GatewayEvent]) error {
return status.Error(codes.Unimplemented, "method SubscribeEvents not implemented")
}
func (UnimplementedEdgeGatewayServer) mustEmbedUnimplementedEdgeGatewayServer() {}
func (UnimplementedEdgeGatewayServer) testEmbeddedByValue() {}
// UnsafeEdgeGatewayServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to EdgeGatewayServer will
// result in compilation errors.
type UnsafeEdgeGatewayServer interface {
mustEmbedUnimplementedEdgeGatewayServer()
}
func RegisterEdgeGatewayServer(s grpc.ServiceRegistrar, srv EdgeGatewayServer) {
// If the following call panics, it indicates UnimplementedEdgeGatewayServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&EdgeGateway_ServiceDesc, srv)
}
func _EdgeGateway_ExecuteCommand_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ExecuteCommandRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(EdgeGatewayServer).ExecuteCommand(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: EdgeGateway_ExecuteCommand_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(EdgeGatewayServer).ExecuteCommand(ctx, req.(*ExecuteCommandRequest))
}
return interceptor(ctx, in, info, handler)
}
func _EdgeGateway_SubscribeEvents_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(SubscribeEventsRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(EdgeGatewayServer).SubscribeEvents(m, &grpc.GenericServerStream[SubscribeEventsRequest, GatewayEvent]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type EdgeGateway_SubscribeEventsServer = grpc.ServerStreamingServer[GatewayEvent]
// EdgeGateway_ServiceDesc is the grpc.ServiceDesc for EdgeGateway service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var EdgeGateway_ServiceDesc = grpc.ServiceDesc{
ServiceName: "galaxy.gateway.v1.EdgeGateway",
HandlerType: (*EdgeGatewayServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ExecuteCommand",
Handler: _EdgeGateway_ExecuteCommand_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "SubscribeEvents",
Handler: _EdgeGateway_SubscribeEvents_Handler,
ServerStreams: true,
},
},
Metadata: "galaxy/gateway/v1/edge_gateway.proto",
}
+123 -1
View File
@@ -1,43 +1,165 @@
buf.build/go/hyperpb v0.1.3/go.mod h1:IHXAM5qnS0/Fsnd7/HGDghFNvUET646WoHmq1FDZXIE=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/jackmordaunt/icns/v2 v2.2.6/go.mod h1:DqlVnR5iafSphrId7aSD06r3jg0KRC9V6lEBBp504ZQ=
github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk=
github.com/josephspurrier/goversioninfo v1.4.0/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lucor/goinfo v0.9.0/go.mod h1:L6m6tN5Rlova5Z83h1ZaKsMP1iiaoZ9vGTNzu5QKOD4=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c h1:7ACFcSaQsrWtrH4WHHfUqE1C+f8r2uv8KGaW0jTNjus=
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c/go.mod h1:JKox4Gszkxt57kj27u7rvi7IFoIULvCZHUsBTUmQM/s=
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b h1:vivRhVUAa9t1q0Db4ZmezBP8pWQWnXHFokZj0AOea2g=
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/timandy/routine v1.1.6/go.mod h1:kXslgIosdY8LW0byTyPnenDgn4/azt2euufAq9rK51w=
github.com/urfave/cli/v2 v2.4.0/go.mod h1:NX9W0zmTvedE5oDoOMs2RTC8RvdK98NTYZE5LbaEYPg=
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
golang.org/x/tools/go/vcs v0.1.0-deprecated/go.mod h1:zUrvATBAvEI9535oC0yWYsLsHIV4Z7g63sNPVMtuBy8=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+9
View File
@@ -0,0 +1,9 @@
// gateway contains shared FlatBuffers payloads used by the gateway edge
// transport.
namespace gateway;
table ServerTimeEvent {
server_time_ms:int64;
}
root_type ServerTimeEvent;
+64
View File
@@ -0,0 +1,64 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package gateway
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type ServerTimeEvent struct {
_tab flatbuffers.Table
}
func GetRootAsServerTimeEvent(buf []byte, offset flatbuffers.UOffsetT) *ServerTimeEvent {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &ServerTimeEvent{}
x.Init(buf, n+offset)
return x
}
func FinishServerTimeEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsServerTimeEvent(buf []byte, offset flatbuffers.UOffsetT) *ServerTimeEvent {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &ServerTimeEvent{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedServerTimeEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *ServerTimeEvent) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *ServerTimeEvent) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *ServerTimeEvent) ServerTimeMs() int64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.GetInt64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *ServerTimeEvent) MutateServerTimeMs(n int64) bool {
return rcv._tab.MutateInt64Slot(4, n)
}
func ServerTimeEventStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func ServerTimeEventAddServerTimeMs(builder *flatbuffers.Builder, serverTimeMs int64) {
builder.PrependInt64Slot(0, serverTimeMs, 0)
}
func ServerTimeEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}