Skip to content

Repository files navigation

Blade

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.

Features

  • 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 /api prefix
  • Maintenance Mode - Returns 503 globally or per configured services

Configuration

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 Mode

[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.

Environment Variables

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)

Service Discovery

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.

L4 Relay Nodes

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.

Special Routes Configuration

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 & Run

Local

# Build
go build -o gateway ./cmd/main.go

# Run
./gateway

# Or with custom config
CONFIG_PATH=./configs/config.toml ./gateway

Relay Node

# Build
go build -o relay ./cmd/relay

# Run (needs relay.listen; :443 requires privileges)
RELAY_CONFIG_PATH=./configs/relay.toml ./relay

Docker

# 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

Endpoints

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

Health Checks

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" }
}

WebSocket Authentication Notes

Current implementation follows DysonTokenAuthHandler behavior:

  • Token extraction order: tk query, Authorization header (Bearer, AtField, AkField), AuthToken cookie
  • Token validation: remote gRPC call to DyAuthService/Authenticate using the configured websocket.authService target
  • Request IP is forwarded to auth service as ip_address

Request Flow

Client Request
    ↓
Rate Limiter (120 req/min/IP)
    ↓
Readiness Middleware (503 if core services unhealthy)
    ↓
Reverse Proxy (routes based on path)
    ↓
Backend Service

About

The next generation API gateway designed for Solar Network

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages