Skip to content
Draft
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
10 changes: 10 additions & 0 deletions configs/notifications/notifications-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -274,3 +274,13 @@ events:
torrent_blocker.report:
telegram: true
webhook: true

# ════════════════════════════════════════════════════════════════════════════
# ABUSE BLOCKER EVENTS
# ════════════════════════════════════════════════════════════════════════════

# Triggered for alert, temporary-block, repeat-block, and disable incidents.
# Suspicious reports remain database-only.
abuse_blocker.report:
telegram: true
webhook: true
8 changes: 8 additions & 0 deletions libs/contract/api/controllers/node-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export const NODE_PLUGINS_CONTROLLER = 'node-plugins' as const;

const ACTIONS_ROUTE = 'actions' as const;
const TORRENT_BLOCKER_ROUTE = 'torrent-blocker' as const;
const ABUSE_BLOCKER_ROUTE = 'abuse-blocker' as const;

export const NODE_PLUGINS_ROUTES = {
GET_ALL: '', // get
Expand All @@ -22,4 +23,11 @@ export const NODE_PLUGINS_ROUTES = {
GET_REPORTS_STATS: `${TORRENT_BLOCKER_ROUTE}/stats`,
TRUNCATE_REPORTS: `${TORRENT_BLOCKER_ROUTE}/truncate`,
},
ABUSE_BLOCKER: {
GET_REPORTS: `${ABUSE_BLOCKER_ROUTE}`,
GET_REPORTS_STATS: `${ABUSE_BLOCKER_ROUTE}/stats`,
GET_REVIEW_QUEUE: `${ABUSE_BLOCKER_ROUTE}/review`,
REVIEW: `${ABUSE_BLOCKER_ROUTE}/review/:userUuid`,
TRUNCATE_REPORTS: `${ABUSE_BLOCKER_ROUTE}/truncate`,
},
} as const;
8 changes: 8 additions & 0 deletions libs/contract/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,14 @@ export const REST_API = {
GET_REPORTS_STATS: `${ROOT}/${CONTROLLERS.NODE_PLUGINS_CONTROLLER}/${CONTROLLERS.NODE_PLUGINS_ROUTES.TORRENT_BLOCKER.GET_REPORTS_STATS}`,
TRUNCATE_REPORTS: `${ROOT}/${CONTROLLERS.NODE_PLUGINS_CONTROLLER}/${CONTROLLERS.NODE_PLUGINS_ROUTES.TORRENT_BLOCKER.TRUNCATE_REPORTS}`,
},
ABUSE_BLOCKER: {
GET_REPORTS: `${ROOT}/${CONTROLLERS.NODE_PLUGINS_CONTROLLER}/${CONTROLLERS.NODE_PLUGINS_ROUTES.ABUSE_BLOCKER.GET_REPORTS}`,
GET_REPORTS_STATS: `${ROOT}/${CONTROLLERS.NODE_PLUGINS_CONTROLLER}/${CONTROLLERS.NODE_PLUGINS_ROUTES.ABUSE_BLOCKER.GET_REPORTS_STATS}`,
GET_REVIEW_QUEUE: `${ROOT}/${CONTROLLERS.NODE_PLUGINS_CONTROLLER}/${CONTROLLERS.NODE_PLUGINS_ROUTES.ABUSE_BLOCKER.GET_REVIEW_QUEUE}`,
REVIEW: (userUuid: string) =>
`${ROOT}/${CONTROLLERS.NODE_PLUGINS_CONTROLLER}/${CONTROLLERS.NODE_PLUGINS_ROUTES.ABUSE_BLOCKER.REVIEW.replace(':userUuid', userUuid)}`,
TRUNCATE_REPORTS: `${ROOT}/${CONTROLLERS.NODE_PLUGINS_CONTROLLER}/${CONTROLLERS.NODE_PLUGINS_ROUTES.ABUSE_BLOCKER.TRUNCATE_REPORTS}`,
},
},
BANDWIDTH_STATS: {
NODES: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { z } from 'zod';

import { NODE_PLUGINS_ROUTES, REST_API } from '../../../api';
import { getEndpointDetails } from '../../../constants';
import { AbuseBlockerStoredReportSchema } from '../../../models';

export namespace GetAbuseBlockerReportsCommand {
export const url = REST_API.NODE_PLUGINS.ABUSE_BLOCKER.GET_REPORTS;
export const TSQ_url = url;
export const endpointDetails = getEndpointDetails(
NODE_PLUGINS_ROUTES.ABUSE_BLOCKER.GET_REPORTS,
'get',
'Get Abuse Blocker Reports',
{ scope: 'abuse-blocker-reports', kind: 'read' },
);

export const RequestQuerySchema = z
.object({
start: z.coerce.number().int().min(0).default(0),
size: z.coerce.number().int().min(1).max(500).default(50),
userId: z.coerce.number().int().positive().optional(),
nodeUuid: z.uuid().optional(),
severity: z.enum(['suspicious', 'alert', 'blocked']).optional(),
rule: z.enum(['horizontal_scan', 'destination_sweep']).optional(),
action: z.enum(['none', 'initial_block', 'repeat_block', 'disabled']).optional(),
dateFrom: z.coerce.date().optional(),
dateTo: z.coerce.date().optional(),
})
.refine((query) => !query.dateFrom || !query.dateTo || query.dateFrom <= query.dateTo, {
message: 'dateFrom must be before or equal to dateTo.',
path: ['dateTo'],
});
export const ResponseSchema = z.object({
response: z.object({
records: z.array(AbuseBlockerStoredReportSchema),
total: z.number(),
}),
});

export type RequestQuery = z.infer<typeof RequestQuerySchema>;
export type Response = z.infer<typeof ResponseSchema>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { z } from 'zod';

import { NODE_PLUGINS_ROUTES, REST_API } from '../../../api';
import { getEndpointDetails } from '../../../constants';
import { AbuseBlockerReviewStateSchema } from '../../../models';

export namespace GetAbuseBlockerReviewQueueCommand {
export const url = REST_API.NODE_PLUGINS.ABUSE_BLOCKER.GET_REVIEW_QUEUE;
export const endpointDetails = getEndpointDetails(
NODE_PLUGINS_ROUTES.ABUSE_BLOCKER.GET_REVIEW_QUEUE,
'get',
'Get Abuse Blocker Manual Review Queue',
{ scope: 'abuse-blocker-reports', kind: 'read' },
);
export const RequestQuerySchema = z.object({
start: z.coerce.number().int().min(0).default(0),
size: z.coerce.number().int().min(1).max(500).default(50),
});
export const ResponseSchema = z.object({
response: z.object({ records: z.array(AbuseBlockerReviewStateSchema), total: z.number() }),
});
export type RequestQuery = z.infer<typeof RequestQuerySchema>;
export type Response = z.infer<typeof ResponseSchema>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { z } from 'zod';

import { NODE_PLUGINS_ROUTES, REST_API } from '../../../api';
import { getEndpointDetails } from '../../../constants';

export namespace GetAbuseBlockerStatsCommand {
export const url = REST_API.NODE_PLUGINS.ABUSE_BLOCKER.GET_REPORTS_STATS;
export const endpointDetails = getEndpointDetails(
NODE_PLUGINS_ROUTES.ABUSE_BLOCKER.GET_REPORTS_STATS,
'get',
'Get Abuse Blocker Statistics',
{ scope: 'abuse-blocker-reports', kind: 'read' },
);
export const ResponseSchema = z.object({
response: z.object({
totalReports: z.number(),
reportsLast24Hours: z.number(),
distinctUsers: z.number(),
distinctNodes: z.number(),
manualReviewRequired: z.number(),
bySeverity: z.object({
suspicious: z.number(),
alert: z.number(),
blocked: z.number(),
}),
topUsers: z.array(
z.object({ userId: z.number(), username: z.string(), total: z.number() }),
),
topNodes: z.array(
z.object({
uuid: z.uuid(),
name: z.string(),
countryCode: z.string(),
total: z.number(),
}),
),
}),
});
export type Response = z.infer<typeof ResponseSchema>;
}
5 changes: 5 additions & 0 deletions libs/contract/commands/node-plugins/abuse-blocker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './get-abuse-blocker-reports.command';
export * from './get-abuse-blocker-review-queue.command';
export * from './get-abuse-blocker-stats.command';
export * from './review-abuse-blocker-user.command';
export * from './truncate-abuse-blocker-reports.command';
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { z } from 'zod';

import { NODE_PLUGINS_ROUTES, REST_API } from '../../../api';
import { getEndpointDetails } from '../../../constants';
import { AbuseBlockerReviewStateSchema } from '../../../models';

export namespace ReviewAbuseBlockerUserCommand {
export const url = REST_API.NODE_PLUGINS.ABUSE_BLOCKER.REVIEW;
export const TSQ_url = url(':userUuid');
export const endpointDetails = getEndpointDetails(
NODE_PLUGINS_ROUTES.ABUSE_BLOCKER.REVIEW,
'post',
'Resolve Abuse Blocker Manual Review',
{ scope: 'abuse-blocker-reports', kind: 'write' },
);
export const RequestParamSchema = z.object({
userUuid: z.uuid().describe('User VLESS UUID used by the current Users model.'),
});
export const RequestBodySchema = z.object({ action: z.enum(['enable', 'keep_disabled']) });
export const ResponseSchema = z.object({ response: AbuseBlockerReviewStateSchema });
export type RequestParam = z.infer<typeof RequestParamSchema>;
export type RequestBody = z.infer<typeof RequestBodySchema>;
export type Response = z.infer<typeof ResponseSchema>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { NODE_PLUGINS_ROUTES, REST_API } from '../../../api';
import { getEndpointDetails } from '../../../constants';

export namespace TruncateAbuseBlockerReportsCommand {
export const url = REST_API.NODE_PLUGINS.ABUSE_BLOCKER.TRUNCATE_REPORTS;
export const endpointDetails = getEndpointDetails(
NODE_PLUGINS_ROUTES.ABUSE_BLOCKER.TRUNCATE_REPORTS,
'delete',
'Truncate Abuse Blocker Reports',
{ scope: 'abuse-blocker-reports', kind: 'write' },
);
}
1 change: 1 addition & 0 deletions libs/contract/commands/node-plugins/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './actions';
export * from './abuse-blocker';
export * from './create-node-plugin.command';
export * from './delete-node-plugin.command';
export * from './executor.command';
Expand Down
9 changes: 8 additions & 1 deletion libs/contract/constants/events/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,17 @@ export const EVENTS = {
TORRENT_BLOCKER: {
REPORT: 'torrent_blocker.report',
},
ABUSE_BLOCKER: {
REPORT: 'abuse_blocker.report',
},
CATCH_ALL_USER_EVENTS: 'user.*',
CATCH_ALL_USER_HWID_DEVICES_EVENTS: 'user_hwid_devices.*',
CATCH_ALL_NODE_EVENTS: 'node.*',
CATCH_ALL_SERVICE_EVENTS: 'service.*',
CATCH_ALL_ERRORS_EVENTS: 'errors.*',
CATCH_ALL_CRM_EVENTS: 'crm.*',
CATCH_ALL_TORRENT_BLOCKER_EVENTS: 'torrent_blocker.*',
CATCH_ALL_ABUSE_BLOCKER_EVENTS: 'abuse_blocker.*',
} as const;

export type TNodeEvents = (typeof EVENTS.NODE)[keyof typeof EVENTS.NODE];
Expand All @@ -90,6 +94,7 @@ export type TUserHwidDevicesEvents =

export type TTorrentBlockerEvents =
(typeof EVENTS.TORRENT_BLOCKER)[keyof typeof EVENTS.TORRENT_BLOCKER];
export type TAbuseBlockerEvents = (typeof EVENTS.ABUSE_BLOCKER)[keyof typeof EVENTS.ABUSE_BLOCKER];

export type TAllEvents =
| TUserEvents
Expand All @@ -98,7 +103,8 @@ export type TAllEvents =
| TErrorsEvents
| TCRMEvents
| TUserHwidDevicesEvents
| TTorrentBlockerEvents;
| TTorrentBlockerEvents
| TAbuseBlockerEvents;
export type TAllEventChannels = 'telegram' | 'webhook';

export const EVENTS_SCOPES = {
Expand All @@ -109,6 +115,7 @@ export const EVENTS_SCOPES = {
ERRORS: 'errors',
CRM: 'crm',
TORRENT_BLOCKER: 'torrent_blocker',
ABUSE_BLOCKER: 'abuse_blocker',
} as const;

export type TEventsScope = (typeof EVENTS_SCOPES)[keyof typeof EVENTS_SCOPES];
Expand Down
44 changes: 44 additions & 0 deletions libs/contract/models/abuse-blocker-report.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { z } from 'zod';

import { NodesSchema } from './nodes.schema';

export const AbuseBlockerStoredReportSchema = z.object({
eventId: z.uuid(),
userId: z.number(),
nodeId: z.number(),
severity: z.enum(['suspicious', 'alert', 'blocked']),
score: z.number(),
sourceIp: z.string(),
action: z.enum(['none', 'initial_block', 'repeat_block', 'disabled']),
detectedAt: z.coerce.date(),
report: z.unknown(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
user: z.object({
username: z.string(),
vlessUuid: z.uuid(),
status: z.string(),
}),
node: NodesSchema.pick({
uuid: true,
name: true,
countryCode: true,
}),
});

export const AbuseBlockerReviewStateSchema = z.object({
userId: z.number(),
strikeLevel: z.number(),
lastBlockingIncidentAt: z.coerce.date().nullable(),
manualReviewRequired: z.boolean(),
disabledByPlugin: z.boolean(),
reviewRequestedAt: z.coerce.date().nullable(),
reviewedAt: z.coerce.date().nullable(),
reviewAction: z.enum(['enable', 'keep_disabled']).nullable(),
updatedAt: z.coerce.date(),
user: z.object({
username: z.string(),
vlessUuid: z.uuid(),
status: z.string(),
}),
});
1 change: 1 addition & 0 deletions libs/contract/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ export * from './webhook';
export * from './xray-json-advanced';
export * from './path-params.schema';
export * from './host-mapper';
export * from './abuse-blocker-report.schema';
17 changes: 17 additions & 0 deletions libs/contract/models/webhook/webhook.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,21 @@ export const RemnawaveWebhookTorrentBlockerEvents = z.object({
}),
}),
});
export const RemnawaveWebhookAbuseBlockerEvents = z.object({
scope: z.literal(EVENTS_SCOPES.ABUSE_BLOCKER),
event: z.enum(toZodEnum(EVENTS.ABUSE_BLOCKER)),
timestamp: z
.string()
.datetime()
.transform((str) => new Date(str)),
data: z.object({
node: NodesSchema,
user: ExtendedUsersSchema,
report: z.unknown(),
backendAction: z.enum(['none', 'initial_block', 'repeat_block', 'disabled']),
strikeLevel: z.number(),
}),
});
export const RemnawaveWebhookEventSchema = z.discriminatedUnion('scope', [
RemnawaveWebhookUserEvents,
RemnawaveWebhookUserHwidDevicesEvents,
Expand All @@ -164,6 +179,7 @@ export const RemnawaveWebhookEventSchema = z.discriminatedUnion('scope', [
RemnawaveWebhookErrorsEvents,
RemnawaveWebhookCrmEvents,
RemnawaveWebhookTorrentBlockerEvents,
RemnawaveWebhookAbuseBlockerEvents,
]);

export type TRemnawaveWebhookEvent = z.infer<typeof RemnawaveWebhookEventSchema>;
Expand All @@ -179,3 +195,4 @@ export type TRemnawaveWebhookUserHwidDevicesEvent = z.infer<
export type TRemnawaveWebhookTorrentBlockerEvent = z.infer<
typeof RemnawaveWebhookTorrentBlockerEvents
>;
export type TRemnawaveWebhookAbuseBlockerEvent = z.infer<typeof RemnawaveWebhookAbuseBlockerEvents>;
51 changes: 51 additions & 0 deletions prisma/migrations/20260815110000_abuse_blocker/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
CREATE TABLE "abuse_blocker_reports" (
"event_id" UUID NOT NULL,
"user_id" BIGINT NOT NULL,
"node_id" BIGINT NOT NULL,
"severity" VARCHAR(16) NOT NULL,
"score" INTEGER NOT NULL,
"source_ip" VARCHAR(45) NOT NULL,
"action" VARCHAR(32) NOT NULL,
"detected_at" TIMESTAMP(3) NOT NULL,
"report" JSONB NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "abuse_blocker_reports_pkey" PRIMARY KEY ("event_id")
);

CREATE TABLE "abuse_blocker_user_state" (
"user_id" BIGINT NOT NULL,
"strike_level" INTEGER NOT NULL DEFAULT 0,
"last_blocking_incident_at" TIMESTAMP(3),
"manual_review_required" BOOLEAN NOT NULL DEFAULT false,
"disabled_by_plugin" BOOLEAN NOT NULL DEFAULT false,
"review_requested_at" TIMESTAMP(3),
"reviewed_at" TIMESTAMP(3),
"review_action" VARCHAR(20),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "abuse_blocker_user_state_pkey" PRIMARY KEY ("user_id")
);

CREATE INDEX "abuse_blocker_reports_user_id_detected_at_idx"
ON "abuse_blocker_reports"("user_id", "detected_at");
CREATE INDEX "abuse_blocker_reports_node_id_detected_at_idx"
ON "abuse_blocker_reports"("node_id", "detected_at");
CREATE INDEX "abuse_blocker_reports_severity_detected_at_idx"
ON "abuse_blocker_reports"("severity", "detected_at");
CREATE INDEX "abuse_blocker_reports_action_detected_at_idx"
ON "abuse_blocker_reports"("action", "detected_at");
CREATE INDEX "abuse_blocker_user_state_manual_review_required_updated_at_idx"
ON "abuse_blocker_user_state"("manual_review_required", "updated_at");

ALTER TABLE "abuse_blocker_reports"
ADD CONSTRAINT "abuse_blocker_reports_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "abuse_blocker_reports"
ADD CONSTRAINT "abuse_blocker_reports_node_id_fkey"
FOREIGN KEY ("node_id") REFERENCES "nodes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "abuse_blocker_user_state"
ADD CONSTRAINT "abuse_blocker_user_state_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Loading