Skip to content
Open
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
131 changes: 131 additions & 0 deletions libs/node-plugins/models/node-plugins.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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}`,
Expand Down
3 changes: 2 additions & 1 deletion libs/node-plugins/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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": {
Expand Down
107 changes: 107 additions & 0 deletions libs/node-plugins/tests/node-plugins.schema.test.cjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
2 changes: 1 addition & 1 deletion libs/node-plugins/tsconfig.backend.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,5 @@
"declaration": true,
"declarationMap": true
},
"exclude": ["scripts/**/*", "tests/**/*", "**/*.spec.ts", "**/*.test.ts"]
"exclude": ["build/**/*", "scripts/**/*", "tests/**/*", "**/*.spec.ts", "**/*.test.ts"]
}
3 changes: 2 additions & 1 deletion libs/node-plugins/tsconfig.frontend.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}