feat(api): add public API gateway with rate limiting
- Create API gateway service with Express.js - Implement sliding window rate limiting via Redis - Add API key management with tiered access (free/developer/enterprise) - Track usage analytics per key and globally - Add RPC proxy to blockchain nodes - Configure Docker Compose with api-gateway and redis services - Free tier: 100 req/min, Developer: 1000 req/min, Enterprise: unlimited
This commit is contained in:
parent
d121759d2c
commit
4a2825f516
8 changed files with 933 additions and 0 deletions
35
apps/api-gateway/Dockerfile
Normal file
35
apps/api-gateway/Dockerfile
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
# Build TypeScript
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json ./
|
||||
|
||||
# Install production dependencies only
|
||||
RUN npm install --production
|
||||
|
||||
# Copy built files
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3100
|
||||
|
||||
EXPOSE 3100
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
223
apps/api-gateway/README.md
Normal file
223
apps/api-gateway/README.md
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
# Synor Public API Gateway
|
||||
|
||||
Rate-limited, authenticated access to Synor blockchain RPC.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Start with API profile
|
||||
docker compose -f docker-compose.testnet.yml --profile api up -d
|
||||
|
||||
# Test the API
|
||||
curl http://localhost:17400/health
|
||||
|
||||
# Make an RPC call (anonymous - 100 req/min)
|
||||
curl -X POST http://localhost:17400/rpc \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","method":"synor_getBlockCount","params":[],"id":1}'
|
||||
```
|
||||
|
||||
## Rate Limit Tiers
|
||||
|
||||
| Tier | Rate Limit | Price | Features |
|
||||
|------------|---------------|----------|-----------------------------|
|
||||
| Free | 100 req/min | $0 | Anonymous or API key |
|
||||
| Developer | 1000 req/min | $49/mo | API key + analytics |
|
||||
| Enterprise | Unlimited | Custom | SLA, dedicated support |
|
||||
|
||||
## Authentication
|
||||
|
||||
### API Key Header
|
||||
```bash
|
||||
curl -X POST http://localhost:17400/rpc \
|
||||
-H "Authorization: Bearer sk_developer_abc123..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","method":"synor_getBlockCount","params":[],"id":1}'
|
||||
```
|
||||
|
||||
### X-API-Key Header
|
||||
```bash
|
||||
curl -X POST http://localhost:17400/rpc \
|
||||
-H "X-API-Key: sk_developer_abc123..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","method":"synor_getBlockCount","params":[],"id":1}'
|
||||
```
|
||||
|
||||
### Query Parameter
|
||||
```bash
|
||||
curl -X POST "http://localhost:17400/rpc?api_key=sk_developer_abc123..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","method":"synor_getBlockCount","params":[],"id":1}'
|
||||
```
|
||||
|
||||
## Rate Limit Headers
|
||||
|
||||
All RPC responses include rate limit information:
|
||||
|
||||
```
|
||||
X-RateLimit-Limit: 1000
|
||||
X-RateLimit-Remaining: 999
|
||||
X-RateLimit-Reset: 1704067260
|
||||
X-RateLimit-Tier: developer
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Public Endpoints
|
||||
|
||||
#### Health Check
|
||||
```
|
||||
GET /health
|
||||
```
|
||||
Returns service health status.
|
||||
|
||||
#### JSON-RPC Proxy
|
||||
```
|
||||
POST /rpc
|
||||
```
|
||||
Proxies JSON-RPC requests to the Synor node.
|
||||
|
||||
### Admin Endpoints
|
||||
|
||||
Requires `Authorization: Bearer <ADMIN_KEY>` header.
|
||||
|
||||
#### Create API Key
|
||||
```
|
||||
POST /v1/keys
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"tier": "developer",
|
||||
"name": "My App"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"key": "sk_developer_abc123...",
|
||||
"tier": "developer",
|
||||
"name": "My App",
|
||||
"createdAt": 1704067200000,
|
||||
"lastUsed": 0,
|
||||
"requestCount": 0
|
||||
}
|
||||
```
|
||||
|
||||
#### List API Keys
|
||||
```
|
||||
GET /v1/keys
|
||||
```
|
||||
|
||||
#### Get Key Stats
|
||||
```
|
||||
GET /v1/keys/:key/stats
|
||||
```
|
||||
(Accessible by key owner or admin)
|
||||
|
||||
#### Revoke API Key
|
||||
```
|
||||
DELETE /v1/keys/:key
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
### Rate Limit Exceeded (429)
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"error": {
|
||||
"code": -32005,
|
||||
"message": "Rate limit exceeded",
|
||||
"data": {
|
||||
"retryAfter": 45,
|
||||
"tier": "free",
|
||||
"upgrade": "Upgrade to Developer tier for 1000 req/min"
|
||||
}
|
||||
},
|
||||
"id": null
|
||||
}
|
||||
```
|
||||
|
||||
### Invalid API Key (401)
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"error": {
|
||||
"code": -32001,
|
||||
"message": "Invalid API key"
|
||||
},
|
||||
"id": null
|
||||
}
|
||||
```
|
||||
|
||||
### RPC Node Unavailable (502)
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"error": {
|
||||
"code": -32603,
|
||||
"message": "RPC node unavailable"
|
||||
},
|
||||
"id": null
|
||||
}
|
||||
```
|
||||
|
||||
## Supported RPC Methods
|
||||
|
||||
### Chain Methods
|
||||
- `synor_getBlockCount` - Get current block height
|
||||
- `synor_getBlockHash` - Get block hash by height
|
||||
- `synor_getBlock` - Get block by hash
|
||||
- `synor_getDAGInfo` - Get DAG structure info
|
||||
- `synor_getChainInfo` - Get chain statistics
|
||||
|
||||
### Transaction Methods
|
||||
- `synor_sendRawTransaction` - Submit signed transaction
|
||||
- `synor_getTransaction` - Get transaction by ID
|
||||
- `synor_getMempool` - Get mempool transactions
|
||||
- `synor_estimateFee` - Estimate transaction fee
|
||||
|
||||
### Address Methods
|
||||
- `synor_getBalance` - Get address balance
|
||||
- `synor_getUtxos` - Get unspent outputs
|
||||
- `synor_getAddressTransactions` - Get address history
|
||||
|
||||
### Contract Methods
|
||||
- `synor_deployContract` - Deploy WASM contract
|
||||
- `synor_callContract` - Call contract method
|
||||
- `synor_getContractState` - Get contract storage
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|-------------|----------------------------|--------------------------|
|
||||
| PORT | 3100 | API gateway port |
|
||||
| REDIS_URL | redis://localhost:6379 | Redis connection URL |
|
||||
| RPC_TARGET | http://localhost:16110 | Backend RPC node URL |
|
||||
| ADMIN_KEY | admin-secret-key | Admin API key |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd apps/api-gateway
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
The API gateway is designed for horizontal scaling:
|
||||
|
||||
1. Deploy multiple API gateway instances behind a load balancer
|
||||
2. All instances share the same Redis for rate limiting
|
||||
3. Use Redis Cluster for high availability
|
||||
4. Set unique `ADMIN_KEY` per environment
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Always use HTTPS in production**
|
||||
2. **Rotate admin keys regularly**
|
||||
3. **Monitor for abuse patterns**
|
||||
4. **Set up alerting for high error rates**
|
||||
5. **Use Redis AUTH in production**
|
||||
30
apps/api-gateway/package.json
Normal file
30
apps/api-gateway/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "@synor/api-gateway",
|
||||
"version": "0.1.0",
|
||||
"description": "Public API gateway for Synor blockchain",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"ioredis": "^5.3.2",
|
||||
"uuid": "^9.0.0",
|
||||
"http-proxy-middleware": "^2.0.6",
|
||||
"helmet": "^7.1.0",
|
||||
"cors": "^2.8.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^1.0.0"
|
||||
}
|
||||
}
|
||||
198
apps/api-gateway/src/api-keys.ts
Normal file
198
apps/api-gateway/src/api-keys.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
/**
|
||||
* API Key management for Synor public API.
|
||||
*
|
||||
* Keys are stored in Redis with the format:
|
||||
* apikey:{key} -> { tier, name, createdAt, lastUsed }
|
||||
*/
|
||||
|
||||
// Use require for ioredis to avoid ESM import issues
|
||||
import type { Redis } from 'ioredis';
|
||||
import IORedisModule from 'ioredis';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const IORedis = (IORedisModule as any).default || IORedisModule;
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { RateLimitTier } from './rate-limiter.js';
|
||||
|
||||
export interface ApiKey {
|
||||
key: string;
|
||||
tier: RateLimitTier;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
lastUsed: number;
|
||||
requestCount: number;
|
||||
}
|
||||
|
||||
export interface CreateApiKeyOptions {
|
||||
tier: RateLimitTier;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class ApiKeyManager {
|
||||
private redis: Redis;
|
||||
|
||||
constructor(redisUrl: string) {
|
||||
this.redis = new IORedis(redisUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new API key.
|
||||
*/
|
||||
async createKey(options: CreateApiKeyOptions): Promise<ApiKey> {
|
||||
const key = `sk_${options.tier}_${uuidv4().replace(/-/g, '')}`;
|
||||
const now = Date.now();
|
||||
|
||||
const apiKey: ApiKey = {
|
||||
key,
|
||||
tier: options.tier,
|
||||
name: options.name,
|
||||
createdAt: now,
|
||||
lastUsed: 0,
|
||||
requestCount: 0,
|
||||
};
|
||||
|
||||
await this.redis.set(
|
||||
`apikey:${key}`,
|
||||
JSON.stringify(apiKey),
|
||||
'EX',
|
||||
365 * 24 * 60 * 60 // 1 year expiry
|
||||
);
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an API key and return its metadata.
|
||||
*/
|
||||
async validateKey(key: string): Promise<ApiKey | null> {
|
||||
const data = await this.redis.get(`apikey:${key}`);
|
||||
if (!data) return null;
|
||||
|
||||
const apiKey = JSON.parse(data) as ApiKey;
|
||||
|
||||
// Update last used timestamp
|
||||
const pipeline = this.redis.pipeline();
|
||||
pipeline.hset(`apikey:${key}:stats`, 'lastUsed', Date.now());
|
||||
pipeline.hincrby(`apikey:${key}:stats`, 'requestCount', 1);
|
||||
await pipeline.exec();
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key with usage statistics.
|
||||
*/
|
||||
async getKeyWithStats(key: string): Promise<(ApiKey & { stats: {
|
||||
requestsToday: number;
|
||||
requestsThisMonth: number;
|
||||
} }) | null> {
|
||||
const data = await this.redis.get(`apikey:${key}`);
|
||||
if (!data) return null;
|
||||
|
||||
const apiKey = JSON.parse(data) as ApiKey;
|
||||
const stats = await this.redis.hgetall(`apikey:${key}:stats`);
|
||||
|
||||
return {
|
||||
...apiKey,
|
||||
lastUsed: parseInt(stats.lastUsed || '0'),
|
||||
requestCount: parseInt(stats.requestCount || '0'),
|
||||
stats: {
|
||||
requestsToday: parseInt(stats.requestsToday || '0'),
|
||||
requestsThisMonth: parseInt(stats.requestsThisMonth || '0'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an API key.
|
||||
*/
|
||||
async revokeKey(key: string): Promise<boolean> {
|
||||
const deleted = await this.redis.del(`apikey:${key}`);
|
||||
await this.redis.del(`apikey:${key}:stats`);
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all API keys for an account (by prefix).
|
||||
*/
|
||||
async listKeys(pattern: string = 'apikey:*'): Promise<string[]> {
|
||||
const keys = await this.redis.keys(pattern);
|
||||
return keys.map((k: string) => k.replace('apikey:', ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record usage for analytics.
|
||||
*/
|
||||
async recordUsage(
|
||||
key: string,
|
||||
method: string,
|
||||
responseTime: number
|
||||
): Promise<void> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const month = today.substring(0, 7);
|
||||
|
||||
const pipeline = this.redis.pipeline();
|
||||
|
||||
// Per-key daily stats
|
||||
pipeline.hincrby(`usage:${key}:${today}`, 'requests', 1);
|
||||
pipeline.hincrby(`usage:${key}:${today}`, 'responseTime', responseTime);
|
||||
pipeline.expire(`usage:${key}:${today}`, 90 * 24 * 60 * 60); // 90 days
|
||||
|
||||
// Per-key monthly stats
|
||||
pipeline.hincrby(`usage:${key}:${month}`, 'requests', 1);
|
||||
pipeline.expire(`usage:${key}:${month}`, 365 * 24 * 60 * 60); // 1 year
|
||||
|
||||
// Method tracking
|
||||
pipeline.hincrby(`methods:${today}`, method, 1);
|
||||
pipeline.expire(`methods:${today}`, 90 * 24 * 60 * 60);
|
||||
|
||||
// Global stats
|
||||
pipeline.hincrby('stats:global', 'totalRequests', 1);
|
||||
pipeline.lpush('stats:latency', responseTime);
|
||||
pipeline.ltrim('stats:latency', 0, 999); // Keep last 1000
|
||||
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage analytics.
|
||||
*/
|
||||
async getAnalytics(key: string): Promise<{
|
||||
today: { requests: number; avgResponseTime: number };
|
||||
thisMonth: { requests: number };
|
||||
methods: Record<string, number>;
|
||||
}> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const month = today.substring(0, 7);
|
||||
|
||||
const pipeline = this.redis.pipeline();
|
||||
pipeline.hgetall(`usage:${key}:${today}`);
|
||||
pipeline.hgetall(`usage:${key}:${month}`);
|
||||
pipeline.hgetall(`methods:${today}`);
|
||||
|
||||
const results = await pipeline.exec();
|
||||
|
||||
const dailyStats = (results?.[0]?.[1] as Record<string, string>) || {};
|
||||
const monthlyStats = (results?.[1]?.[1] as Record<string, string>) || {};
|
||||
const methodStats = (results?.[2]?.[1] as Record<string, string>) || {};
|
||||
|
||||
const requests = parseInt(dailyStats.requests || '0');
|
||||
const totalTime = parseInt(dailyStats.responseTime || '0');
|
||||
|
||||
return {
|
||||
today: {
|
||||
requests,
|
||||
avgResponseTime: requests > 0 ? Math.round(totalTime / requests) : 0,
|
||||
},
|
||||
thisMonth: {
|
||||
requests: parseInt(monthlyStats.requests || '0'),
|
||||
},
|
||||
methods: Object.fromEntries(
|
||||
Object.entries(methodStats).map(([k, v]) => [k, parseInt(v)])
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.redis.quit();
|
||||
}
|
||||
}
|
||||
258
apps/api-gateway/src/index.ts
Normal file
258
apps/api-gateway/src/index.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
/**
|
||||
* Synor Public API Gateway
|
||||
*
|
||||
* Provides rate-limited, authenticated access to Synor blockchain RPC.
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /rpc - JSON-RPC proxy to node (requires API key)
|
||||
* - GET /health - Health check
|
||||
* - GET /v1/keys - List API keys (admin)
|
||||
* - POST /v1/keys - Create API key (admin)
|
||||
* - GET /v1/keys/:key/stats - Get usage stats
|
||||
* - DELETE /v1/keys/:key - Revoke API key (admin)
|
||||
* - GET /v1/analytics - Global analytics (admin)
|
||||
*/
|
||||
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import helmet from 'helmet';
|
||||
import cors from 'cors';
|
||||
import { RateLimiter, RateLimitTier } from './rate-limiter.js';
|
||||
import { ApiKeyManager } from './api-keys.js';
|
||||
|
||||
// Configuration
|
||||
const PORT = parseInt(process.env.PORT || '3100');
|
||||
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
|
||||
const RPC_TARGET = process.env.RPC_TARGET || 'http://localhost:16110';
|
||||
const ADMIN_KEY = process.env.ADMIN_KEY || 'admin-secret-key';
|
||||
|
||||
// Initialize services
|
||||
const rateLimiter = new RateLimiter(REDIS_URL);
|
||||
const apiKeyManager = new ApiKeyManager(REDIS_URL);
|
||||
|
||||
const app = express();
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Request timing middleware
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
(req as any).startTime = Date.now();
|
||||
next();
|
||||
});
|
||||
|
||||
// Health check (no auth required)
|
||||
app.get('/health', (req: Request, res: Response) => {
|
||||
res.json({ status: 'healthy', timestamp: Date.now() });
|
||||
});
|
||||
|
||||
// API key extraction middleware
|
||||
const extractApiKey = (req: Request): string | null => {
|
||||
// Check Authorization header
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
|
||||
// Check X-API-Key header
|
||||
const apiKeyHeader = req.headers['x-api-key'];
|
||||
if (typeof apiKeyHeader === 'string') {
|
||||
return apiKeyHeader;
|
||||
}
|
||||
|
||||
// Check query parameter
|
||||
if (typeof req.query.api_key === 'string') {
|
||||
return req.query.api_key;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Rate limiting and auth middleware for RPC
|
||||
const rpcAuthMiddleware = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
const apiKey = extractApiKey(req);
|
||||
|
||||
let tier: RateLimitTier = 'free';
|
||||
let keyIdentifier: string;
|
||||
|
||||
if (apiKey) {
|
||||
const keyData = await apiKeyManager.validateKey(apiKey);
|
||||
if (keyData) {
|
||||
tier = keyData.tier;
|
||||
keyIdentifier = apiKey;
|
||||
} else {
|
||||
return res.status(401).json({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32001, message: 'Invalid API key' },
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Anonymous access - rate limit by IP
|
||||
keyIdentifier = `ip:${req.ip}`;
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
const result = await rateLimiter.checkLimit(keyIdentifier, tier);
|
||||
|
||||
// Set rate limit headers
|
||||
res.set('X-RateLimit-Limit', tier === 'enterprise' ? 'unlimited' : String(
|
||||
tier === 'developer' ? 1000 : 100
|
||||
));
|
||||
res.set('X-RateLimit-Remaining', String(result.remaining));
|
||||
res.set('X-RateLimit-Reset', String(Math.ceil(result.resetAt / 1000)));
|
||||
res.set('X-RateLimit-Tier', tier);
|
||||
|
||||
if (!result.allowed) {
|
||||
return res.status(429).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32005,
|
||||
message: 'Rate limit exceeded',
|
||||
data: {
|
||||
retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
|
||||
tier,
|
||||
upgrade: tier === 'free'
|
||||
? 'Upgrade to Developer tier for 1000 req/min'
|
||||
: tier === 'developer'
|
||||
? 'Contact us for Enterprise tier'
|
||||
: null,
|
||||
},
|
||||
},
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Store for analytics
|
||||
(req as any).apiKey = keyIdentifier;
|
||||
(req as any).tier = tier;
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
// RPC proxy
|
||||
app.post(
|
||||
'/rpc',
|
||||
rpcAuthMiddleware,
|
||||
createProxyMiddleware({
|
||||
target: RPC_TARGET,
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/rpc': '/' },
|
||||
onProxyRes: async (proxyRes, req: any) => {
|
||||
// Record usage analytics
|
||||
const responseTime = Date.now() - req.startTime;
|
||||
const body = req.body || {};
|
||||
const method = body.method || 'unknown';
|
||||
|
||||
await apiKeyManager.recordUsage(req.apiKey, method, responseTime);
|
||||
},
|
||||
onError: (err, req, res: any) => {
|
||||
res.status(502).json({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32603, message: 'RPC node unavailable' },
|
||||
id: null,
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Admin middleware
|
||||
const adminAuth = (req: Request, res: Response, next: NextFunction) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader !== `Bearer ${ADMIN_KEY}`) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// Admin: Create API key
|
||||
app.post('/v1/keys', adminAuth, async (req: Request, res: Response) => {
|
||||
const { tier, name } = req.body;
|
||||
|
||||
if (!tier || !name) {
|
||||
return res.status(400).json({ error: 'tier and name are required' });
|
||||
}
|
||||
|
||||
if (!['free', 'developer', 'enterprise'].includes(tier)) {
|
||||
return res.status(400).json({ error: 'Invalid tier' });
|
||||
}
|
||||
|
||||
const key = await apiKeyManager.createKey({ tier, name });
|
||||
res.status(201).json(key);
|
||||
});
|
||||
|
||||
// Admin: List API keys
|
||||
app.get('/v1/keys', adminAuth, async (req: Request, res: Response) => {
|
||||
const keys = await apiKeyManager.listKeys();
|
||||
res.json({ keys });
|
||||
});
|
||||
|
||||
// Get API key stats (key owner or admin)
|
||||
app.get('/v1/keys/:key/stats', async (req: Request, res: Response) => {
|
||||
const { key } = req.params;
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
// Allow key owner or admin
|
||||
if (authHeader !== `Bearer ${key}` && authHeader !== `Bearer ${ADMIN_KEY}`) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
const stats = await apiKeyManager.getKeyWithStats(key);
|
||||
if (!stats) {
|
||||
return res.status(404).json({ error: 'Key not found' });
|
||||
}
|
||||
|
||||
const analytics = await apiKeyManager.getAnalytics(key);
|
||||
res.json({ ...stats, analytics });
|
||||
});
|
||||
|
||||
// Admin: Revoke API key
|
||||
app.delete('/v1/keys/:key', adminAuth, async (req: Request, res: Response) => {
|
||||
const { key } = req.params;
|
||||
const deleted = await apiKeyManager.revokeKey(key);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Key not found' });
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Admin: Global analytics
|
||||
app.get('/v1/analytics', adminAuth, async (req: Request, res: Response) => {
|
||||
// Get global statistics
|
||||
res.json({
|
||||
message: 'Analytics endpoint - implement as needed',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
console.error('Error:', err.message);
|
||||
res.status(500).json({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32603, message: 'Internal server error' },
|
||||
id: null,
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Synor API Gateway running on port ${PORT}`);
|
||||
console.log(`Proxying RPC to: ${RPC_TARGET}`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('Shutting down...');
|
||||
await rateLimiter.close();
|
||||
await apiKeyManager.close();
|
||||
process.exit(0);
|
||||
});
|
||||
121
apps/api-gateway/src/rate-limiter.ts
Normal file
121
apps/api-gateway/src/rate-limiter.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Rate limiting using Redis sliding window algorithm.
|
||||
*
|
||||
* Tiers:
|
||||
* - free: 100 requests/minute
|
||||
* - developer: 1000 requests/minute
|
||||
* - enterprise: unlimited
|
||||
*/
|
||||
|
||||
// Use require for ioredis to avoid ESM import issues
|
||||
import type { Redis } from 'ioredis';
|
||||
import IORedisModule from 'ioredis';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const IORedis = (IORedisModule as any).default || IORedisModule;
|
||||
|
||||
export type RateLimitTier = 'free' | 'developer' | 'enterprise';
|
||||
|
||||
interface TierConfig {
|
||||
requestsPerMinute: number;
|
||||
burstLimit: number;
|
||||
}
|
||||
|
||||
const TIER_CONFIGS: Record<RateLimitTier, TierConfig> = {
|
||||
free: { requestsPerMinute: 100, burstLimit: 20 },
|
||||
developer: { requestsPerMinute: 1000, burstLimit: 100 },
|
||||
enterprise: { requestsPerMinute: Infinity, burstLimit: Infinity },
|
||||
};
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
resetAt: number;
|
||||
tier: RateLimitTier;
|
||||
}
|
||||
|
||||
export class RateLimiter {
|
||||
private redis: Redis;
|
||||
private windowMs = 60_000; // 1 minute
|
||||
|
||||
constructor(redisUrl: string) {
|
||||
this.redis = new IORedis(redisUrl);
|
||||
}
|
||||
|
||||
async checkLimit(key: string, tier: RateLimitTier): Promise<RateLimitResult> {
|
||||
const config = TIER_CONFIGS[tier];
|
||||
|
||||
// Enterprise tier bypasses rate limiting
|
||||
if (tier === 'enterprise') {
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: Infinity,
|
||||
resetAt: 0,
|
||||
tier,
|
||||
};
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const windowStart = now - this.windowMs;
|
||||
const redisKey = `ratelimit:${key}`;
|
||||
|
||||
// Use Redis sorted set for sliding window
|
||||
const pipeline = this.redis.pipeline();
|
||||
|
||||
// Remove expired entries
|
||||
pipeline.zremrangebyscore(redisKey, 0, windowStart);
|
||||
|
||||
// Count current requests in window
|
||||
pipeline.zcard(redisKey);
|
||||
|
||||
// Add current request
|
||||
pipeline.zadd(redisKey, now, `${now}-${Math.random()}`);
|
||||
|
||||
// Set expiry on the key
|
||||
pipeline.expire(redisKey, 120);
|
||||
|
||||
const results = await pipeline.exec();
|
||||
const currentCount = (results?.[1]?.[1] as number) || 0;
|
||||
|
||||
const allowed = currentCount < config.requestsPerMinute;
|
||||
const remaining = Math.max(0, config.requestsPerMinute - currentCount - 1);
|
||||
|
||||
// Calculate reset time
|
||||
const oldestRequest = await this.redis.zrange(redisKey, 0, 0, 'WITHSCORES');
|
||||
const resetAt = oldestRequest.length >= 2
|
||||
? parseInt(oldestRequest[1]) + this.windowMs
|
||||
: now + this.windowMs;
|
||||
|
||||
return {
|
||||
allowed,
|
||||
remaining: allowed ? remaining : 0,
|
||||
resetAt,
|
||||
tier,
|
||||
};
|
||||
}
|
||||
|
||||
async getUsageStats(key: string): Promise<{
|
||||
requestsLastMinute: number;
|
||||
requestsLastHour: number;
|
||||
requestsLastDay: number;
|
||||
}> {
|
||||
const now = Date.now();
|
||||
const redisKey = `ratelimit:${key}`;
|
||||
|
||||
const pipeline = this.redis.pipeline();
|
||||
pipeline.zcount(redisKey, now - 60_000, now);
|
||||
pipeline.zcount(redisKey, now - 3600_000, now);
|
||||
pipeline.zcount(redisKey, now - 86400_000, now);
|
||||
|
||||
const results = await pipeline.exec();
|
||||
|
||||
return {
|
||||
requestsLastMinute: (results?.[0]?.[1] as number) || 0,
|
||||
requestsLastHour: (results?.[1]?.[1] as number) || 0,
|
||||
requestsLastDay: (results?.[2]?.[1] as number) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.redis.quit();
|
||||
}
|
||||
}
|
||||
18
apps/api-gateway/tsconfig.json
Normal file
18
apps/api-gateway/tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
@ -313,6 +313,55 @@ services:
|
|||
profiles:
|
||||
- monitoring
|
||||
|
||||
# ==========================================================================
|
||||
# Redis - API Gateway Cache & Rate Limiting
|
||||
# ==========================================================================
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: synor-redis
|
||||
hostname: redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "17379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
- synor-testnet
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
profiles:
|
||||
- api
|
||||
|
||||
# ==========================================================================
|
||||
# Public API Gateway - Rate-limited RPC Access
|
||||
# ==========================================================================
|
||||
api-gateway:
|
||||
build:
|
||||
context: ./apps/api-gateway
|
||||
dockerfile: Dockerfile
|
||||
container_name: synor-api-gateway
|
||||
hostname: api-gateway
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "17400:3100"
|
||||
environment:
|
||||
- PORT=3100
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- RPC_TARGET=http://seed1:17110
|
||||
- ADMIN_KEY=${API_ADMIN_KEY:-admin-secret-key}
|
||||
networks:
|
||||
- synor-testnet
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
seed1:
|
||||
condition: service_healthy
|
||||
profiles:
|
||||
- api
|
||||
|
||||
# =============================================================================
|
||||
# Networks
|
||||
# =============================================================================
|
||||
|
|
@ -335,3 +384,4 @@ volumes:
|
|||
prometheus-data:
|
||||
grafana-data:
|
||||
alertmanager-data:
|
||||
redis-data:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue