An API Gateway built in Go using Gin, ported from the .NET YARP-based gateway.
In fact, it's not the next generation gateway, it's the prev generation. Because Solar Network is built by pure Go at the v2, and migrated to .NET at v3, now we planned to move some core services to Go again.
- Reverse Proxy Routing - Routes requests to backend microservices
- Health Monitoring - Background health checks every 10 seconds
- Service Discovery - Redis-backed leased instance registry over gRPC
- L4 Relays - Optional TCP relay nodes (
cmd/relay) that route by TLS SNI without terminating TLS - Readiness Gating - Returns 503 if core services are unhealthy
- CORS Support - Allows all origins with custom headers
- Special Routes - Fully configurable route system via
routes - Route Transforms - Strips service prefix, adds
/apiprefix - Maintenance Mode - Returns 503 globally or per configured services
Edit configs/config.toml:
siteUrl = "https://solian.app"
[cache]
serializer = "JSON"
[services]
ring = { http = "http://ring:5000", grpc = "ring:5001" }
pass = { http = "http://pass:5000", grpc = "pass:5001" }
drive = { http = "http://drive:5000", grpc = "drive:5001" }
sphere = { http = "http://sphere:5000", grpc = "sphere:5001" }
develop = { http = "http://develop:5000", grpc = "develop:5001" }
insight = { http = "http://insight:5000", grpc = "insight:5001" }
zone = { http = "http://zone:5000", grpc = "zone:5001" }
messager = { http = "http://messager:5000", grpc = "messager:5001" }
wallet = { http = "http://wallet:5000", grpc = "wallet:5001" }
ideask = { http = "http://ideask:5000", grpc = "ideask:5001" }
[endpoints]
serviceNames = ["ring", "pass", "drive", "sphere", "develop", "insight", "zone", "messager", "wallet", "ideask"]
coreServiceNames = ["ring", "pass", "drive", "sphere"]
[rateLimit]
requestsPerMinute = 120
burstAllowance = 10
[health]
checkIntervalSeconds = 10
[discovery]
enabled = true
prefix = "blade:discovery"
leaseSeconds = 30
leaderLeaseSeconds = 15
registrationToken = "replace-with-a-service-secret"
relayServiceName = "relay"
[server]
port = "6000"
readTimeout = 60
writeTimeout = 60
[websocket]
enabled = true
path = "/ws"
keepAliveSeconds = 60
maxMessageBytes = 4096
[maintenance]
enabled = false
mode = "full" # "full" or "service"
services = [] # used when mode = "service"[maintenance]
enabled = true
mode = "full" # blocks all proxied requests with 503[maintenance]
enabled = true
mode = "service" # blocks only listed services
services = ["sphere", "drive"]Legacy key maintaince is also supported for backward compatibility.
| Variable | Description | Default |
|---|---|---|
CONFIG_PATH |
Path to config file | configs/config.toml |
RELAY_CONFIG_PATH |
Path to relay config file | configs/relay.toml |
GIN_MODE |
debug or release |
debug |
ZEROLOG_PRETTY |
Enable pretty logging | false |
LOG_LEVEL |
Log level (debug, info, warn, error) |
info (debug when pretty) |
When discovery.enabled is set, Blade requires cache.redisUrl and a non-empty
discovery.registrationToken. Services register an HTTP/gRPC endpoint through
DyServiceDiscoveryService/Register, then renew the lease before it expires.
Only the Redis-elected Blade replica probes registered HTTP endpoints; all
replicas read the same healthy instance set for proxy routing. Send the service
secret as authorization: Bearer <registrationToken> for registration, renewal,
and deregistration calls.
Configured [services] targets remain a fallback until that service has a
registered instance, allowing incremental migration.
cmd/relay is a separate binary (image blade-relay, config
configs/relay.toml, path overridable with RELAY_CONFIG_PATH) that accepts
TCP connections, peeks the cleartext TLS ClientHello for the SNI, and copies
bytes to an upstream chosen from a static allowlist. TLS is never terminated:
certificates, client certificates, and ECH-less SNI routing all stay
end-to-end. Unlisted SNI is rejected unless relay.defaultUpstream is set.
Relays are deployed outside the cluster network, so they announce themselves
over the gateway's public HTTPS entry — PUT <discovery.url>/relays/{id}
with discovery.registrationToken — publishing a tcp endpoint
(relay.publicHost:relay.publicPort), a region, and a weight. The heartbeat
carries the relay's own health report: the elected checker never dials a relay,
because it cannot reach one, and a relay that stops reporting expires out of
the catalog. Blade serves the resulting list at GET /relays:
{"relays":[{"id":"jp-01","endpoint":"relay-jp.solian.app","port":443,"region":"jp","weight":1,"healthy":true}]}GET /relays returns 503 when discovery.enabled is false. The gateway owns
the registry service name (discovery.relayServiceName, default relay).
Those control routes are mounted ahead of the readiness gate so a relay can
register while core services are unhealthy. See
RELAY_DEPLOYMENT.md for the node's deployment,
configuration reference, and operations.
The gateway supports fully configurable special routes:
[[routes]]
path = "/.well-known/openid-configuration"
service = "pass"
target = "/auth/.well-known/openid-configuration"
prefix = false
[[routes]]
path = "/activitypub"
service = "sphere"
target = "/activitypub"
prefix = true # true for wildcard matching| Field | Description |
|---|---|
path |
Source path to match (e.g., /ws, /.well-known/openid-configuration) |
service |
Target service name |
target |
Path on the backend service |
prefix |
If true, match path as prefix (e.g., /activitypub/**) |
# Build
go build -o gateway ./cmd/main.go
# Run
./gateway
# Or with custom config
CONFIG_PATH=./configs/config.toml ./gateway# Build
go build -o relay ./cmd/relay
# Run (needs relay.listen; :443 requires privileges)
RELAY_CONFIG_PATH=./configs/relay.toml ./relay# Build
docker build -t dyson-gateway .
docker build -f Dockerfile.relay -t blade-relay .
# Run
docker run -p 6000:6000 dyson-gateway
docker run -p 443:443 blade-relay
# Run with custom config
docker run -p 6000:6000 -v ./config.toml:/app/configs/config.toml dyson-gateway
docker run -p 443:443 -v ./relay.toml:/app/configs/relay.toml blade-relay| Endpoint | Description |
|---|---|
GET /health |
Gateway health document (application/health+json, never gated) |
GET /health/{service} |
Per-service health document (200 pass, 503 fail, 404 unknown) |
GET /relays |
Catalog of registered L4 relay nodes (503 when discovery is off) |
/<service>/** |
Proxied to backend service (e.g., /ring/** → ring:5000/api/**) |
/ws |
Native WebSocket gateway (configurable via websocket.path) |
/.well-known/* |
.well-known endpoints (configurable via routes) |
/activitypub/** |
ActivityPub (configurable via routes) |
/swagger/<service>/** |
Swagger docs → service |
gRPC DyServiceDiscoveryService |
Register, renew, remove, and resolve service instances |
GET /health answers with a health check document in the
health+json
format and is mounted ahead of the readiness gate, so it reports why the
gateway is not ready instead of the gate's generic 503. status is fail
(HTTP 503) when any core service is unhealthy, warn (HTTP 200) when only
non-core services are, and pass (HTTP 200) otherwise. Each tracked service
appears under checks as a one-element array.
{
"status": "warn",
"serviceId": "blade",
"description": "Solar Network API gateway",
"output": "one or more non-core services are unhealthy",
"checks": {
"ring": [
{
"componentId": "ring",
"componentType": "component",
"status": "pass",
"time": "2026-10-04T11:42:16Z",
"links": { "self": "https://api.solian.app/health/ring" }
}
],
"sphere": [
{
"componentId": "sphere",
"componentType": "component",
"status": "fail",
"time": "2026-10-04T11:42:16Z"
}
]
},
"links": {
"self": "https://api.solian.app/health"
}
}Cache-Control: max-age mirrors health.checkIntervalSeconds, the period at
which the aggregator refreshes the snapshot.
GET /health/{service} serves the same document scoped to one service — it is
the target a status page polls, and each checks entry publishes it as its
links.self, so the roster can be discovered from the aggregate document
alone. An unknown service is a failing document answered with 404.
{
"status": "fail",
"serviceId": "blade",
"output": "service \"sphere\" is unhealthy",
"checks": {
"sphere": [
{
"componentId": "sphere",
"componentType": "component",
"status": "fail",
"time": "2026-10-04T04:07:13Z",
"links": { "self": "https://api.solian.app/health/sphere" }
}
]
},
"links": { "self": "https://api.solian.app/health/sphere" }
}Current implementation follows DysonTokenAuthHandler behavior:
- Token extraction order:
tkquery,Authorizationheader (Bearer,AtField,AkField),AuthTokencookie - Token validation: remote gRPC call to
DyAuthService/Authenticateusing the configuredwebsocket.authServicetarget - Request IP is forwarded to auth service as
ip_address
Client Request
↓
Rate Limiter (120 req/min/IP)
↓
Readiness Middleware (503 if core services unhealthy)
↓
Reverse Proxy (routes based on path)
↓
Backend Service