diff --git a/libs/node-plugins/models/node-plugins.schema.ts b/libs/node-plugins/models/node-plugins.schema.ts index 968a14f1..dd0f5e78 100644 --- a/libs/node-plugins/models/node-plugins.schema.ts +++ b/libs/node-plugins/models/node-plugins.schema.ts @@ -75,6 +75,133 @@ export const TorrentBlockerPluginSchema = z.object({ }), }); +const abuseBlockerDocs = (description: string) => `${description}${DOCS_LINK}`; + +const AbuseBlockerIgnoreListsSchema = z + .object({ + userId: z + .array(z.int().min(1)) + .default([]) + .meta({ + title: 'Ignored User IDs', + markdownDescription: abuseBlockerDocs( + 'Users that must not be scored or blocked by Abuse Blocker.', + ), + }), + sourceIp: z + .array(IpCidrOrExtSchema) + .default([]) + .meta({ + title: 'Ignored Source IPs', + markdownDescription: abuseBlockerDocs( + 'Source IP addresses and CIDR ranges that must not be scored or blocked. Shared IP lists are supported through **ext:list_name**.', + ), + }), + destinationIp: z + .array(IpCidrOrExtSchema) + .default([]) + .meta({ + title: 'Ignored Destination IPs', + markdownDescription: abuseBlockerDocs( + 'Destination IP addresses and CIDR ranges excluded from scan detection. Shared IP lists are supported through **ext:list_name**.', + ), + }), + }) + .default({ userId: [], sourceIp: [], destinationIp: [] }); + +const HorizontalScanRuleSchema = z + .object({ + enabled: z.boolean().default(true), + windowSeconds: z.int().min(1).max(3600).default(60), + uniqueDestinations: z.int().min(2).max(65535).default(20), + ipv4Prefix: z.int().min(0).max(32).default(24), + ipv6Prefix: z.int().min(0).max(128).default(64), + score: z.int().min(1).max(10000).default(100), + }) + .default({ + enabled: true, + windowSeconds: 60, + uniqueDestinations: 20, + ipv4Prefix: 24, + ipv6Prefix: 64, + score: 100, + }) + .meta({ + title: 'Horizontal Scan', + markdownDescription: abuseBlockerDocs( + 'Detects one user contacting many unique hosts in the same network prefix on the same non-web port.', + ), + }); + +const DestinationSweepRuleSchema = z + .object({ + enabled: z.boolean().default(true), + windowSeconds: z.int().min(1).max(3600).default(60), + uniqueDestinations: z.int().min(2).max(65535).default(50), + score: z.int().min(1).max(10000).default(50), + }) + .default({ enabled: true, windowSeconds: 60, uniqueDestinations: 50, score: 50 }) + .meta({ + title: 'Destination Sweep', + markdownDescription: abuseBlockerDocs( + 'Detects one user contacting many unique destination IPs on the same non-web port.', + ), + }); + +export const AbuseBlockerPluginSchema = z + .object({ + enabled: z.boolean().meta({ + title: 'Enabled', + markdownDescription: abuseBlockerDocs( + 'Enables local scan detection, scoring, reporting, and temporary source-IP blocking.', + ), + }), + excludedPorts: z + .array(z.int().min(1).max(65535)) + .refine((ports) => new Set(ports).size === ports.length, { + message: 'Excluded ports must be unique.', + }) + .default([80, 443]) + .meta({ + title: 'Excluded Ports', + markdownDescription: abuseBlockerDocs( + 'Destination ports excluded from scan detection.', + ), + }), + ignoreLists: AbuseBlockerIgnoreListsSchema, + scoreWindowSeconds: z.int().min(60).max(86400).default(3600), + incidentCooldownSeconds: z.int().min(0).max(86400).default(300), + suspiciousScore: z.int().min(1).max(100000).default(50), + alertScore: z.int().min(1).max(100000).default(100), + blockScore: z.int().min(1).max(100000).default(150), + initialBlockSeconds: z.int().min(1).max(2592000).default(600), + repeatBlockSeconds: z.int().min(1).max(2592000).default(3600), + repeatWindowSeconds: z.int().min(60).max(31536000).default(604800), + evidenceLimit: z.int().min(1).max(1000).default(10), + enhancedEvidenceLimit: z.int().min(1).max(5000).default(50), + maxTrackedUsers: z.int().min(1).max(1000000).default(50000), + maxKeysPerUser: z.int().min(1).max(4096).default(256), + reportBufferSize: z.int().min(1).max(1000000).default(10000), + horizontalScan: HorizontalScanRuleSchema, + destinationSweep: DestinationSweepRuleSchema, + }) + .refine((config) => config.suspiciousScore < config.alertScore, { + message: 'suspiciousScore must be lower than alertScore.', + path: ['alertScore'], + }) + .refine((config) => config.alertScore < config.blockScore, { + message: 'alertScore must be lower than blockScore.', + path: ['blockScore'], + }) + .refine((config) => config.initialBlockSeconds <= config.repeatBlockSeconds, { + message: 'repeatBlockSeconds must be greater than or equal to initialBlockSeconds.', + path: ['repeatBlockSeconds'], + }) + .refine((config) => config.evidenceLimit <= config.enhancedEvidenceLimit, { + message: 'enhancedEvidenceLimit must be greater than or equal to evidenceLimit.', + path: ['enhancedEvidenceLimit'], + }); + export const ConnectionDropPluginSchema = z.object({ enabled: z.boolean().meta({ title: 'Enabled', @@ -173,6 +300,10 @@ export const NodePluginSchema = z.object({ title: 'Torrent Blocker', markdownDescription: `Torrent Blocker Plugin configuration. Optional.${DOCS_LINK}`, }), + abuseBlocker: AbuseBlockerPluginSchema.optional().meta({ + title: 'Abuse Blocker', + markdownDescription: `Abuse Blocker Plugin configuration. Optional.${DOCS_LINK}`, + }), ingressFilter: IngressFilterPluginSchema.optional().meta({ title: 'Ingress Filter', markdownDescription: `Ingress Filter Plugin configuration. Optional.${DOCS_LINK}`, diff --git a/libs/node-plugins/package.json b/libs/node-plugins/package.json index d46299e2..93495dd3 100644 --- a/libs/node-plugins/package.json +++ b/libs/node-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@remnawave/node-plugins", - "version": "0.6.3", + "version": "0.7.0", "description": "A library for Remnawave Node Plugins.", "keywords": [], "homepage": "https://github.com/remnawave", @@ -24,6 +24,7 @@ "build:backend": "tsc -p tsconfig.backend.json", "build:frontend": "tsc -p tsconfig.frontend.json", "build": "npm run clean && npm run build:backend && npm run build:frontend", + "test": "npm run build:backend && node --test tests/*.test.cjs", "prepublish": "npm run build" }, "dependencies": { diff --git a/libs/node-plugins/tests/node-plugins.schema.test.cjs b/libs/node-plugins/tests/node-plugins.schema.test.cjs new file mode 100644 index 00000000..d4257c6b --- /dev/null +++ b/libs/node-plugins/tests/node-plugins.schema.test.cjs @@ -0,0 +1,107 @@ +const assert = require('node:assert/strict'); +const { describe, it } = require('node:test'); + +const { + AbuseBlockerPluginSchema, + NodePluginSchema, +} = require('../build/backend/models/node-plugins.schema.js'); + +describe('AbuseBlockerPluginSchema', () => { + it('applies the documented defaults', () => { + const config = AbuseBlockerPluginSchema.parse({ enabled: true }); + + assert.deepEqual(config.excludedPorts, [80, 443]); + assert.deepEqual(config.ignoreLists, { + userId: [], + sourceIp: [], + destinationIp: [], + }); + assert.equal(config.scoreWindowSeconds, 3600); + assert.equal(config.incidentCooldownSeconds, 300); + assert.equal(config.suspiciousScore, 50); + assert.equal(config.alertScore, 100); + assert.equal(config.blockScore, 150); + assert.equal(config.initialBlockSeconds, 600); + assert.equal(config.repeatBlockSeconds, 3600); + assert.equal(config.repeatWindowSeconds, 604800); + assert.equal(config.evidenceLimit, 10); + assert.equal(config.enhancedEvidenceLimit, 50); + assert.equal(config.maxTrackedUsers, 50000); + assert.equal(config.maxKeysPerUser, 256); + assert.equal(config.reportBufferSize, 10000); + assert.deepEqual(config.horizontalScan, { + enabled: true, + windowSeconds: 60, + uniqueDestinations: 20, + ipv4Prefix: 24, + ipv6Prefix: 64, + score: 100, + }); + assert.deepEqual(config.destinationSweep, { + enabled: true, + windowSeconds: 60, + uniqueDestinations: 50, + score: 50, + }); + }); + + it('accepts shared lists and custom rule settings', () => { + const result = AbuseBlockerPluginSchema.safeParse({ + enabled: true, + excludedPorts: [80, 443, 8443], + ignoreLists: { + userId: [42], + sourceIp: ['192.0.2.0/24', 'ext:trusted_sources'], + destinationIp: ['2001:db8::/32'], + }, + horizontalScan: { + uniqueDestinations: 25, + ipv4Prefix: 20, + ipv6Prefix: 56, + score: 120, + }, + destinationSweep: { + uniqueDestinations: 75, + score: 60, + }, + }); + + assert.equal(result.success, true); + }); + + it('rejects unordered score thresholds', () => { + const result = AbuseBlockerPluginSchema.safeParse({ + enabled: true, + suspiciousScore: 100, + alertScore: 100, + blockScore: 150, + }); + + assert.equal(result.success, false); + assert.deepEqual(result.error.issues[0].path, ['alertScore']); + }); + + it('rejects duplicate excluded ports and invalid prefixes', () => { + const result = AbuseBlockerPluginSchema.safeParse({ + enabled: true, + excludedPorts: [443, 443], + horizontalScan: { ipv4Prefix: 33 }, + }); + + assert.equal(result.success, false); + assert.equal(result.error.issues.length, 2); + }); + + it('keeps configurations without abuseBlocker backward compatible', () => { + const result = NodePluginSchema.safeParse({ + torrentBlocker: { + enabled: false, + blockDuration: 600, + ignoreLists: {}, + }, + }); + + assert.equal(result.success, true); + assert.equal(result.data.abuseBlocker, undefined); + }); +}); diff --git a/libs/node-plugins/tsconfig.backend.json b/libs/node-plugins/tsconfig.backend.json index c091a8ab..2bca3385 100644 --- a/libs/node-plugins/tsconfig.backend.json +++ b/libs/node-plugins/tsconfig.backend.json @@ -14,5 +14,5 @@ "declaration": true, "declarationMap": true }, - "exclude": ["scripts/**/*", "tests/**/*", "**/*.spec.ts", "**/*.test.ts"] + "exclude": ["build/**/*", "scripts/**/*", "tests/**/*", "**/*.spec.ts", "**/*.test.ts"] } diff --git a/libs/node-plugins/tsconfig.frontend.json b/libs/node-plugins/tsconfig.frontend.json index 730515ba..be253c4f 100644 --- a/libs/node-plugins/tsconfig.frontend.json +++ b/libs/node-plugins/tsconfig.frontend.json @@ -91,5 +91,6 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ - } + }, + "exclude": ["build/**/*", "tests/**/*", "**/*.spec.ts", "**/*.test.ts"] }