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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,10 @@ PURGE_CRON="0 * * * *" # hourly purge of expired signpacks
# 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

# ── Visitor stats ─────────────────────────────────────────────────────────────
# Secret used to salt the daily IP hash (SHA-256(ip|YYYY-MM-DD|secret)).
# Changing this key invalidates all existing ip_hash values in visitor_daily
# (old rows become unrelatable — fine, they remain as aggregate counts).
# Generate: openssl rand -hex 32
IP_HASH_SECRET=changeme-generate-with-openssl-rand-hex-32
9 changes: 8 additions & 1 deletion apps/server/src/app/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { QrModule } from './qr/qr.module';
Expand All @@ -17,6 +18,7 @@ import databaseConfig from './config/database.config';
import { MetricsModule } from './metrics/metrics.module';
import { CryptoModule } from './crypto/crypto.module';
import { ApiKeyModule } from './api-key/api-key.module';
import { ApiKeyGuard } from './api-key/api-key.guard';
import { UsageModule } from './usage/usage.module';

@Module({
Expand Down Expand Up @@ -99,6 +101,11 @@ import { UsageModule } from './usage/usage.module';
}),
],
controllers: [AppController, ReportsController],
providers: [AppService],
providers: [
AppService,
// Global guard — reads @RequiresAdminKey() / @RequiresTier() / @TierRateLimit()
// metadata set by decorators. Without this, those decorators are no-ops.
{ provide: APP_GUARD, useClass: ApiKeyGuard },
],
})
export class AppModule {}
45 changes: 43 additions & 2 deletions apps/server/src/app/metrics/anomaly-detector.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';

/** How often to sweep stale IP entries (ms). Default: 10 min */
const SWEEP_INTERVAL_MS = 10 * 60_000;
/** IP not seen for this long is evicted from in-memory Maps. */
const STALE_AFTER_MS = 20 * 60_000;
import { SlidingCounter, SlidingDistinct } from './sliding';
import { BlocklistService } from './blocklist.service';

Expand All @@ -19,7 +24,7 @@ const cfgFromEnv = (): AnomalyConfig => ({
});

@Injectable()
export class AnomalyDetectorService {
export class AnomalyDetectorService implements OnModuleInit, OnModuleDestroy {
private readonly log = new Logger(AnomalyDetectorService.name);
private readonly cfg = cfgFromEnv();

Expand All @@ -30,8 +35,42 @@ export class AnomalyDetectorService {
private perIpReq5m = new Map<string, SlidingCounter>(); // requests 5min
private perIpErr5m = new Map<string, SlidingCounter>(); // errors 5min

/** Tracks the last-seen timestamp per IP for stale-entry eviction. */
private lastSeen = new Map<string, number>();
private sweepTimer: ReturnType<typeof setInterval> | null = null;

constructor(private readonly blocklist: BlocklistService) {}

onModuleInit(): void {
this.sweepTimer = setInterval(() => this.sweepStale(), SWEEP_INTERVAL_MS);
if (this.sweepTimer.unref) this.sweepTimer.unref();
}

onModuleDestroy(): void {
if (this.sweepTimer) clearInterval(this.sweepTimer);
}

/** Remove in-memory counters for IPs that haven't been seen recently. */
sweepStale(now = Date.now()): number {
const cutoff = now - STALE_AFTER_MS;
let evicted = 0;
for (const [ip, ts] of this.lastSeen) {
if (ts < cutoff) {
this.perIpMinute.delete(ip);
this.perIpFiveMin.delete(ip);
this.perIpRoutes.delete(ip);
this.perIpReq5m.delete(ip);
this.perIpErr5m.delete(ip);
this.lastSeen.delete(ip);
evicted++;
}
}
if (evicted > 0) {
this.log.debug(`AnomalyDetector sweep: evicted ${evicted} stale IP entries`);
}
return evicted;
}

private getIp(req: any): string {
const xff = (req?.headers?.['x-forwarded-for'] as string) || '';
const candidate = xff.split(',')[0]?.trim();
Expand All @@ -42,6 +81,8 @@ export class AnomalyDetectorService {
const ip = this.getIp(req);
const now = Date.now();

this.lastSeen.set(ip, now);

const minute = (this.perIpMinute.get(ip) ?? new SlidingCounter(60_000, 5_000));
const five = (this.perIpFiveMin.get(ip) ?? new SlidingCounter(300_000, 10_000));
const routes = (this.perIpRoutes.get(ip) ?? new SlidingDistinct(60_000, 5_000));
Expand Down
54 changes: 54 additions & 0 deletions apps/server/src/app/metrics/entities/visitor-daily.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Column, Entity, Index, PrimaryGeneratedColumn, Unique, UpdateDateColumn } from 'typeorm';

/**
* One row = one (date, ip_hash, route_group, tier) combination.
*
* Privacy model:
* ip_hash = SHA-256(rawIp + daily-salt)
* daily-salt = YYYY-MM-DD + IP_HASH_SECRET env-var
* → same IP on different days produces different hashes (no long-term profile)
* → raw IP is NEVER stored
*/
@Entity({ name: 'visitor_daily' })
// UNIQUE constraint is required for ON CONFLICT DO UPDATE (upsert) in flush().
// The combination (day, ip_hash, route_group, tier, api_key_prefix) uniquely
// identifies one bucket. @Index alone does not create a unique constraint.
@Unique('UQ_VD_BUCKET', ['day', 'ipHash', 'routeGroup', 'tier', 'apiKeyPrefix'])
@Index('IDX_VD_DATE', ['day'])
export class VisitorDailyEntity {
@PrimaryGeneratedColumn()
id!: number;

/** ISO date string YYYY-MM-DD */
@Column({ type: 'text' })
day!: string;

/** SHA-256(ip + daily-salt) — never raw IP */
@Column({ name: 'ip_hash', type: 'text' })
ipHash!: string;

/** ISO 3166-1 alpha-2 country code from geoip-lite, or 'XX' if unknown */
@Column({ name: 'country_code', type: 'text', default: 'XX' })
countryCode!: string;

/** API group derived from route prefix: qr / barcode / watermark / crypto / signpack / utils / lock / admin / other */
@Column({ name: 'route_group', type: 'text', default: 'other' })
routeGroup!: string;

/** Tier of the resolved API key, or 'anonymous' */
@Column({ type: 'text', default: 'anonymous' })
tier!: string;

/** First 8 characters of the API key prefix, nullable */
@Column({ name: 'api_key_prefix', type: 'text', nullable: true })
apiKeyPrefix!: string | null;

@Column({ name: 'request_count', type: 'integer', default: 0 })
requestCount!: number;

@Column({ name: 'error_count', type: 'integer', default: 0 })
errorCount!: number;

@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
41 changes: 41 additions & 0 deletions apps/server/src/app/metrics/metrics.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { MetricsService } from './metrics.service';
import { SkipMetrics } from './metrics.decorator';
import { SkipAnomalyGuard } from './anomaly.guard';
import { BlocklistService } from './blocklist.service';
import { VisitorService } from './visitor.service';

/**
* Admin-only metrics & security dashboard.
Expand All @@ -21,6 +22,7 @@ export class MetricsController {
constructor(
private readonly metrics: MetricsService,
private readonly blocklist: BlocklistService,
private readonly visitor: VisitorService,
) {}

@Get()
Expand Down Expand Up @@ -61,4 +63,43 @@ export class MetricsController {
const ok = this.blocklist.unban(ip);
return { ok };
}

// ── Visitor stats ──────────────────────────────────────────────────────────

@Get('visitors/summary')
@HttpCode(200)
@SkipMetrics()
@RequiresAdminKey()
@ApiOperation({ summary: 'Visitor summary: unique IPs today / 7d / 30d with tier breakdown (admin)' })
async visitorSummary() {
return this.visitor.getSummary();
}

@Get('visitors/daily')
@HttpCode(200)
@SkipMetrics()
@RequiresAdminKey()
@ApiOperation({ summary: 'Daily unique IPs + request counts for the last N days (admin)' })
async visitorDaily(@Query('days') days?: string) {
const n = days ? Math.min(Math.max(parseInt(days, 10) || 30, 1), 90) : 30;
return this.visitor.getDailyUnique(n);
}

@Get('visitors/by-api')
@HttpCode(200)
@SkipMetrics()
@RequiresAdminKey()
@ApiOperation({ summary: 'Unique IPs and request counts grouped by API (route group) for a given day (admin)' })
async visitorByApi(@Query('day') day?: string) {
return this.visitor.getByApi(day);
}

@Get('visitors/by-country')
@HttpCode(200)
@SkipMetrics()
@RequiresAdminKey()
@ApiOperation({ summary: 'Unique IPs grouped by country for a given day (admin)' })
async visitorByCountry(@Query('day') day?: string) {
return this.visitor.getByCountry(day);
}
}
15 changes: 15 additions & 0 deletions apps/server/src/app/metrics/metrics.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { MetricsService } from './metrics.service';
import { Reflector } from '@nestjs/core';
import { SKIP_METRICS } from './metrics.decorator';
import { AnomalyDetectorService } from './anomaly-detector.service';
import { VisitorService } from './visitor.service';
import { RESOLVED_KEY_PROP } from '../api-key/api-key.guard';

function resolveRoutePath(req: any): string {
const expressRoute =
Expand All @@ -34,6 +36,7 @@ export class MetricsInterceptor implements NestInterceptor {
private readonly metrics: MetricsService,
private readonly reflector: Reflector,
private readonly anomaly: AnomalyDetectorService,
private readonly visitor: VisitorService,
) {}

intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
Expand Down Expand Up @@ -63,6 +66,18 @@ export class MetricsInterceptor implements NestInterceptor {
this.metrics.record(path, method, status, durationMs).catch((err) => {
this.log.error('Failed to store request metric', err instanceof Error ? err.stack : String(err));
});

// Visitor stats — resolve IP, tier and API-key prefix from request
const resolved = req[RESOLVED_KEY_PROP] as { tier?: string; prefix?: string } | undefined;
const xff = (req?.headers?.['x-forwarded-for'] as string) || '';
const rawIp = xff.split(',')[0]?.trim() || req?.ip || req?.socket?.remoteAddress || 'unknown';
this.visitor.record({
rawIp,
route: path,
status,
tier: resolved?.tier ?? 'anonymous',
apiKeyPrefix: resolved?.prefix ? resolved.prefix.slice(0, 8) : null,
});
}
this.anomaly.observe(req, path, method, status);
}),
Expand Down
7 changes: 5 additions & 2 deletions apps/server/src/app/metrics/metrics.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,27 @@ import { MetricsController } from './metrics.controller';
import { BlocklistService } from './blocklist.service';
import { AnomalyDetectorService } from './anomaly-detector.service';
import { AnomalyGuard } from './anomaly.guard';
import { VisitorService } from './visitor.service';
import { VisitorDailyEntity } from './entities/visitor-daily.entity';
import { MetricMetaEntity } from './entities/metric-meta.entity';
import { MetricRouteEntity } from './entities/metric-route.entity';
import { MetricDailyEntity } from './entities/metric-daily.entity';
import { SecurityBlockEntity } from './entities/security-block.entity';

@Module({
imports: [
TypeOrmModule.forFeature([MetricMetaEntity, MetricRouteEntity, MetricDailyEntity, SecurityBlockEntity]),
TypeOrmModule.forFeature([MetricMetaEntity, MetricRouteEntity, MetricDailyEntity, SecurityBlockEntity, VisitorDailyEntity]),
],
providers: [
MetricsService,
BlocklistService,
AnomalyDetectorService,
VisitorService,
Reflector,
{ provide: APP_GUARD, useClass: AnomalyGuard },
{ provide: APP_INTERCEPTOR, useClass: MetricsInterceptor },
],
controllers: [MetricsController],
exports: [MetricsService, BlocklistService],
exports: [MetricsService, BlocklistService, VisitorService],
})
export class MetricsModule {}
Loading
Loading