Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# ── Database ──────────────────────────────────────────────────────────────────
TYPEORM_DB=./data/simonapi.sqlite # SQLite path (dev) or ignored when Postgres env set

# ── Signpack ──────────────────────────────────────────────────────────────────
DATA_DIR=/data # Host path mounted into container (see docker-compose.yml)
FILE_MAX_BYTES=26214400 # 25 MB default
PURGE_CRON="0 * * * *" # hourly purge of expired signpacks

# ── Admin ─────────────────────────────────────────────────────────────────────
# Static key protecting /admin/stats and /admin/usage.
# Must be long and random — e.g.: openssl rand -hex 32
# Send as: x-api-key: <value> when calling admin endpoints.
ADMIN_KEY=changeme-generate-with-openssl-rand-hex-32
36 changes: 15 additions & 21 deletions apps/server/src/app/api-key/api-key-admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,37 @@ import {
Controller,
Delete,
Get,
Headers,
HttpCode,
HttpStatus,
Param,
Post,
UnauthorizedException,
} from '@nestjs/common';
import { ApiExcludeController } from '@nestjs/swagger';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { RequiresAdminKey } from './api-key.decorator';
import { SkipAnomalyGuard } from '../metrics/anomaly.guard';
import { ApiKeyService } from './api-key.service';
import { ApiKeyTier } from './entities/api-key.entity';

@ApiExcludeController()
@Controller('_admin/api-keys')
@ApiTags('Admin')
@ApiSecurity('x-api-key')
@SkipAnomalyGuard()
@Controller('admin/api-keys')
export class ApiKeyAdminController {
constructor(private readonly svc: ApiKeyService) {}

private guard(token?: string) {
const expected = process.env.ADMIN_TOKEN;
if (!expected || token !== expected) {
throw new UnauthorizedException('Invalid admin token');
}
}

@Get()
list(@Headers('x-admin-token') token?: string) {
this.guard(token);
@RequiresAdminKey()
@ApiOperation({ summary: 'List all API keys (admin)' })
list() {
return this.svc.list();
}

@Post()
@RequiresAdminKey()
@ApiOperation({ summary: 'Create a new API key (admin)' })
async create(
@Headers('x-admin-token') token: string | undefined,
@Body() body: { label: string; tier: ApiKeyTier; expiresAt?: string },
) {
this.guard(token);
const expires = body.expiresAt ? new Date(body.expiresAt) : undefined;
const { rawKey, entity } = await this.svc.create(body.label, body.tier, expires);
return {
Expand All @@ -51,11 +47,9 @@ export class ApiKeyAdminController {

@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async revoke(
@Headers('x-admin-token') token: string | undefined,
@Param('id') id: string,
) {
this.guard(token);
@RequiresAdminKey()
@ApiOperation({ summary: 'Revoke an API key (admin)' })
async revoke(@Param('id') id: string) {
await this.svc.revoke(id);
}
}
13 changes: 13 additions & 0 deletions apps/server/src/app/api-key/api-key.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,16 @@ export const RequiresTier = (minTier: ApiKeyTier) =>
* \@Post('sscc/build')
*/
export const TierRateLimit = () => SetMetadata(TIER_RATE_LIMIT_KEY, true);

/**
* Hard gate: the caller MUST supply the static admin key from ADMIN_KEY env var.
* Completely independent of the tier system and DB — purely env-based.
*
* Use only on internal admin/ops endpoints (/admin/stats, /admin/usage).
*
* @example
* \@RequiresAdminKey()
* \@Get('admin/stats')
*/
export const REQUIRES_ADMIN_KEY = 'requiresAdminKey';
export const RequiresAdminKey = () => SetMetadata(REQUIRES_ADMIN_KEY, true);
33 changes: 32 additions & 1 deletion apps/server/src/app/api-key/api-key.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
import { Reflector } from '@nestjs/core';
import { ApiKeyTier } from './entities/api-key.entity';
import { ApiKeyService, ResolvedKey } from './api-key.service';
import { REQUIRES_TIER_KEY, TIER_RATE_LIMIT_KEY } from './api-key.decorator';
import { REQUIRES_ADMIN_KEY, REQUIRES_TIER_KEY, TIER_RATE_LIMIT_KEY } from './api-key.decorator';

const TIER_ORDER: ApiKeyTier[] = ['free', 'pro', 'industrial'];

Expand All @@ -28,6 +28,37 @@ export class ApiKeyGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<any>();

// ── @RequiresAdminKey() — env-based admin gate ───────────────────────────
// Compared with constant-time equality to prevent timing attacks.
// The key must be set in the ADMIN_KEY environment variable.
const requiresAdmin = this.reflector.getAllAndOverride<boolean | undefined>(
REQUIRES_ADMIN_KEY,
[context.getHandler(), context.getClass()],
);
if (requiresAdmin) {
const adminKey = process.env['ADMIN_KEY'];
if (!adminKey) {
// Server misconfiguration — ADMIN_KEY not set
throw new ForbiddenException('Admin endpoint not configured (ADMIN_KEY missing)');
}
const provided: string = req.headers['x-api-key'] ?? '';
// Constant-time comparison via Buffer to mitigate timing attacks
const aLen = Buffer.byteLength(adminKey);
const bLen = Buffer.byteLength(provided);
const a = Buffer.alloc(aLen, 0);
const b = Buffer.alloc(aLen, 0);
Buffer.from(adminKey).copy(a);
Buffer.from(provided.slice(0, aLen)).copy(b);
const match = a.equals(b) && aLen === bLen;
if (!match) {
throw new UnauthorizedException({
message: 'Admin key required',
hint: 'Send your ADMIN_KEY as header: x-api-key: <admin-key>',
});
}
return true;
}

// ── Determine mode from metadata ────────────────────────────────────────
const minTier = this.reflector.getAllAndOverride<ApiKeyTier | undefined>(
REQUIRES_TIER_KEY,
Expand Down
1 change: 0 additions & 1 deletion apps/server/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ import { UsageModule } from './usage/usage.module';
// Authenticated callers get TIER_LIMITS from ApiKeyService (applied
// per-request in UsageInterceptor via overrideRule — no global mutation).
UsageModule.forRoot({
adminToken: process.env.ADMIN_TOKEN,
defaultRule: {
perMinute: 10, // anonymous / free tier fallback
},
Expand Down
37 changes: 28 additions & 9 deletions apps/server/src/app/metrics/metrics.controller.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,61 @@
import { Controller, Get, HttpCode, Query } from '@nestjs/common';
import { Controller, Delete, Get, HttpCode, Query } from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { RequiresAdminKey } from '../api-key/api-key.decorator';
import { MetricsService } from './metrics.service';
import { SkipMetrics } from './metrics.decorator';
import { SkipAnomalyGuard } from './anomaly.guard';
import { BlocklistService } from './blocklist.service';

@Controller()
/**
* Admin-only metrics & security dashboard.
*
* All routes require an `industrial` API key via `x-api-key` header.
* Rate limiting is intentionally skipped — admin tooling must not be
* blocked by its own counters.
*/
@ApiTags('Admin')
@ApiSecurity('x-api-key')
@SkipAnomalyGuard()
@Controller('admin/stats')
export class MetricsController {
constructor(
private readonly metrics: MetricsService,
private readonly blocklist: BlocklistService,
) {}

@Get('_stats')
@Get()
@HttpCode(200)
@SkipMetrics()
@RequiresAdminKey()
@ApiOperation({ summary: 'Request metrics snapshot (admin)' })
getStats() {
return this.metrics.snapshot();
}

@Get('_stats/reset')
@Delete('reset')
@HttpCode(200)
@SkipMetrics()
@RequiresAdminKey()
@ApiOperation({ summary: 'Reset metrics counters (admin)' })
async reset() {
await this.metrics.reset();
return { ok: true };
}

@Get('_stats/security')
@Get('security')
@HttpCode(200)
@SkipMetrics()
@RequiresAdminKey()
@ApiOperation({ summary: 'List currently blocked IPs (admin)' })
security() {
return {
blocked: this.blocklist.list(),
};
return { blocked: this.blocklist.list() };
}

@Get('_stats/security/unban')
@Get('security/unban')
@HttpCode(200)
@SkipMetrics()
@RequiresAdminKey()
@ApiOperation({ summary: 'Unban an IP address (admin)' })
unban(@Query('ip') ip: string) {
if (!ip) return { ok: false, error: 'ip required' };
const ok = this.blocklist.unban(ip);
Expand Down
33 changes: 18 additions & 15 deletions apps/server/src/app/usage/usage.controller.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,32 @@
import { Controller, Get, Headers, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { RequiresAdminKey } from '../api-key/api-key.decorator';
import { SkipAnomalyGuard } from '../metrics/anomaly.guard';
import { UsageService } from './usage.service';

@Controller('_usage')
/**
* Rate-limit usage snapshot — admin only.
* Secured via x-api-key (industrial tier), same as /admin/stats.
*/
@ApiTags('Admin')
@ApiSecurity('x-api-key')
@SkipAnomalyGuard()
@Controller('admin/usage')
export class UsageController {
constructor(private readonly usage: UsageService) {}

private authOk(token?: string) {
const adminToken = this.usage.getAdminToken();
return !!adminToken && token === adminToken;
}

@Get('stats')
getStats(@Headers('x-admin-token') token?: string) {
if (!this.authOk(token)) {
return { error: 'unauthorized' };
}
@RequiresAdminKey()
@ApiOperation({ summary: 'Rate-limit counters snapshot (admin)' })
getStats() {
return this.usage.snapshot();
}

@Post('reset')
@HttpCode(HttpStatus.NO_CONTENT)
reset(@Headers('x-admin-token') token?: string) {
if (!this.authOk(token)) return { error: 'unauthorized' };
@RequiresAdminKey()
@ApiOperation({ summary: 'Reset rate-limit counters (admin)' })
reset() {
this.usage.resetAll();
return;
}
}

4 changes: 4 additions & 0 deletions apps/server/src/app/usage/usage.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export class UsageInterceptor implements NestInterceptor {

const path = req.originalUrl || req.url;

// Admin routes are authenticated via API key (industrial tier) and must
// never be blocked by their own rate-limit counters.
if (path.startsWith('/admin/')) return next.handle();

// Prefer the resolved key attached by ApiKeyGuard (validated, tier known).
// Fall back to the raw header string, then real client IP, then 'anonymous'.
//
Expand Down
7 changes: 6 additions & 1 deletion apps/simonapi/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ export const appRoutes: Route[] = [
{
path: 'admin/stats',
loadComponent: () => import('./features/stats/stats-dashboard.component').then(m => m.StatsDashboardComponent),
title: 'API Stats',
title: 'Admin – Stats',
},
{
path: 'admin/api-keys',
loadComponent: () => import('./features/stats/api-keys.component').then(m => m.ApiKeysComponent),
title: 'Admin – API Keys',
},
{
path: 'crypto',
Expand Down
Loading
Loading