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/.github/workflows/release.cd.yml +7 -7
- package/README.md +5 -5
- package/bin/deploy.js +0 -127
- package/cli.md +93 -26
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
- package/package.json +1 -1
- package/scripts/rocky-setup.sh +1 -0
- package/src/cli/db.js +1148 -197
- package/src/cli/deploy.js +17 -12
- package/src/cli/env.js +2 -2
- package/src/cli/index.js +100 -11
- package/src/cli/repository.js +127 -3
- package/src/cli/run.js +40 -11
- package/src/cli/ssh.js +424 -13
- package/src/client/components/core/CommonJs.js +0 -1
- package/src/db/mongo/MongooseDB.js +5 -1
- package/src/index.js +1 -1
- package/src/server/dns.js +154 -0
- package/src/server/start.js +2 -0
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
|
-
*
|
|
18
|
-
* @
|
|
19
|
-
*
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
`
|
|
39
|
-
|
|
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
|
};
|
|
@@ -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
|
-
|
|
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
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.
|
package/src/server/start.js
CHANGED
|
@@ -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
|
/**
|