feat: edge gateway service
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
.codex
|
||||
.vscode/
|
||||
artifacts/
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
# Generated by buf. DO NOT EDIT.
|
||||
version: v2
|
||||
deps:
|
||||
- name: buf.build/bufbuild/protovalidate
|
||||
commit: 80ab13bee0bf4272b6161a72bf7034e0
|
||||
digest: b5:1aa6a965be5d02d64e1d81954fa2e78ef9d1e33a0c30f92bc2626039006a94deb3a5b05f14ed8893f5c3ffce444ac008f7e968188ad225c4c29c813aa5f2daa1
|
||||
@@ -0,0 +1,15 @@
|
||||
version: v2
|
||||
|
||||
modules:
|
||||
- path: proto
|
||||
|
||||
deps:
|
||||
- buf.build/bufbuild/protovalidate
|
||||
|
||||
lint:
|
||||
use:
|
||||
- STANDARD
|
||||
|
||||
breaking:
|
||||
use:
|
||||
- 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[:]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`)
|
||||
}
|
||||
@@ -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{}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{}
|
||||
@@ -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{}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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=
|
||||
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user