@liberstudio/cloudflare-list 2.4.8 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,6 +47,8 @@ Il modulo ignora automaticamente:
47
47
  - Action: `Block`
48
48
  6. Salvare la regola
49
49
 
50
+ Con più account Cloudflare ripetere i passi per ogni account: ognuno ha la sua lista, il suo token e la sua regola WAF.
51
+
50
52
  ---
51
53
 
52
54
  ## Installazione
@@ -67,9 +69,10 @@ import { CloudflareAttacksModule } from "@liberstudio/cloudflare-list";
67
69
  @Module({
68
70
  imports: [
69
71
  CloudflareAttacksModule.forRoot({
70
- accountId: "abc123",
71
- listId: "def456",
72
- apiToken: "your-cloudflare-api-token",
72
+ accounts: [
73
+ { accountId: "abc123", listId: "def456", apiToken: "token-account-1" },
74
+ { accountId: "ghi789", listId: "jkl012", apiToken: "token-account-2" },
75
+ ],
73
76
  comment: "Blocked by liberstudio/cloudflare-list",
74
77
  logPath: "/var/log/nestjs-attacks.log",
75
78
  allowedIps: ["1.2.3.4", "10.0.0.0/8"],
@@ -94,9 +97,18 @@ import { CloudflareAttacksModule } from "@liberstudio/cloudflare-list";
94
97
  imports: [ConfigModule],
95
98
  inject: [ConfigService],
96
99
  useFactory: (config: ConfigService) => ({
97
- apiToken: config.getOrThrow<string>("CLOUDFLARE_API_TOKEN"),
98
- accountId: config.getOrThrow<string>("CLOUDFLARE_ACCOUNT_ID"),
99
- listId: config.getOrThrow<string>("CLOUDFLARE_LIST_ID"),
100
+ accounts: [
101
+ {
102
+ apiToken: config.getOrThrow<string>("CLOUDFLARE_API_TOKEN"),
103
+ accountId: config.getOrThrow<string>("CLOUDFLARE_ACCOUNT_ID"),
104
+ listId: config.getOrThrow<string>("CLOUDFLARE_LIST_ID"),
105
+ },
106
+ {
107
+ apiToken: config.getOrThrow<string>("CLOUDFLARE_2_API_TOKEN"),
108
+ accountId: config.getOrThrow<string>("CLOUDFLARE_2_ACCOUNT_ID"),
109
+ listId: config.getOrThrow<string>("CLOUDFLARE_2_LIST_ID"),
110
+ },
111
+ ],
100
112
  comment: config.get<string>("CLOUDFLARE_LIST_COMMENT") || "Blocked",
101
113
  logPath: config.get<string>("CLOUDFLARE_LIST_LOG_PATH") || "/var/log/nestjs-attacks.log",
102
114
  allowedIps: ["1.2.3.4", "10.0.0.0/8"],
@@ -120,9 +132,7 @@ export class AppModule {}
120
132
 
121
133
  | Opzione | Tipo | Obbligatorio | Descrizione |
122
134
  |---|---|---|---|
123
- | `apiToken` | `string` | si | Token API Cloudflare |
124
- | `accountId` | `string` | si | ID account Cloudflare |
125
- | `listId` | `string` | si | ID della IP List Cloudflare |
135
+ | `accounts` | `CloudflareAccount[]` | si | Account Cloudflare su cui bannare (almeno uno), vedi sotto |
126
136
  | `comment` | `string` | si | Commento aggiunto ad ogni IP bloccato |
127
137
  | `logPath` | `string` | si | Percorso del file di log degli attacchi |
128
138
  | `allowedIps` | `string[]` | no | IP o CIDR mai bloccati (whitelist, supporta IPv4 e IPv6) |
@@ -133,6 +143,38 @@ export class AppModule {}
133
143
  | `telegramNotify` | `{ chatId: string }` | no | Invia una notifica Telegram quando un IP viene bannato |
134
144
  | `notifyProvider` | `"whatsapp" \| "telegram"` | no | Canale di notifica da usare (default: vedi sotto) |
135
145
 
146
+ ### `accounts` — ban su più account Cloudflare
147
+
148
+ Ogni elemento di `accounts` è un `CloudflareAccount`:
149
+
150
+ | Campo | Tipo | Obbligatorio | Descrizione |
151
+ |---|---|---|---|
152
+ | `apiToken` | `string` | si | Token API Cloudflare con accesso all'account |
153
+ | `accountId` | `string` | si | ID account Cloudflare |
154
+ | `listId` | `string` | si | ID della IP List dell'account |
155
+
156
+ Ad ogni ban l'IP (o la subnet) viene aggiunto **in parallelo** alle liste di tutti gli account:
157
+
158
+ - se un account fallisce, l'errore viene loggato con il suo `accountId` e gli altri proseguono;
159
+ - se falliscono **tutti**, `updateIpList()` lancia `HttpException` con il primo errore;
160
+ - `updateIpList()` e `blockIp()` ritornano un `CloudflareResponse[]` con le risposte degli account riusciti.
161
+
162
+ Se lo stesso token ha accesso a più account, basta ripeterlo in ogni elemento.
163
+
164
+ ### Migrazione dalla 2.x
165
+
166
+ La 3.0.0 è un breaking change: `apiToken`, `accountId` e `listId` non stanno più alla radice delle opzioni, ma dentro `accounts`.
167
+
168
+ ```typescript
169
+ // 2.x
170
+ { apiToken: "...", accountId: "...", listId: "...", comment: "...", logPath: "..." }
171
+
172
+ // 3.x
173
+ { accounts: [{ apiToken: "...", accountId: "...", listId: "..." }], comment: "...", logPath: "..." }
174
+ ```
175
+
176
+ Inoltre `updateIpList()` ritorna `CloudflareResponse[]` invece di `CloudflareResponse`, e `blockIp()` ritorna `CloudflareResponse[] | null`.
177
+
136
178
  ### `notifyProvider` — scelta del canale di notifica
137
179
 
138
180
  Ad ogni ban viene inviata **una sola** notifica, sul canale scelto:
@@ -176,8 +218,11 @@ Il messaggio inviato sarà:
176
218
  Provenienza: Blocked by liberstudio/cloudflare-list
177
219
  IP: 1.2.3.4
178
220
  Subnet bloccata: 1.2.0.0/16
221
+ Account: 2/2
179
222
  ```
180
223
 
224
+ `Account` indica su quanti account configurati il ban è andato a buon fine. La notifica è sempre **una** per ban.
225
+
181
226
  Variabili d'ambiente richieste (lette automaticamente da `process.env`):
182
227
  ```env
183
228
  WA_URL=https://messaggistica.liberdesign.eu
@@ -7,11 +7,13 @@ export declare class AttacksService {
7
7
  private readonly API_URL;
8
8
  private readonly ASN_LOOKUP_URL;
9
9
  constructor(httpService: HttpService, options: CloudflareAttacksOptions);
10
- blockIp(rawIp: string): Promise<CloudflareResponse | null>;
10
+ blockIp(rawIp: string): Promise<CloudflareResponse[] | null>;
11
11
  private sendNotification;
12
12
  private isWhatsappConfigured;
13
13
  private sendTelegramNotification;
14
14
  private sendWhatsappNotification;
15
15
  private lookupAsnRange;
16
- updateIpList(cidr: string): Promise<CloudflareResponse>;
16
+ /** Aggiunge il CIDR alle liste di tutti gli account; ritorna le risposte degli account riusciti. */
17
+ updateIpList(cidr: string): Promise<CloudflareResponse[]>;
18
+ private addToAccountList;
17
19
  }
@@ -47,7 +47,7 @@ let AttacksService = class AttacksService {
47
47
  if (asnRange && !(await (0, utils_1.googleBotOverlapsCidr)(asnRange))) {
48
48
  this.logger.error(`ASN range trovato per ${rawIp}: ${asnRange} — blocco intera subnet`);
49
49
  const result = await this.updateIpList(asnRange);
50
- this.sendNotification(rawIp, asnRange);
50
+ this.sendNotification(rawIp, asnRange, result.length);
51
51
  return result;
52
52
  }
53
53
  if (asnRange) {
@@ -57,11 +57,11 @@ let AttacksService = class AttacksService {
57
57
  if (!cidr)
58
58
  throw new Error(`IP non valido: ${rawIp}`);
59
59
  const result = await this.updateIpList(cidr);
60
- this.sendNotification(rawIp, cidr);
60
+ this.sendNotification(rawIp, cidr, result.length);
61
61
  return result;
62
62
  }
63
- sendNotification(rawIp, cidr) {
64
- const message = `🚨 IP bannato su Cloudflare\nProvenienza: ${this.options.comment}\nIP: ${rawIp}\nSubnet bloccata: ${cidr}`;
63
+ sendNotification(rawIp, cidr, okAccounts) {
64
+ const message = `🚨 IP bannato su Cloudflare\nProvenienza: ${this.options.comment}\nIP: ${rawIp}\nSubnet bloccata: ${cidr}\nAccount: ${okAccounts}/${this.options.accounts.length}`;
65
65
  const provider = this.options.notifyProvider ?? (this.isWhatsappConfigured() ? "whatsapp" : "telegram");
66
66
  if (provider === "whatsapp")
67
67
  this.sendWhatsappNotification(message);
@@ -114,11 +114,30 @@ let AttacksService = class AttacksService {
114
114
  return null;
115
115
  }
116
116
  }
117
+ /** Aggiunge il CIDR alle liste di tutti gli account; ritorna le risposte degli account riusciti. */
117
118
  async updateIpList(cidr) {
118
- const { accountId, listId, apiToken, comment } = this.options;
119
+ const { accounts } = this.options;
120
+ this.logger.error(`Aggiungo ${cidr} alle liste Cloudflare (${accounts.length} account)`);
121
+ const results = await Promise.allSettled(accounts.map((account) => this.addToAccountList(account, cidr)));
122
+ const ok = [];
123
+ let firstError;
124
+ results.forEach((r, i) => {
125
+ if (r.status === "fulfilled") {
126
+ ok.push(r.value);
127
+ return;
128
+ }
129
+ firstError ??= r.reason;
130
+ const msg = r.reason instanceof Error ? r.reason.message : "Errore sconosciuto";
131
+ this.logger.error(`Aggiunta di ${cidr} fallita sull'account ${accounts[i].accountId}: ${msg}`);
132
+ });
133
+ if (ok.length === 0)
134
+ throw firstError;
135
+ return ok;
136
+ }
137
+ async addToAccountList(account, cidr) {
138
+ const { accountId, listId, apiToken } = account;
119
139
  const url = `${this.API_URL}/${accountId}/rules/lists/${listId}/items`;
120
- this.logger.error(`Aggiungo ${cidr} alla lista Cloudflare`);
121
- const body = [{ ip: cidr, comment }];
140
+ const body = [{ ip: cidr, comment: this.options.comment }];
122
141
  try {
123
142
  const response = await (0, rxjs_1.firstValueFrom)(this.httpService.post(url, body, {
124
143
  headers: {
@@ -5,10 +5,13 @@ export interface TelegramNotifyOptions {
5
5
  chatId: string;
6
6
  }
7
7
  export type NotifyProvider = "whatsapp" | "telegram";
8
- export interface CloudflareAttacksOptions {
8
+ export interface CloudflareAccount {
9
9
  apiToken: string;
10
10
  accountId: string;
11
11
  listId: string;
12
+ }
13
+ export interface CloudflareAttacksOptions {
14
+ accounts: CloudflareAccount[];
12
15
  comment: string;
13
16
  logPath: string;
14
17
  allowedIps?: string[];
@@ -20,4 +20,10 @@ if (minor >= 10) {
20
20
  pkg.version = `${major}.${minor}.${patch}`;
21
21
  // Scrittura package.json
22
22
  (0, node_fs_1.writeFileSync)(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
23
+ // Allineamento package-lock.json (versione radice e del pacchetto "")
24
+ const lockPath = (0, node_path_1.resolve)(__dirname, '../../package-lock.json');
25
+ const lock = JSON.parse((0, node_fs_1.readFileSync)(lockPath, 'utf8'));
26
+ lock.version = pkg.version;
27
+ lock.packages[''].version = pkg.version;
28
+ (0, node_fs_1.writeFileSync)(lockPath, JSON.stringify(lock, null, 2) + '\n');
23
29
  console.log(`Version updated to ${pkg.version}`);
@@ -1,16 +1,29 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.validateOptions = validateOptions;
4
+ function isNonEmptyString(value) {
5
+ return typeof value === "string" && value.trim().length > 0;
6
+ }
4
7
  function validateOptions(opzioni) {
5
8
  if (!opzioni)
6
9
  throw new Error("CloudflareAttacksOptions è obbligatorio");
7
- const campiObbligatori = ["apiToken", "accountId", "listId", "comment", "logPath"];
10
+ const campiObbligatori = ["comment", "logPath"];
8
11
  for (const campo of campiObbligatori) {
9
- const valore = opzioni[campo];
10
- if (typeof valore !== "string" || valore.trim().length === 0) {
12
+ if (!isNonEmptyString(opzioni[campo])) {
11
13
  throw new Error(`CloudflareAttacksOptions.${campo} deve essere una stringa non vuota`);
12
14
  }
13
15
  }
16
+ if (!Array.isArray(opzioni.accounts) || opzioni.accounts.length === 0) {
17
+ throw new Error("CloudflareAttacksOptions.accounts deve essere un array non vuoto");
18
+ }
19
+ const campiAccount = ["apiToken", "accountId", "listId"];
20
+ opzioni.accounts.forEach((account, i) => {
21
+ for (const campo of campiAccount) {
22
+ if (!isNonEmptyString(account?.[campo])) {
23
+ throw new Error(`CloudflareAttacksOptions.accounts[${i}].${campo} deve essere una stringa non vuota`);
24
+ }
25
+ }
26
+ });
14
27
  const provider = opzioni.notifyProvider;
15
28
  if (provider !== undefined && provider !== "whatsapp" && provider !== "telegram") {
16
29
  throw new Error(`CloudflareAttacksOptions.notifyProvider deve essere "whatsapp" o "telegram"`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liberstudio/cloudflare-list",
3
- "version": "2.4.8",
3
+ "version": "3.0.0",
4
4
  "description": "Modulo NestJS per gestione IP List Cloudflare",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",