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:
Gulshan Yadav 2026-01-10 06:19:08 +05:30
parent d121759d2c
commit 4a2825f516
8 changed files with 933 additions and 0 deletions

View 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
View 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**

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

View 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();
}
}

View 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);
});

View 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();
}
}

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

View file

@ -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: