nftables-napi 0.0.2 → 0.1.1

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.
@@ -4,6 +4,7 @@
4
4
  #include "netlink/parsed_addr.h"
5
5
  #include "netlink/table_ops.h"
6
6
  #include "netlink/set_ops.h"
7
+ #include "netlink/constants.h"
7
8
 
8
9
  #include <cmath>
9
10
  #include <cstring>
@@ -46,23 +47,41 @@ static std::vector<ParsedAddr> parse_ip_array(Napi::Env env, Napi::Array arr) {
46
47
  return addrs;
47
48
  }
48
49
 
49
- // Returns the parsed TargetSet or sets a JS exception and returns std::nullopt.
50
- static std::optional<nft::TargetSet> parse_target_set(Napi::Env env, Napi::Object opts, const char* method_name) {
50
+ // Parse set name, filtering by allowed SetKind values.
51
+ // allow_ip=true matches InIP and OutIP; allow_port=true matches OutPort.
52
+ static std::optional<size_t> parse_set_name(Napi::Env env, Napi::Object opts,
53
+ const char* method_name,
54
+ const nft::NftConfig& cfg,
55
+ bool allow_ip, bool allow_port) {
51
56
  if (!opts.Has("set") || !opts.Get("set").IsString()) {
52
- std::string msg = std::string(method_name) + ": 'set' is required and must be a string ('blacklist' or 'droplist')";
57
+ std::string msg = std::string(method_name) + ": 'set' is required and must be a string";
53
58
  Napi::TypeError::New(env, msg).ThrowAsJavaScriptException();
54
59
  return std::nullopt;
55
60
  }
56
61
  std::string set_str = opts.Get("set").As<Napi::String>().Utf8Value();
57
- if (set_str == "blacklist") return nft::TargetSet::Blacklist;
58
- if (set_str == "droplist") return nft::TargetSet::Droplist;
59
- std::string msg = std::string(method_name) + ": 'set' must be 'blacklist' or 'droplist'";
62
+
63
+ for (size_t i = 0; i < cfg.sets.size(); ++i) {
64
+ if (cfg.sets[i].name != set_str) continue;
65
+ bool is_ip = (cfg.sets[i].kind == nft::SetKind::InIP || cfg.sets[i].kind == nft::SetKind::OutIP);
66
+ bool is_port = (cfg.sets[i].kind == nft::SetKind::OutPort);
67
+ if ((allow_ip && is_ip) || (allow_port && is_port)) return i;
68
+ }
69
+
70
+ // Build valid names for error message
71
+ std::string valid;
72
+ for (size_t i = 0; i < cfg.sets.size(); ++i) {
73
+ bool is_ip = (cfg.sets[i].kind == nft::SetKind::InIP || cfg.sets[i].kind == nft::SetKind::OutIP);
74
+ bool is_port = (cfg.sets[i].kind == nft::SetKind::OutPort);
75
+ if (!((allow_ip && is_ip) || (allow_port && is_port))) continue;
76
+ if (!valid.empty()) valid += ", ";
77
+ valid += "'" + cfg.sets[i].name + "'";
78
+ }
79
+ std::string msg = std::string(method_name) + ": 'set' must be one of: " + valid;
60
80
  Napi::Error::New(env, msg).ThrowAsJavaScriptException();
61
81
  return std::nullopt;
62
82
  }
63
83
 
64
84
  // Returns timeout in ms (0 = permanent/no timeout). Sets JS exception on error.
65
- // Returns std::nullopt on error.
66
85
  static std::optional<uint64_t> parse_timeout(Napi::Env env, Napi::Object opts, const char* method_name) {
67
86
  if (!opts.Has("timeout") || opts.Get("timeout").IsUndefined() || opts.Get("timeout").IsNull()) {
68
87
  return uint64_t{0}; // permanent
@@ -82,6 +101,129 @@ static std::optional<uint64_t> parse_timeout(Napi::Env env, Napi::Object opts, c
82
101
  return static_cast<uint64_t>(timeout_sec * 1000.0);
83
102
  }
84
103
 
104
+ // Parse a single port from opts.port
105
+ static std::optional<uint16_t> parse_port_value(Napi::Env env, Napi::Object opts,
106
+ const char* method_name) {
107
+ if (!opts.Has("port") || !opts.Get("port").IsNumber()) {
108
+ std::string msg = std::string(method_name) + ": 'port' is required and must be a number";
109
+ Napi::TypeError::New(env, msg).ThrowAsJavaScriptException();
110
+ return std::nullopt;
111
+ }
112
+ double val = opts.Get("port").As<Napi::Number>().DoubleValue();
113
+ PortVal pv = parse_port(val);
114
+ if (!pv.valid) {
115
+ Napi::Error::New(env, std::string(method_name) + ": 'port' must be an integer 0-65535")
116
+ .ThrowAsJavaScriptException();
117
+ return std::nullopt;
118
+ }
119
+ return pv.port;
120
+ }
121
+
122
+ // Parse array of ports from opts.ports
123
+ static std::vector<uint16_t> parse_port_array(Napi::Env env, Napi::Array arr,
124
+ const char* method_name) {
125
+ std::vector<uint16_t> ports;
126
+ ports.reserve(arr.Length());
127
+
128
+ for (uint32_t i = 0; i < arr.Length(); ++i) {
129
+ Napi::Value val = arr[i];
130
+ if (!val.IsNumber()) {
131
+ Napi::TypeError::New(env, std::string(method_name) + ": each port must be a number")
132
+ .ThrowAsJavaScriptException();
133
+ return {};
134
+ }
135
+ double d = val.As<Napi::Number>().DoubleValue();
136
+ PortVal pv = parse_port(d);
137
+ if (!pv.valid) {
138
+ Napi::Error::New(env, std::string(method_name) +
139
+ ": invalid port at index " + std::to_string(i))
140
+ .ThrowAsJavaScriptException();
141
+ return {};
142
+ }
143
+ ports.push_back(pv.port);
144
+ }
145
+ return ports;
146
+ }
147
+
148
+ // Parse optional protocol: 'tcp', 'udp', or absent (both).
149
+ // Returns 0 for both, PROTO_TCP for tcp, PROTO_UDP for udp.
150
+ // Returns 255 on error (after throwing JS exception).
151
+ static uint8_t parse_protocol(Napi::Env env, Napi::Object opts, const char* method_name) {
152
+ if (!opts.Has("protocol") || opts.Get("protocol").IsUndefined() || opts.Get("protocol").IsNull()) {
153
+ return 0; // both
154
+ }
155
+ Napi::Value pv = opts.Get("protocol");
156
+ if (!pv.IsString()) {
157
+ std::string msg = std::string(method_name) + ": 'protocol' must be 'tcp' or 'udp'";
158
+ Napi::TypeError::New(env, msg).ThrowAsJavaScriptException();
159
+ return 255;
160
+ }
161
+ std::string proto = pv.As<Napi::String>().Utf8Value();
162
+ if (proto == "tcp") return nft::PROTO_TCP;
163
+ if (proto == "udp") return nft::PROTO_UDP;
164
+ std::string msg = std::string(method_name) + ": 'protocol' must be 'tcp' or 'udp'";
165
+ Napi::Error::New(env, msg).ThrowAsJavaScriptException();
166
+ return 255;
167
+ }
168
+
169
+ static std::vector<PortElem> make_port_elems(uint16_t port, uint8_t proto) {
170
+ if (proto == 0) {
171
+ return {{nft::PROTO_TCP, port}, {nft::PROTO_UDP, port}};
172
+ }
173
+ return {{proto, port}};
174
+ }
175
+
176
+ static std::vector<PortElem> make_port_elems_bulk(const std::vector<uint16_t>& ports, uint8_t proto) {
177
+ std::vector<PortElem> elems;
178
+ if (proto == 0) {
179
+ elems.reserve(ports.size() * 2);
180
+ for (uint16_t p : ports) {
181
+ elems.push_back({nft::PROTO_TCP, p});
182
+ elems.push_back({nft::PROTO_UDP, p});
183
+ }
184
+ } else {
185
+ elems.reserve(ports.size());
186
+ for (uint16_t p : ports) {
187
+ elems.push_back({proto, p});
188
+ }
189
+ }
190
+ return elems;
191
+ }
192
+
193
+ // Helper: parse optional string array from opts, returns empty vector if not present
194
+ static std::vector<std::string> parse_optional_string_array(Napi::Env env, Napi::Object opts,
195
+ const char* field_name,
196
+ const char* context) {
197
+ if (!opts.Has(field_name) || opts.Get(field_name).IsUndefined() || opts.Get(field_name).IsNull()) {
198
+ return {};
199
+ }
200
+ Napi::Value val = opts.Get(field_name);
201
+ if (!val.IsArray()) {
202
+ std::string msg = std::string(context) + ": '" + field_name + "' must be an array of strings";
203
+ Napi::TypeError::New(env, msg).ThrowAsJavaScriptException();
204
+ return {};
205
+ }
206
+ Napi::Array arr = val.As<Napi::Array>();
207
+ std::vector<std::string> result;
208
+ result.reserve(arr.Length());
209
+ for (uint32_t i = 0; i < arr.Length(); ++i) {
210
+ Napi::Value v = arr[i];
211
+ if (!v.IsString()) {
212
+ std::string msg = std::string(context) + ": '" + field_name + "[" + std::to_string(i) + "]' must be a string";
213
+ Napi::TypeError::New(env, msg).ThrowAsJavaScriptException();
214
+ return {};
215
+ }
216
+ std::string name = v.As<Napi::String>().Utf8Value();
217
+ if (name.empty()) {
218
+ std::string msg = std::string(context) + ": '" + field_name + "[" + std::to_string(i) + "]' must not be empty";
219
+ Napi::Error::New(env, msg).ThrowAsJavaScriptException();
220
+ return {};
221
+ }
222
+ result.push_back(std::move(name));
223
+ }
224
+ return result;
225
+ }
226
+
85
227
  Napi::Object NftManager::Init(Napi::Env env, Napi::Object exports) {
86
228
  Napi::Function func = DefineClass(env, "NftManager", {
87
229
  InstanceMethod<&NftManager::CreateTable>("createTable"),
@@ -90,6 +232,10 @@ Napi::Object NftManager::Init(Napi::Env env, Napi::Object exports) {
90
232
  InstanceMethod<&NftManager::AddAddresses>("addAddresses"),
91
233
  InstanceMethod<&NftManager::RemoveAddresses>("removeAddresses"),
92
234
  InstanceMethod<&NftManager::DeleteTable>("deleteTable"),
235
+ InstanceMethod<&NftManager::AddPort>("addPort"),
236
+ InstanceMethod<&NftManager::RemovePort>("removePort"),
237
+ InstanceMethod<&NftManager::AddPorts>("addPorts"),
238
+ InstanceMethod<&NftManager::RemovePorts>("removePorts"),
93
239
  });
94
240
 
95
241
  env.SetInstanceData(new Napi::FunctionReference(Napi::Persistent(func)));
@@ -102,73 +248,138 @@ NftManager::NftManager(const Napi::CallbackInfo& info)
102
248
  : Napi::ObjectWrap<NftManager>(info) {
103
249
  Napi::Env env = info.Env();
104
250
 
105
- // 1. Validate options object exists
106
251
  if (info.Length() < 1 || !info[0].IsObject()) {
107
252
  Napi::TypeError::New(env,
108
- "NftManager requires options object with tableName, blacklistSetName, droplistSetName")
253
+ "NftManager requires options object with tableName and sets")
109
254
  .ThrowAsJavaScriptException();
110
255
  return;
111
256
  }
112
257
 
113
258
  Napi::Object opts = info[0].As<Napi::Object>();
114
259
 
115
- // 2. Extract and validate 3 required string fields
260
+ // tableName required string
116
261
  if (!opts.Has("tableName") || !opts.Get("tableName").IsString()) {
117
262
  Napi::TypeError::New(env, "NftManager: 'tableName' is required and must be a string")
118
263
  .ThrowAsJavaScriptException();
119
264
  return;
120
265
  }
121
- if (!opts.Has("blacklistSetName") || !opts.Get("blacklistSetName").IsString()) {
122
- Napi::TypeError::New(env, "NftManager: 'blacklistSetName' is required and must be a string")
266
+
267
+ // sets required non-empty array
268
+ if (!opts.Has("sets") || !opts.Get("sets").IsArray()) {
269
+ Napi::TypeError::New(env, "NftManager: 'sets' is required and must be an array of strings")
123
270
  .ThrowAsJavaScriptException();
124
271
  return;
125
272
  }
126
- if (!opts.Has("droplistSetName") || !opts.Get("droplistSetName").IsString()) {
127
- Napi::TypeError::New(env, "NftManager: 'droplistSetName' is required and must be a string")
273
+
274
+ Napi::Array sets_arr = opts.Get("sets").As<Napi::Array>();
275
+ uint32_t len = sets_arr.Length();
276
+
277
+ if (len == 0) {
278
+ Napi::Error::New(env, "NftManager: 'sets' must contain at least one set name")
128
279
  .ThrowAsJavaScriptException();
129
280
  return;
130
281
  }
131
282
 
283
+ // Parse required sets (InIP)
284
+ std::vector<std::string> in_sets;
285
+ in_sets.reserve(len);
286
+ for (uint32_t i = 0; i < len; ++i) {
287
+ Napi::Value val = sets_arr[i];
288
+ if (!val.IsString()) {
289
+ Napi::TypeError::New(env, "NftManager: 'sets[" + std::to_string(i) + "]' must be a string")
290
+ .ThrowAsJavaScriptException();
291
+ return;
292
+ }
293
+ std::string name = val.As<Napi::String>().Utf8Value();
294
+ if (name.empty()) {
295
+ Napi::Error::New(env, "NftManager: 'sets[" + std::to_string(i) + "]' must not be empty")
296
+ .ThrowAsJavaScriptException();
297
+ return;
298
+ }
299
+ in_sets.push_back(std::move(name));
300
+ }
301
+
302
+ // Parse optional outSets (OutIP)
303
+ std::vector<std::string> out_sets = parse_optional_string_array(env, opts, "outSets", "NftManager");
304
+ if (env.IsExceptionPending()) return;
305
+
306
+ // Parse optional outPortSets (OutPort)
307
+ std::vector<std::string> out_port_sets = parse_optional_string_array(env, opts, "outPortSets", "NftManager");
308
+ if (env.IsExceptionPending()) return;
309
+
310
+ // Cross-array duplicate check: all names must be unique across all arrays
311
+ std::vector<std::string> all_names;
312
+ all_names.reserve(in_sets.size() + out_sets.size() + out_port_sets.size());
313
+ all_names.insert(all_names.end(), in_sets.begin(), in_sets.end());
314
+ all_names.insert(all_names.end(), out_sets.begin(), out_sets.end());
315
+ all_names.insert(all_names.end(), out_port_sets.begin(), out_port_sets.end());
316
+ for (size_t i = 0; i < all_names.size(); ++i) {
317
+ for (size_t j = i + 1; j < all_names.size(); ++j) {
318
+ if (all_names[i] == all_names[j]) {
319
+ Napi::Error::New(env, "NftManager: duplicate set name '" + all_names[i] + "'")
320
+ .ThrowAsJavaScriptException();
321
+ return;
322
+ }
323
+ }
324
+ }
325
+
132
326
  std::string table_name = opts.Get("tableName").As<Napi::String>().Utf8Value();
133
- std::string blacklist_set_name = opts.Get("blacklistSetName").As<Napi::String>().Utf8Value();
134
- std::string droplist_set_name = opts.Get("droplistSetName").As<Napi::String>().Utf8Value();
135
327
 
136
- // 3. Create NftConfig from names
137
328
  config_ = std::make_shared<const nft::NftConfig>(
138
- nft::NftConfig::from_names(table_name, blacklist_set_name, droplist_set_name));
329
+ nft::NftConfig::from_names(table_name, in_sets, out_sets, out_port_sets));
139
330
 
140
- // 4. Open netlink socket
141
331
  sock_ = std::make_shared<NlSocket>();
142
332
 
143
- // 5. Validate socket
144
333
  if (!sock_->is_valid()) {
145
334
  Napi::Error::New(env, "Failed to open netlink socket. Ensure CAP_NET_ADMIN or root.")
146
335
  .ThrowAsJavaScriptException();
147
336
  return;
148
337
  }
338
+
339
+ valid_ = true;
149
340
  }
150
341
 
342
+ // ── Table lifecycle ─────────────────────────────────────────────────────────
343
+
151
344
  Napi::Value NftManager::CreateTable(const Napi::CallbackInfo& info) {
152
345
  Napi::Env env = info.Env();
153
-
346
+ if (!valid_) {
347
+ Napi::Error::New(env, "NftManager is not initialized").ThrowAsJavaScriptException();
348
+ return env.Undefined();
349
+ }
154
350
  auto deferred = Napi::Promise::Deferred::New(env);
155
351
  auto promise = deferred.Promise();
156
352
  Enqueue(std::make_unique<CreateTableOp>(config_), std::move(deferred));
157
353
  return promise;
158
354
  }
159
355
 
160
- Napi::Value NftManager::AddAddress(const Napi::CallbackInfo& info) {
356
+ Napi::Value NftManager::DeleteTable(const Napi::CallbackInfo& info) {
161
357
  Napi::Env env = info.Env();
358
+ if (!valid_) {
359
+ Napi::Error::New(env, "NftManager is not initialized").ThrowAsJavaScriptException();
360
+ return env.Undefined();
361
+ }
362
+ auto deferred = Napi::Promise::Deferred::New(env);
363
+ auto promise = deferred.Promise();
364
+ Enqueue(std::make_unique<DeleteTableOp>(config_), std::move(deferred));
365
+ return promise;
366
+ }
162
367
 
368
+ // ── IP address operations ───────────────────────────────────────────────────
369
+
370
+ Napi::Value NftManager::AddAddress(const Napi::CallbackInfo& info) {
371
+ Napi::Env env = info.Env();
372
+ if (!valid_) {
373
+ Napi::Error::New(env, "NftManager is not initialized").ThrowAsJavaScriptException();
374
+ return env.Undefined();
375
+ }
163
376
  if (info.Length() < 1 || !info[0].IsObject()) {
164
377
  Napi::TypeError::New(env, "addAddress requires an options object: { ip, set, timeout? }")
165
378
  .ThrowAsJavaScriptException();
166
379
  return env.Undefined();
167
380
  }
168
-
169
381
  Napi::Object opts = info[0].As<Napi::Object>();
170
382
 
171
- // ip — required string
172
383
  if (!opts.Has("ip") || !opts.Get("ip").IsString()) {
173
384
  Napi::TypeError::New(env, "addAddress: 'ip' is required and must be a string")
174
385
  .ThrowAsJavaScriptException();
@@ -176,25 +387,21 @@ Napi::Value NftManager::AddAddress(const Napi::CallbackInfo& info) {
176
387
  }
177
388
  std::string ip = opts.Get("ip").As<Napi::String>().Utf8Value();
178
389
 
179
- // set required string ('blacklist' or 'droplist')
180
- auto target = parse_target_set(env, opts, "addAddress");
181
- if (!target) return env.Undefined();
390
+ auto set_idx = parse_set_name(env, opts, "addAddress", *config_, true, false);
391
+ if (!set_idx) return env.Undefined();
182
392
 
183
- // timeout — optional number (seconds). If absent, 0 = permanent
184
393
  auto timeout_ms = parse_timeout(env, opts, "addAddress");
185
394
  if (!timeout_ms) return env.Undefined();
186
395
 
187
- // Validate IP
188
396
  IpAddr addr = parse_ip(ip);
189
397
  if (addr.family == IpFamily::Invalid) {
190
- Napi::Error::New(env, "Invalid IP address: " + ip)
191
- .ThrowAsJavaScriptException();
398
+ Napi::Error::New(env, "Invalid IP address: " + ip).ThrowAsJavaScriptException();
192
399
  return env.Undefined();
193
400
  }
194
401
 
195
402
  std::vector<ParsedAddr> addrs;
196
403
  addrs.push_back(to_parsed_addr(addr));
197
- auto op = std::make_unique<BulkAddSetElemOp>(std::move(addrs), *timeout_ms, config_, *target);
404
+ auto op = std::make_unique<BulkAddSetElemOp>(std::move(addrs), *timeout_ms, config_, *set_idx);
198
405
 
199
406
  auto deferred = Napi::Promise::Deferred::New(env);
200
407
  auto promise = deferred.Promise();
@@ -204,16 +411,17 @@ Napi::Value NftManager::AddAddress(const Napi::CallbackInfo& info) {
204
411
 
205
412
  Napi::Value NftManager::RemoveAddress(const Napi::CallbackInfo& info) {
206
413
  Napi::Env env = info.Env();
207
-
414
+ if (!valid_) {
415
+ Napi::Error::New(env, "NftManager is not initialized").ThrowAsJavaScriptException();
416
+ return env.Undefined();
417
+ }
208
418
  if (info.Length() < 1 || !info[0].IsObject()) {
209
419
  Napi::TypeError::New(env, "removeAddress requires an options object: { ip, set }")
210
420
  .ThrowAsJavaScriptException();
211
421
  return env.Undefined();
212
422
  }
213
-
214
423
  Napi::Object opts = info[0].As<Napi::Object>();
215
424
 
216
- // ip — required string
217
425
  if (!opts.Has("ip") || !opts.Get("ip").IsString()) {
218
426
  Napi::TypeError::New(env, "removeAddress: 'ip' is required and must be a string")
219
427
  .ThrowAsJavaScriptException();
@@ -221,21 +429,18 @@ Napi::Value NftManager::RemoveAddress(const Napi::CallbackInfo& info) {
221
429
  }
222
430
  std::string ip = opts.Get("ip").As<Napi::String>().Utf8Value();
223
431
 
224
- // set required string ('blacklist' or 'droplist')
225
- auto target = parse_target_set(env, opts, "removeAddress");
226
- if (!target) return env.Undefined();
432
+ auto set_idx = parse_set_name(env, opts, "removeAddress", *config_, true, false);
433
+ if (!set_idx) return env.Undefined();
227
434
 
228
- // Validate IP
229
435
  IpAddr addr = parse_ip(ip);
230
436
  if (addr.family == IpFamily::Invalid) {
231
- Napi::Error::New(env, "Invalid IP address: " + ip)
232
- .ThrowAsJavaScriptException();
437
+ Napi::Error::New(env, "Invalid IP address: " + ip).ThrowAsJavaScriptException();
233
438
  return env.Undefined();
234
439
  }
235
440
 
236
441
  std::vector<ParsedAddr> addrs;
237
442
  addrs.push_back(to_parsed_addr(addr));
238
- auto op = std::make_unique<BulkDelSetElemOp>(std::move(addrs), config_, *target);
443
+ auto op = std::make_unique<BulkDelSetElemOp>(std::move(addrs), config_, *set_idx);
239
444
 
240
445
  auto deferred = Napi::Promise::Deferred::New(env);
241
446
  auto promise = deferred.Promise();
@@ -245,16 +450,17 @@ Napi::Value NftManager::RemoveAddress(const Napi::CallbackInfo& info) {
245
450
 
246
451
  Napi::Value NftManager::AddAddresses(const Napi::CallbackInfo& info) {
247
452
  Napi::Env env = info.Env();
248
-
453
+ if (!valid_) {
454
+ Napi::Error::New(env, "NftManager is not initialized").ThrowAsJavaScriptException();
455
+ return env.Undefined();
456
+ }
249
457
  if (info.Length() < 1 || !info[0].IsObject()) {
250
458
  Napi::TypeError::New(env, "addAddresses requires an options object: { ips, set, timeout? }")
251
459
  .ThrowAsJavaScriptException();
252
460
  return env.Undefined();
253
461
  }
254
-
255
462
  Napi::Object opts = info[0].As<Napi::Object>();
256
463
 
257
- // ips — required Array of strings
258
464
  if (!opts.Has("ips") || !opts.Get("ips").IsArray()) {
259
465
  Napi::TypeError::New(env, "addAddresses: 'ips' is required and must be an array of strings")
260
466
  .ThrowAsJavaScriptException();
@@ -262,18 +468,15 @@ Napi::Value NftManager::AddAddresses(const Napi::CallbackInfo& info) {
262
468
  }
263
469
  Napi::Array arr = opts.Get("ips").As<Napi::Array>();
264
470
 
265
- // set required string ('blacklist' or 'droplist')
266
- auto target = parse_target_set(env, opts, "addAddresses");
267
- if (!target) return env.Undefined();
471
+ auto set_idx = parse_set_name(env, opts, "addAddresses", *config_, true, false);
472
+ if (!set_idx) return env.Undefined();
268
473
 
269
- // timeout — optional number (seconds). If absent, 0 = permanent
270
474
  auto timeout_ms = parse_timeout(env, opts, "addAddresses");
271
475
  if (!timeout_ms) return env.Undefined();
272
476
 
273
477
  std::vector<ParsedAddr> addrs = parse_ip_array(env, arr);
274
478
  if (env.IsExceptionPending()) return env.Undefined();
275
479
 
276
- // Empty arrays: early-exit with resolved promise
277
480
  if (addrs.empty()) {
278
481
  auto deferred = Napi::Promise::Deferred::New(env);
279
482
  auto promise = deferred.Promise();
@@ -281,8 +484,7 @@ Napi::Value NftManager::AddAddresses(const Napi::CallbackInfo& info) {
281
484
  return promise;
282
485
  }
283
486
 
284
- auto op = std::make_unique<BulkAddSetElemOp>(std::move(addrs), *timeout_ms, config_, *target);
285
-
487
+ auto op = std::make_unique<BulkAddSetElemOp>(std::move(addrs), *timeout_ms, config_, *set_idx);
286
488
  auto deferred = Napi::Promise::Deferred::New(env);
287
489
  auto promise = deferred.Promise();
288
490
  Enqueue(std::move(op), std::move(deferred));
@@ -291,16 +493,17 @@ Napi::Value NftManager::AddAddresses(const Napi::CallbackInfo& info) {
291
493
 
292
494
  Napi::Value NftManager::RemoveAddresses(const Napi::CallbackInfo& info) {
293
495
  Napi::Env env = info.Env();
294
-
496
+ if (!valid_) {
497
+ Napi::Error::New(env, "NftManager is not initialized").ThrowAsJavaScriptException();
498
+ return env.Undefined();
499
+ }
295
500
  if (info.Length() < 1 || !info[0].IsObject()) {
296
501
  Napi::TypeError::New(env, "removeAddresses requires an options object: { ips, set }")
297
502
  .ThrowAsJavaScriptException();
298
503
  return env.Undefined();
299
504
  }
300
-
301
505
  Napi::Object opts = info[0].As<Napi::Object>();
302
506
 
303
- // ips — required Array of strings
304
507
  if (!opts.Has("ips") || !opts.Get("ips").IsArray()) {
305
508
  Napi::TypeError::New(env, "removeAddresses: 'ips' is required and must be an array of strings")
306
509
  .ThrowAsJavaScriptException();
@@ -308,14 +511,12 @@ Napi::Value NftManager::RemoveAddresses(const Napi::CallbackInfo& info) {
308
511
  }
309
512
  Napi::Array arr = opts.Get("ips").As<Napi::Array>();
310
513
 
311
- // set required string ('blacklist' or 'droplist')
312
- auto target = parse_target_set(env, opts, "removeAddresses");
313
- if (!target) return env.Undefined();
514
+ auto set_idx = parse_set_name(env, opts, "removeAddresses", *config_, true, false);
515
+ if (!set_idx) return env.Undefined();
314
516
 
315
517
  std::vector<ParsedAddr> addrs = parse_ip_array(env, arr);
316
518
  if (env.IsExceptionPending()) return env.Undefined();
317
519
 
318
- // Empty arrays: early-exit with resolved promise
319
520
  if (addrs.empty()) {
320
521
  auto deferred = Napi::Promise::Deferred::New(env);
321
522
  auto promise = deferred.Promise();
@@ -323,7 +524,42 @@ Napi::Value NftManager::RemoveAddresses(const Napi::CallbackInfo& info) {
323
524
  return promise;
324
525
  }
325
526
 
326
- auto op = std::make_unique<BulkDelSetElemOp>(std::move(addrs), config_, *target);
527
+ auto op = std::make_unique<BulkDelSetElemOp>(std::move(addrs), config_, *set_idx);
528
+ auto deferred = Napi::Promise::Deferred::New(env);
529
+ auto promise = deferred.Promise();
530
+ Enqueue(std::move(op), std::move(deferred));
531
+ return promise;
532
+ }
533
+
534
+ // ── Port operations ─────────────────────────────────────────────────────────
535
+
536
+ Napi::Value NftManager::AddPort(const Napi::CallbackInfo& info) {
537
+ Napi::Env env = info.Env();
538
+ if (!valid_) {
539
+ Napi::Error::New(env, "NftManager is not initialized").ThrowAsJavaScriptException();
540
+ return env.Undefined();
541
+ }
542
+ if (info.Length() < 1 || !info[0].IsObject()) {
543
+ Napi::TypeError::New(env, "addPort requires an options object: { port, set, timeout? }")
544
+ .ThrowAsJavaScriptException();
545
+ return env.Undefined();
546
+ }
547
+ Napi::Object opts = info[0].As<Napi::Object>();
548
+
549
+ auto port = parse_port_value(env, opts, "addPort");
550
+ if (!port) return env.Undefined();
551
+
552
+ auto set_idx = parse_set_name(env, opts, "addPort", *config_, false, true);
553
+ if (!set_idx) return env.Undefined();
554
+
555
+ uint8_t proto = parse_protocol(env, opts, "addPort");
556
+ if (proto == 255) return env.Undefined();
557
+
558
+ auto timeout_ms = parse_timeout(env, opts, "addPort");
559
+ if (!timeout_ms) return env.Undefined();
560
+
561
+ auto elems = make_port_elems(*port, proto);
562
+ auto op = std::make_unique<BulkAddPortElemOp>(std::move(elems), *timeout_ms, config_, *set_idx);
327
563
 
328
564
  auto deferred = Napi::Promise::Deferred::New(env);
329
565
  auto promise = deferred.Promise();
@@ -331,15 +567,130 @@ Napi::Value NftManager::RemoveAddresses(const Napi::CallbackInfo& info) {
331
567
  return promise;
332
568
  }
333
569
 
334
- Napi::Value NftManager::DeleteTable(const Napi::CallbackInfo& info) {
570
+ Napi::Value NftManager::RemovePort(const Napi::CallbackInfo& info) {
335
571
  Napi::Env env = info.Env();
572
+ if (!valid_) {
573
+ Napi::Error::New(env, "NftManager is not initialized").ThrowAsJavaScriptException();
574
+ return env.Undefined();
575
+ }
576
+ if (info.Length() < 1 || !info[0].IsObject()) {
577
+ Napi::TypeError::New(env, "removePort requires an options object: { port, set }")
578
+ .ThrowAsJavaScriptException();
579
+ return env.Undefined();
580
+ }
581
+ Napi::Object opts = info[0].As<Napi::Object>();
582
+
583
+ auto port = parse_port_value(env, opts, "removePort");
584
+ if (!port) return env.Undefined();
585
+
586
+ auto set_idx = parse_set_name(env, opts, "removePort", *config_, false, true);
587
+ if (!set_idx) return env.Undefined();
588
+
589
+ uint8_t proto = parse_protocol(env, opts, "removePort");
590
+ if (proto == 255) return env.Undefined();
591
+
592
+ auto elems = make_port_elems(*port, proto);
593
+ auto op = std::make_unique<BulkDelPortElemOp>(std::move(elems), config_, *set_idx);
336
594
 
337
595
  auto deferred = Napi::Promise::Deferred::New(env);
338
596
  auto promise = deferred.Promise();
339
- Enqueue(std::make_unique<DeleteTableOp>(config_), std::move(deferred));
597
+ Enqueue(std::move(op), std::move(deferred));
598
+ return promise;
599
+ }
600
+
601
+ Napi::Value NftManager::AddPorts(const Napi::CallbackInfo& info) {
602
+ Napi::Env env = info.Env();
603
+ if (!valid_) {
604
+ Napi::Error::New(env, "NftManager is not initialized").ThrowAsJavaScriptException();
605
+ return env.Undefined();
606
+ }
607
+ if (info.Length() < 1 || !info[0].IsObject()) {
608
+ Napi::TypeError::New(env, "addPorts requires an options object: { ports, set, timeout? }")
609
+ .ThrowAsJavaScriptException();
610
+ return env.Undefined();
611
+ }
612
+ Napi::Object opts = info[0].As<Napi::Object>();
613
+
614
+ if (!opts.Has("ports") || !opts.Get("ports").IsArray()) {
615
+ Napi::TypeError::New(env, "addPorts: 'ports' is required and must be an array of numbers")
616
+ .ThrowAsJavaScriptException();
617
+ return env.Undefined();
618
+ }
619
+ Napi::Array arr = opts.Get("ports").As<Napi::Array>();
620
+
621
+ auto set_idx = parse_set_name(env, opts, "addPorts", *config_, false, true);
622
+ if (!set_idx) return env.Undefined();
623
+
624
+ uint8_t proto = parse_protocol(env, opts, "addPorts");
625
+ if (proto == 255) return env.Undefined();
626
+
627
+ auto timeout_ms = parse_timeout(env, opts, "addPorts");
628
+ if (!timeout_ms) return env.Undefined();
629
+
630
+ std::vector<uint16_t> ports = parse_port_array(env, arr, "addPorts");
631
+ if (env.IsExceptionPending()) return env.Undefined();
632
+
633
+ if (ports.empty()) {
634
+ auto deferred = Napi::Promise::Deferred::New(env);
635
+ auto promise = deferred.Promise();
636
+ deferred.Resolve(env.Undefined());
637
+ return promise;
638
+ }
639
+
640
+ auto elems = make_port_elems_bulk(ports, proto);
641
+ auto op = std::make_unique<BulkAddPortElemOp>(std::move(elems), *timeout_ms, config_, *set_idx);
642
+ auto deferred = Napi::Promise::Deferred::New(env);
643
+ auto promise = deferred.Promise();
644
+ Enqueue(std::move(op), std::move(deferred));
645
+ return promise;
646
+ }
647
+
648
+ Napi::Value NftManager::RemovePorts(const Napi::CallbackInfo& info) {
649
+ Napi::Env env = info.Env();
650
+ if (!valid_) {
651
+ Napi::Error::New(env, "NftManager is not initialized").ThrowAsJavaScriptException();
652
+ return env.Undefined();
653
+ }
654
+ if (info.Length() < 1 || !info[0].IsObject()) {
655
+ Napi::TypeError::New(env, "removePorts requires an options object: { ports, set }")
656
+ .ThrowAsJavaScriptException();
657
+ return env.Undefined();
658
+ }
659
+ Napi::Object opts = info[0].As<Napi::Object>();
660
+
661
+ if (!opts.Has("ports") || !opts.Get("ports").IsArray()) {
662
+ Napi::TypeError::New(env, "removePorts: 'ports' is required and must be an array of numbers")
663
+ .ThrowAsJavaScriptException();
664
+ return env.Undefined();
665
+ }
666
+ Napi::Array arr = opts.Get("ports").As<Napi::Array>();
667
+
668
+ auto set_idx = parse_set_name(env, opts, "removePorts", *config_, false, true);
669
+ if (!set_idx) return env.Undefined();
670
+
671
+ uint8_t proto = parse_protocol(env, opts, "removePorts");
672
+ if (proto == 255) return env.Undefined();
673
+
674
+ std::vector<uint16_t> ports = parse_port_array(env, arr, "removePorts");
675
+ if (env.IsExceptionPending()) return env.Undefined();
676
+
677
+ if (ports.empty()) {
678
+ auto deferred = Napi::Promise::Deferred::New(env);
679
+ auto promise = deferred.Promise();
680
+ deferred.Resolve(env.Undefined());
681
+ return promise;
682
+ }
683
+
684
+ auto elems = make_port_elems_bulk(ports, proto);
685
+ auto op = std::make_unique<BulkDelPortElemOp>(std::move(elems), config_, *set_idx);
686
+ auto deferred = Napi::Promise::Deferred::New(env);
687
+ auto promise = deferred.Promise();
688
+ Enqueue(std::move(op), std::move(deferred));
340
689
  return promise;
341
690
  }
342
691
 
692
+ // ── Queue management ────────────────────────────────────────────────────────
693
+
343
694
  void NftManager::Enqueue(std::unique_ptr<NlOperation> op, Napi::Promise::Deferred deferred) {
344
695
  queue_.push({std::move(op), std::move(deferred)});
345
696
  DrainQueue();