underpost 2.90.4 → 2.92.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/src/cli/ssh.js CHANGED
@@ -4,7 +4,13 @@
4
4
  * @namespace UnderpostSSH
5
5
  */
6
6
 
7
+ import { generateRandomPasswordSelection } from '../client/components/core/CommonJs.js';
8
+ import Dns from '../server/dns.js';
7
9
  import { shellExec } from '../server/process.js';
10
+ import { loggerFactory } from '../server/logger.js';
11
+ import fs from 'fs-extra';
12
+
13
+ const logger = loggerFactory(import.meta);
8
14
 
9
15
  /**
10
16
  * @class UnderpostSSH
@@ -14,30 +20,435 @@ import { shellExec } from '../server/process.js';
14
20
  class UnderpostSSH {
15
21
  static API = {
16
22
  /**
17
- * @method callback
18
- * @description Manages SSH key generation and connection setup based on the default deployment ID.
19
- * This function will either generate a new SSH key pair or import an existing one,
20
- * then initiate the SSH connection process.
21
- * @param {object} [options={ generate: false }] - Options for the SSH callback.
22
- * @param {boolean} [options.generate=false] - If true, generates a new SSH key pair. Otherwise, it imports the existing one.
23
+ * Main callback function for SSH operations including user management, key import/export, and SSH service configuration.
24
+ * @async
25
+ * @function callback
23
26
  * @memberof UnderpostSSH
27
+ * @param {Object} options - Configuration options for SSH operations
28
+ * @param {string} [options.deployId=''] - Deployment ID context for SSH operations
29
+ * @param {boolean} [options.generate=false] - Generate new SSH credentials
30
+ * @param {string} [options.user=''] - SSH user name (defaults to 'root')
31
+ * @param {string} [options.password=''] - SSH user password (auto-generated if not provided, overridden by saved config if user exists)
32
+ * @param {string} [options.host=''] - SSH host address (defaults to public IP, overridden by saved config if user exists)
33
+ * @param {string} [options.filter=''] - Filter for user/group listings
34
+ * @param {string} [options.groups=''] - Comma-separated list of groups for the user (defaults to 'wheel')
35
+ * @param {number} [options.port=22] - SSH port number
36
+ * @param {boolean} [options.start=false] - Start SSH service with hardened configuration
37
+ * @param {boolean} [options.userAdd=false] - Add a new SSH user and generate keys
38
+ * @param {boolean} [options.userRemove=false] - Remove an SSH user and cleanup keys
39
+ * @param {boolean} [options.userLs=false] - List all SSH users and groups
40
+ * @param {boolean} [options.reset=false] - Reset SSH configuration (clear authorized_keys and known_hosts)
41
+ * @param {boolean} [options.keysList=false] - List authorized SSH keys
42
+ * @param {boolean} [options.hostsList=false] - List known SSH hosts
43
+ * @param {boolean} [options.importKeys=false] - Import keys from private backup to user's SSH directory
44
+ * @param {boolean} [options.exportKeys=false] - Export keys from user's SSH directory to private backup
45
+ * @param {boolean} [options.disablePassword=false] - If true, do not set a password for the user
24
46
  * @returns {Promise<void>}
47
+ * @description
48
+ * Handles various SSH operations:
49
+ * - User creation with automatic key generation and backup
50
+ * - User removal with key cleanup
51
+ * - Key import/export between SSH directory and private backup location
52
+ * - SSH service initialization and hardening
53
+ * - User and group listing with optional filtering
25
54
  */
26
55
  callback: async (
27
56
  options = {
57
+ deployId: '',
28
58
  generate: false,
59
+ user: '',
60
+ password: '',
61
+ host: '',
62
+ filter: '',
63
+ groups: '',
64
+ port: 22,
65
+ start: false,
66
+ userAdd: false,
67
+ userRemove: false,
68
+ userLs: false,
69
+ reset: false,
70
+ keysList: false,
71
+ hostsList: false,
72
+ disablePassword: false,
29
73
  },
30
74
  ) => {
31
- // Example usage for importing an existing key:
32
- // node bin/deploy ssh root@<host> <password> import
75
+ let confNode, confNodePath;
76
+ if (!options.user) options.user = 'root';
77
+ if (!options.host) options.host = await Dns.getPublicIp();
78
+ if (!options.password) options.password = options.disablePassword ? '' : generateRandomPasswordSelection(16);
79
+ if (!options.groups) options.groups = 'wheel';
80
+ if (!options.port) options.port = 22;
81
+
82
+ // Load config and override password and host if user exists in config
83
+ if (options.deployId) {
84
+ confNodePath = `./engine-private/conf/${options.deployId}/conf.node.json`;
85
+ confNode = fs.existsSync(confNodePath) ? JSON.parse(fs.readFileSync(confNodePath, 'utf8')) : { users: {} };
86
+
87
+ if (confNode.users && confNode.users[options.user]) {
88
+ if (confNode.users[options.user].password) {
89
+ options.password = confNode.users[options.user].password;
90
+ logger.info(`Using saved password for user ${options.user}`);
91
+ }
92
+ if (confNode.users[options.user].host) {
93
+ options.host = confNode.users[options.user].host;
94
+ logger.info(`Using saved host for user ${options.user}: ${options.host}`);
95
+ }
96
+ }
97
+ }
98
+
99
+ let userHome = shellExec(`getent passwd ${options.user} | cut -d: -f6`, { silent: true, stdout: true }).trim();
100
+ options.userHome = userHome;
101
+
102
+ logger.info('options', options);
103
+
104
+ if (options.reset) {
105
+ shellExec(`> ${userHome}/.ssh/authorized_keys`);
106
+ shellExec(`> ${userHome}/.ssh/known_hosts`);
107
+ return;
108
+ }
109
+
110
+ if (options.keysList) shellExec(`cat ${userHome}/.ssh/authorized_keys`);
111
+ if (options.hostsList) shellExec(`cat ${userHome}/.ssh/known_hosts`);
112
+
113
+ if (options.userLs) {
114
+ const filter = options.filter ? `${options.filter}` : '';
115
+ const groupsOut = shellExec(`getent group${filter ? ` | grep '${filter}'` : ''}`, {
116
+ silent: true,
117
+ stdout: true,
118
+ });
119
+ const usersOut = shellExec(`getent passwd${filter ? ` | grep '${filter}'` : ''}`, {
120
+ silent: true,
121
+ stdout: true,
122
+ });
123
+ console.log('Groups'.bold.blue);
124
+ console.log(`group_name : password_x : GID(Internal Group ID) : user_list`.blue);
125
+ console.log(filter ? groupsOut.replaceAll(filter, filter.red) : groupsOut);
126
+ console.log('Users'.bold.blue);
127
+ console.log(`usuario : x : UID : GID : GECOS : home_dir : shell`.blue);
128
+ console.log(filter ? usersOut.replaceAll(filter, filter.red) : usersOut);
129
+ }
130
+
131
+ if (options.deployId) {
132
+ // Config already loaded above, just use it
133
+ if (!confNode) {
134
+ confNodePath = `./engine-private/conf/${options.deployId}/conf.node.json`;
135
+ confNode = fs.existsSync(confNodePath) ? JSON.parse(fs.readFileSync(confNodePath, 'utf8')) : { users: {} };
136
+ }
137
+
138
+ if (options.userAdd) {
139
+ // Check if user already exists in config
140
+ const userExistsInConfig = confNode.users && confNode.users[options.user];
141
+ const privateCopyDir = `./engine-private/conf/${options.deployId}/users/${options.user}`;
142
+ const privateKeyPath = `${privateCopyDir}/id_rsa`;
143
+ const publicKeyPath = `${privateCopyDir}/id_rsa.pub`;
144
+ const keysExistInBackup = fs.existsSync(privateKeyPath) && fs.existsSync(publicKeyPath);
145
+
146
+ if (userExistsInConfig && keysExistInBackup) {
147
+ logger.info(`User ${options.user} already exists in config. Importing existing keys...`);
148
+
149
+ // Check if system user exists
150
+ const userExists =
151
+ shellExec(`id -u ${options.user} 2>/dev/null || echo "not_found"`, {
152
+ silent: true,
153
+ stdout: true,
154
+ }).trim() !== 'not_found';
155
+
156
+ if (!userExists) {
157
+ shellExec(`useradd -m -s /bin/bash ${options.user}`);
158
+ shellExec(`echo "${options.user}:${options.password}" | chpasswd`);
159
+ if (options.groups)
160
+ for (const group of options.groups.split(',').map((g) => g.trim())) {
161
+ shellExec(`usermod -aG "${group}" "${options.user}"`);
162
+ }
163
+ }
164
+
165
+ const userHome = shellExec(`getent passwd ${options.user} | cut -d: -f6`, {
166
+ silent: true,
167
+ stdout: true,
168
+ }).trim();
169
+ const sshDir = `${userHome}/.ssh`;
170
+
171
+ if (!fs.existsSync(sshDir)) {
172
+ shellExec(`mkdir -p ${sshDir}`);
173
+ shellExec(`chmod 700 ${sshDir}`);
174
+ }
175
+
176
+ const userKeyPath = `${sshDir}/id_rsa`;
177
+ const userPubKeyPath = `${sshDir}/id_rsa.pub`;
178
+
179
+ // Import keys from backup
180
+ fs.copyFileSync(privateKeyPath, userKeyPath);
181
+ fs.copyFileSync(publicKeyPath, userPubKeyPath);
182
+ if (options.disablePassword) {
183
+ shellExec(`cat >> ${sshDir}/authorized_keys <<EOF
184
+ no-port-forwarding,no-X11-forwarding,no-agent-forwarding ${fs.readFileSync(userPubKeyPath, 'utf8')}
185
+ EOF`);
186
+ shellExec(`echo '${options.user} ALL=(ALL) NOPASSWD: ALL' | sudo tee /etc/sudoers.d/90_${options.user}`);
187
+ } else {
188
+ shellExec(`cat ${userPubKeyPath} >> ${sshDir}/authorized_keys`);
189
+ shellExec(`echo "${options.user}:${options.password}" | sudo chpasswd`);
190
+ }
191
+ shellExec(`ssh-keyscan -p ${options.port} -H localhost >> ${sshDir}/known_hosts`);
192
+ shellExec(`ssh-keyscan -p ${options.port} -H 127.0.0.1 >> ${sshDir}/known_hosts`);
193
+ if (options.host) shellExec(`ssh-keyscan -p ${options.port} -H ${options.host} >> ${sshDir}/known_hosts`);
194
+
195
+ shellExec(`chmod 600 ${sshDir}/authorized_keys`);
196
+ shellExec(`chmod 644 ${sshDir}/known_hosts`);
197
+ shellExec(`chmod 600 ${userKeyPath}`);
198
+ shellExec(`chmod 644 ${userPubKeyPath}`);
199
+ shellExec(`chown -R ${options.user}:${options.user} ${sshDir}`);
200
+
201
+ logger.info(`Keys imported from ${privateCopyDir} to ${sshDir}`);
202
+ logger.info(`User added with existing keys`);
203
+ return;
204
+ }
205
+
206
+ // New user or no existing keys - create new user and generate keys
207
+ shellExec(`useradd -m -s /bin/bash ${options.user}`);
208
+ shellExec(`echo "${options.user}:${options.password}" | chpasswd`);
209
+ if (options.groups)
210
+ for (const group of options.groups.split(',').map((g) => g.trim())) {
211
+ shellExec(`usermod -aG "${group}" "${options.user}"`);
212
+ }
213
+
214
+ const userHome = shellExec(`getent passwd ${options.user} | cut -d: -f6`, {
215
+ silent: true,
216
+ stdout: true,
217
+ }).trim();
218
+ const sshDir = `${userHome}/.ssh`;
219
+
220
+ if (!fs.existsSync(sshDir)) {
221
+ shellExec(`mkdir -p ${sshDir}`);
222
+ shellExec(`chmod 700 ${sshDir}`);
223
+ }
224
+
225
+ const keyPath = `${sshDir}/id_rsa`;
226
+ const pubKeyPath = `${sshDir}/id_rsa.pub`;
227
+
228
+ if (!fs.existsSync(keyPath)) {
229
+ shellExec(
230
+ `ssh-keygen -t ed25519 -f ${keyPath} -N "${options.password}" -q -C "${options.user}@${options.host}"`,
231
+ );
232
+ }
233
+
234
+ if (options.disablePassword) {
235
+ shellExec(`cat >> ${sshDir}/authorized_keys <<EOF
236
+ no-port-forwarding,no-X11-forwarding,no-agent-forwarding ${fs.readFileSync(pubKeyPath, 'utf8')}
237
+ EOF`);
238
+ shellExec(`echo '${options.user} ALL=(ALL) NOPASSWD: ALL' | sudo tee /etc/sudoers.d/90_${options.user}`);
239
+ } else {
240
+ shellExec(`cat ${pubKeyPath} >> ${sshDir}/authorized_keys`);
241
+ shellExec(`echo "${options.user}:${options.password}" | sudo chpasswd`);
242
+ }
243
+ shellExec(`ssh-keyscan -p ${options.port} -H localhost >> ${sshDir}/known_hosts`);
244
+ shellExec(`ssh-keyscan -p ${options.port} -H 127.0.0.1 >> ${sshDir}/known_hosts`);
245
+ if (options.host) shellExec(`ssh-keyscan -p ${options.port} -H ${options.host} >> ${sshDir}/known_hosts`);
246
+
247
+ shellExec(`chmod 600 ${sshDir}/authorized_keys`);
248
+ shellExec(`chmod 644 ${sshDir}/known_hosts`);
249
+ shellExec(`chmod 600 ${keyPath}`);
250
+ shellExec(`chmod 644 ${pubKeyPath}`);
251
+ shellExec(`chown -R ${options.user}:${options.user} ${sshDir}`);
252
+
253
+ // Save a copy of the keys to the private folder
254
+ fs.ensureDirSync(privateCopyDir);
255
+
256
+ const privateKeyCopyPath = `${privateCopyDir}/id_rsa`;
257
+ const publicKeyCopyPath = `${privateCopyDir}/id_rsa.pub`;
258
+
259
+ fs.copyFileSync(keyPath, privateKeyCopyPath);
260
+ fs.copyFileSync(pubKeyPath, publicKeyCopyPath);
261
+
262
+ logger.info(`Keys backed up to ${privateCopyDir}`);
33
263
 
34
- // Example usage for generating a new key:
35
- // node bin/deploy ssh root@<host> <password>
264
+ confNode.users[options.user] = {
265
+ ...confNode.users[options.user],
266
+ ...options,
267
+ keyPath,
268
+ pubKeyPath,
269
+ privateKeyCopyPath,
270
+ publicKeyCopyPath,
271
+ };
272
+ fs.outputFileSync(confNodePath, JSON.stringify(confNode, null, 4), 'utf8');
273
+ logger.info(`User added`);
274
+ return;
275
+ }
276
+ if (options.userRemove) {
277
+ const groups = shellExec(`id -Gn ${options.user}`, { silent: true, stdout: true }).trim().replace(/ /g, ', ');
278
+ shellExec(`userdel -r ${options.user}`);
36
279
 
280
+ // Remove the private key copy folder
281
+ const privateCopyDir = `./engine-private/conf/${options.deployId}/users/${options.user}`;
282
+ if (fs.existsSync(privateCopyDir)) {
283
+ fs.removeSync(privateCopyDir);
284
+ logger.info(`Private key copy removed from ${privateCopyDir}`);
285
+ }
286
+
287
+ delete confNode.users[options.user];
288
+ fs.outputFileSync(confNodePath, JSON.stringify(confNode, null, 4), 'utf8');
289
+ logger.info(`User removed`);
290
+ if (groups) logger.info(`User removed from groups: ${groups}`);
291
+ return;
292
+ }
293
+ if (options.userLs) {
294
+ logger.info(`Users:`);
295
+ Object.keys(confNode.users).forEach((user) => {
296
+ logger.info(`- ${user}`);
297
+ });
298
+ return;
299
+ }
300
+ }
301
+
302
+ if (options.generate)
303
+ UnderpostSSH.API.generateKeys({ user: options.user, password: options.password, host: options.host });
304
+ if (options.start) {
305
+ UnderpostSSH.API.chmod({ user: options.user });
306
+ UnderpostSSH.API.initService({ port: options.port });
307
+ }
308
+ },
309
+ /**
310
+ * Generates new SSH ED25519 key pair and stores copies in multiple locations.
311
+ * @function generateKeys
312
+ * @memberof UnderpostSSH
313
+ * @param {Object} params - Key generation parameters
314
+ * @param {string} params.user - Username for the SSH key comment
315
+ * @param {string} params.password - Password to encrypt the private key
316
+ * @param {string} params.host - Host address for the SSH key comment
317
+ * @returns {void}
318
+ * @description
319
+ * Creates a new SSH ED25519 key pair and distributes it to:
320
+ * - User's ~/.ssh/ directory
321
+ * - ./engine-private/deploy/ directory
322
+ * Cleans up temporary key files after copying.
323
+ */
324
+ generateKeys: ({ user, password, host }) => {
325
+ shellExec(`sudo rm -rf ./id_rsa`);
326
+ shellExec(`sudo rm -rf ./id_rsa.pub`);
327
+
328
+ shellExec(`ssh-keygen -t ed25519 -f id_rsa -N "${password}" -q -C "${user}@${host}"`);
329
+
330
+ shellExec(`sudo cp ./id_rsa ~/.ssh/id_rsa`);
331
+ shellExec(`sudo cp ./id_rsa.pub ~/.ssh/id_rsa.pub`);
332
+
333
+ shellExec(`sudo cp ./id_rsa ./engine-private/deploy/id_rsa`);
334
+ shellExec(`sudo cp ./id_rsa.pub ./engine-private/deploy/id_rsa.pub`);
335
+
336
+ shellExec(`sudo rm -rf ./id_rsa`);
337
+ shellExec(`sudo rm -rf ./id_rsa.pub`);
338
+ },
339
+ /**
340
+ * Sets proper permissions and ownership for SSH directories and files.
341
+ * @function chmod
342
+ * @memberof UnderpostSSH
343
+ * @param {Object} params - Permission configuration parameters
344
+ * @param {string} params.user - Username for setting ownership
345
+ * @returns {void}
346
+ * @description
347
+ * Applies secure permissions to SSH files:
348
+ * - ~/.ssh/ directory: 700
349
+ * - ~/.ssh/authorized_keys: 600
350
+ * - ~/.ssh/known_hosts: 644
351
+ * - ~/.ssh/id_rsa: 600
352
+ * - /etc/ssh/ssh_host_ed25519_key: 600
353
+ * Sets ownership to specified user for ~/.ssh/ and contents.
354
+ */
355
+ chmod: ({ user }) => {
356
+ shellExec(`sudo chmod 700 ~/.ssh/`);
357
+ shellExec(`sudo chmod 600 ~/.ssh/authorized_keys`);
358
+ shellExec(`sudo chmod 644 ~/.ssh/known_hosts`);
359
+ shellExec(`sudo chmod 600 ~/.ssh/id_rsa`);
360
+ shellExec(`sudo chmod 600 /etc/ssh/ssh_host_ed25519_key`);
361
+ shellExec(`chown -R ${user}:${user} ~/.ssh`);
362
+ },
363
+ /**
364
+ * Initializes and hardens SSH service configuration for RHEL-based systems.
365
+ * @function initService
366
+ * @memberof UnderpostSSH
367
+ * @param {Object} params - Service configuration parameters
368
+ * @param {number} params.port - Port number for SSH service
369
+ * @returns {void}
370
+ * @description
371
+ * Configures SSH daemon with hardened security settings:
372
+ * - Disables password authentication (key-only)
373
+ * - Disables root login
374
+ * - Enables ED25519 host key
375
+ * - Disables X11 forwarding and TCP forwarding
376
+ * - Sets client alive intervals to prevent ghost connections
377
+ * - Configures PAM for RHEL/SELinux compatibility
378
+ *
379
+ * After configuration:
380
+ * - Enables sshd service for auto-start on boot
381
+ * - Restarts sshd service to apply changes
382
+ * - Displays service status with colored output
383
+ */
384
+ initService: ({ port }) => {
37
385
  shellExec(
38
- `node bin/deploy ssh root@${process.env.DEFAULT_DEPLOY_HOST} ${process.env.DEFAULT_DEPLOY_PASSWORD ?? `''`}${
39
- options.generate === true ? '' : ' import'
40
- }`,
386
+ `sudo tee /etc/ssh/sshd_config <<EOF
387
+ # ==============================================================
388
+ # RHEL Hardened SSHD Configuration
389
+ # ==============================================================
390
+
391
+ # --- Network Settings ---
392
+ Port ${port ? port : '22'}
393
+ # Explicitly listen on all interfaces (IPv4 and IPv6)
394
+
395
+ # --- Host Keys ---
396
+ # ED25519 is the modern standard (Fast & Secure)
397
+ HostKey /etc/ssh/ssh_host_ed25519_key
398
+ # RSA is kept for compatibility with older clients (Optional)
399
+ # HostKey /etc/ssh/ssh_host_rsa_key
400
+
401
+ # --- Logging ---
402
+ SyslogFacility AUTHPRIV
403
+ # VERBOSE logs the key fingerprint used for login (Audit trail)
404
+ LogLevel VERBOSE
405
+
406
+ # --- Authentication & Security ---
407
+ # STRICTLY KEY-BASED AUTHENTICATION
408
+ PubkeyAuthentication yes
409
+ PasswordAuthentication no
410
+ ChallengeResponseAuthentication no
411
+ PermitEmptyPasswords no
412
+
413
+ # PREVENT ROOT LOGIN
414
+ # Administrators should log in as a standard user and use 'sudo'
415
+ PermitRootLogin no
416
+
417
+ # Security checks on ownership of ~/.ssh/ files
418
+ StrictModes yes
419
+ MaxAuthTries 3
420
+ LoginGraceTime 60
421
+
422
+ # --- PAM (Pluggable Authentication Modules) ---
423
+ # REQUIRED: Must be 'yes' on RHEL for proper session/SELinux handling.
424
+ # Since PasswordAuthentication is 'no', PAM will not ask for passwords.
425
+ UsePAM yes
426
+
427
+ # --- Session & Network Health ---
428
+ # Disconnect idle sessions after 5 minutes (300s * 0) to prevent ghost connections
429
+ ClientAliveInterval 300
430
+ ClientAliveCountMax 0
431
+
432
+ # --- Feature Restrictions ---
433
+ # Disable GUI forwarding unless explicitly needed
434
+ X11Forwarding no
435
+ # Disable DNS checks for faster logins (unless you use Host based auth)
436
+ UseDNS no
437
+ # Disable tunneling unless needed
438
+ PermitTunnel no
439
+ AllowTcpForwarding no
440
+
441
+ # --- Subsystem ---
442
+ Subsystem sftp /usr/libexec/openssh/sftp-server
443
+ EOF`,
444
+ { disableLog: true },
445
+ );
446
+ shellExec(`sudo systemctl enable sshd`);
447
+ shellExec(`sudo systemctl restart sshd`);
448
+
449
+ const status = shellExec(`sudo systemctl status sshd`, { silent: true, stdout: true });
450
+ console.log(
451
+ status.match('running') ? status.replaceAll(`running`, `running`.green) : `ssh service not running`.red,
41
452
  );
42
453
  },
43
454
  };
@@ -798,7 +798,6 @@ const generateRandomPasswordSelection = (length) => {
798
798
  ',',
799
799
  '.',
800
800
  '|',
801
- '\\',
802
801
  ];
803
802
  const numbers = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
804
803
 
@@ -31,7 +31,11 @@ class MongooseDBService {
31
31
  logger.info('MongooseDB connect', { host, name, uri });
32
32
  return await mongoose
33
33
  .createConnection(uri, {
34
- // Options like useNewUrlParser and useUnifiedTopology are often set here.
34
+ serverSelectionTimeoutMS: 5000,
35
+ // readPreference: 'primary',
36
+ // directConnection: true,
37
+ // useNewUrlParser: true,
38
+ // useUnifiedTopology: true,
35
39
  })
36
40
  .asPromise();
37
41
  }
package/src/index.js CHANGED
@@ -36,7 +36,7 @@ class Underpost {
36
36
  * @type {String}
37
37
  * @memberof Underpost
38
38
  */
39
- static version = 'v2.90.4';
39
+ static version = 'v2.92.0';
40
40
  /**
41
41
  * Repository cli API
42
42
  * @static
package/src/server/dns.js CHANGED
@@ -100,6 +100,160 @@ class Dns {
100
100
  return ipv4.address;
101
101
  }
102
102
 
103
+ /**
104
+ * Setup nftables tables and chains if they don't exist.
105
+ * @static
106
+ * @memberof DnsManager
107
+ */
108
+ static setupNftables() {
109
+ shellExec(`sudo nft add table inet filter 2>/dev/null || true`, { silent: true });
110
+ shellExec(
111
+ `sudo nft add chain inet filter input '{ type filter hook input priority 0; policy accept; }' 2>/dev/null || true`,
112
+ { silent: true },
113
+ );
114
+ shellExec(
115
+ `sudo nft add chain inet filter output '{ type filter hook output priority 0; policy accept; }' 2>/dev/null || true`,
116
+ { silent: true },
117
+ );
118
+ shellExec(
119
+ `sudo nft add chain inet filter forward '{ type filter hook forward priority 0; policy accept; }' 2>/dev/null || true`,
120
+ { silent: true },
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Bans an IP address from ingress traffic.
126
+ * @static
127
+ * @memberof DnsManager
128
+ * @param {string} ip - The IP address to ban.
129
+ */
130
+ static banIngress(ip) {
131
+ Dns.setupNftables();
132
+ if (!validator.isIP(ip)) {
133
+ logger.error(`Invalid IP address: ${ip}`);
134
+ return;
135
+ }
136
+ shellExec(`sudo nft add rule inet filter input ip saddr ${ip} counter drop`, { silent: true });
137
+ logger.info(`Banned ingress for IP: ${ip}`);
138
+ }
139
+
140
+ /**
141
+ * Bans an IP address from egress traffic.
142
+ * @static
143
+ * @memberof DnsManager
144
+ * @param {string} ip - The IP address to ban.
145
+ */
146
+ static banEgress(ip) {
147
+ Dns.setupNftables();
148
+ if (!validator.isIP(ip)) {
149
+ logger.error(`Invalid IP address: ${ip}`);
150
+ return;
151
+ }
152
+ shellExec(`sudo nft add rule inet filter output ip daddr ${ip} counter drop`, { silent: true });
153
+ shellExec(`sudo nft add rule inet filter forward ip daddr ${ip} counter drop`, { silent: true });
154
+ logger.info(`Banned egress for IP: ${ip}`);
155
+ }
156
+
157
+ /**
158
+ * Helper to get nftables rule handles for a specific IP and chain.
159
+ * @static
160
+ * @memberof DnsManager
161
+ * @param {string} chain - The chain name (input, output, forward).
162
+ * @param {string} ip - The IP address.
163
+ * @param {string} type - The type (saddr or daddr).
164
+ * @returns {string[]} Array of handles.
165
+ */
166
+ static getNftHandles(chain, ip, type) {
167
+ const output = shellExec(`sudo nft -a list chain inet filter ${chain}`, { stdout: true, silent: true });
168
+ const lines = output.split('\n');
169
+ const handles = [];
170
+ // Regex to match IP and handle. Note: output format depends on nft version but usually contains "handle <id>" at end.
171
+ // Example: ip saddr 1.2.3.4 counter packets 0 bytes 0 drop # handle 5
172
+ const regex = new RegExp(`ip ${type} ${ip} .* handle (\\d+)`);
173
+ for (const line of lines) {
174
+ const match = line.match(regex);
175
+ if (match) {
176
+ handles.push(match[1]);
177
+ }
178
+ }
179
+ return handles;
180
+ }
181
+
182
+ /**
183
+ * Unbans an IP address from ingress traffic.
184
+ * @static
185
+ * @memberof DnsManager
186
+ * @param {string} ip - The IP address to unban.
187
+ */
188
+ static unbanIngress(ip) {
189
+ const handles = Dns.getNftHandles('input', ip, 'saddr');
190
+ for (const handle of handles) {
191
+ shellExec(`sudo nft delete rule inet filter input handle ${handle}`, { silent: true });
192
+ }
193
+ logger.info(`Unbanned ingress for IP: ${ip}`);
194
+ }
195
+
196
+ /**
197
+ * Unbans an IP address from egress traffic.
198
+ * @static
199
+ * @memberof DnsManager
200
+ * @param {string} ip - The IP address to unban.
201
+ */
202
+ static unbanEgress(ip) {
203
+ const outputHandles = Dns.getNftHandles('output', ip, 'daddr');
204
+ for (const handle of outputHandles) {
205
+ shellExec(`sudo nft delete rule inet filter output handle ${handle}`, { silent: true });
206
+ }
207
+ const forwardHandles = Dns.getNftHandles('forward', ip, 'daddr');
208
+ for (const handle of forwardHandles) {
209
+ shellExec(`sudo nft delete rule inet filter forward handle ${handle}`, { silent: true });
210
+ }
211
+ logger.info(`Unbanned egress for IP: ${ip}`);
212
+ }
213
+
214
+ /**
215
+ * Lists all banned ingress IPs.
216
+ * @static
217
+ * @memberof DnsManager
218
+ */
219
+ static listBannedIngress() {
220
+ const output = shellExec(`sudo nft list chain inet filter input`, { stdout: true, silent: true });
221
+ console.log(output);
222
+ }
223
+
224
+ /**
225
+ * Lists all banned egress IPs.
226
+ * @static
227
+ * @memberof DnsManager
228
+ */
229
+ static listBannedEgress() {
230
+ console.log('--- Output Chain ---');
231
+ console.log(shellExec(`sudo nft list chain inet filter output`, { stdout: true, silent: true }));
232
+ console.log('--- Forward Chain ---');
233
+ console.log(shellExec(`sudo nft list chain inet filter forward`, { stdout: true, silent: true }));
234
+ }
235
+
236
+ /**
237
+ * Clears all banned ingress IPs.
238
+ * @static
239
+ * @memberof DnsManager
240
+ */
241
+ static clearBannedIngress() {
242
+ shellExec(`sudo nft flush chain inet filter input`, { silent: true });
243
+ logger.info('Cleared all ingress bans.');
244
+ }
245
+
246
+ /**
247
+ * Clears all banned egress IPs.
248
+ * @static
249
+ * @memberof DnsManager
250
+ */
251
+ static clearBannedEgress() {
252
+ shellExec(`sudo nft flush chain inet filter output`, { silent: true });
253
+ shellExec(`sudo nft flush chain inet filter forward`, { silent: true });
254
+ logger.info('Cleared all egress bans.');
255
+ }
256
+
103
257
  /**
104
258
  * Performs the dynamic DNS update logic.
105
259
  * It checks if the public IP has changed and, if so, updates the configured DNS records.
@@ -119,7 +119,9 @@ class UnderpostStartUp {
119
119
  * @param {boolean} options.run - Whether to run the deployment.
120
120
  */
121
121
  async callback(deployId = 'dd-default', env = 'development', options = { build: false, run: false }) {
122
+ UnderpostRootEnv.API.set('container-status', `${deployId}-${env}-build-deployment`);
122
123
  if (options.build === true) await UnderpostStartUp.API.build(deployId, env);
124
+ UnderpostRootEnv.API.set('container-status', `${deployId}-${env}-initializing-deployment`);
123
125
  if (options.run === true) await UnderpostStartUp.API.run(deployId, env);
124
126
  },
125
127
  /**