· 6 years ago · Oct 11, 2019, 03:28 PM
1// ==================================================
2// Dependencies
3// ==================================================
4const io = require('socket.io');
5const fs = require('fs');
6const os = require('os');
7const dns = require('dns');
8const jwt = require('jsonwebtoken');
9const http = require('http');
10const path = require('path');
11const redis = require('socket.io-redis');
12const https = require('https');
13const express = require('express');
14const mustache = require('mustache');
15const bodyParser = require("body-parser");
16const sequencial = require('promise-sequencial');
17const digitalocean = require('digitalocean');
18const ssh2_client = require('ssh2').Client;
19
20// Get environment variables from symfony.
21require('dotenv').config({path: __dirname + '/../.env'});
22
23// Setup digitalocean api client.
24var digitalocean_api = digitalocean.client(process.env.DIGITALOCEAN_API_TOKEN);
25
26// Initialize the logger.
27const Logger = require('./logger');
28const logger = new Logger();
29
30// Initialize and connect to the database.
31const Database = require('./database');
32const db = new Database(logger);
33// Sockets hashmap
34let clients = {};
35let install_steps = [
36 'Updating system',
37 'Upgrading system',
38 'Installing dependencies',
39 'Getting latest php version',
40 'Installing php and its extensions',
41 'Activating stretch backports repositories',
42 'Installing certbot SSL certificates manager',
43 'Creating maintenance account',
44
45 'Cleaning nginx configuration',
46 'Copying nginx application configuration',
47 'Creating application symbolic link',
48 'Initializing application structure',
49 //'Creating a new SSL certificate for nginx',
50 'Copying application files',
51 'Restarting nginx server',
52
53 'Creating deployment folder structure',
54 'Initialize git deployment repository',
55 'Copying git deployment hook configuration',
56 'Changing hook execution mode to executable.',
57
58 'Server deployment complete!'
59];
60
61
62// ==================================================
63// Configuration
64// ==================================================
65let certs_path = path.join(__dirname, '/../data/ssl/');
66let tpl_path = path.join(__dirname, '/../templates/ssh/');
67let keys_path = path.join(__dirname, '/../data/keys/');
68
69let config = {
70 certs_path: certs_path,
71 tpl_path: tpl_path,
72 keys_path: keys_path,
73 port: 3000,
74 redis_port: 6379,
75 redis_host: 'localhost',
76 debug: true,
77 https_config: process.env.APP_SECRET == 'prod' ? {
78 key: fs.readFileSync(path.join(certs_path, 'privkey.pem'), 'utf8'),
79 cert: fs.readFileSync(path.join(certs_path, 'cert.pem'), 'utf8'),
80 ca: fs.readFileSync(path.join(certs_path, 'chain.pem'), 'utf8')
81 } : {}
82};
83// ==================================================
84// Socket io utility functions
85// ==================================================
86
87/**
88 * Called when a client connect
89 * Add a socket to the clients list
90 * The socket id is the key in the hashmap
91 * @param socket
92 * @param user_id
93 */
94let onConnection = (socket, user_id) => {
95 clients[socket.id] = {
96 user_id: user_id,
97 socket: socket
98 };
99
100 if (config.debug)
101 logger.log('info', 'Client ' + (socket.id) + ' connected');
102};
103
104/**
105 * Called when a client disconnect
106 * Remove the socket from the clients hashmap.
107 * @param socket
108 */
109let onDisconnect = socket => {
110 let client = clients[socket.id];
111
112 if (typeof client != 'undefined') {
113 delete clients[socket.id];
114
115 if (config.debug)
116 logger.log('info', 'Client ' + (socket.id) + ' disconnected');
117 }
118};
119
120/**
121 * Log the actual number of clients connected.
122 */
123let logClientsConnected = () => {
124 logger.log('info', 'Server #' + process.pid + ': ' + (Object.keys(clients).length) + ' clients connected');
125};
126
127/**
128 * Return a user given it's database index.
129 * @param userId
130 * @returns {*}
131 */
132let getSocketByUserId = userId => {
133 for (let socketId in clients) {
134 if (clients[socketId].user_id == userId)
135 return clients[socketId].socket;
136 }
137
138 return null;
139};
140
141/**
142 * Return droplet informations given it's digitalocean_id;
143 * @param dropletId
144 * @param cb
145 * @returns {*}
146 */
147let getDroplet = (dropletId, cb) => {
148 return digitalocean_api.droplets.get(dropletId, (err, data) => {
149 if (err) console.log(err);
150 else cb(data);
151 })
152};
153
154let ssh_exec = (connection, socket, total_steps, command, droplet) => {
155 return new Promise((resolve, reject) => {
156 connection.exec(command, (err, stream) => {
157 if (err) reject(err);
158
159 stream.on('close', (code, signal) => {
160 logger.log('warn', 'Stream closed with ' + code + ', signal: ' + signal);
161 resolve();
162 });
163
164 stream.on('data', data => {
165 if (data != "\n" || data != "\r\n") {
166 let str = data.toString().replace(/\r?\n|\r/g, '');
167 let match = str.match(/__STEP__([\d]+)/);
168
169 if (match) {
170 if (typeof match[1] != 'undefined') {
171
172 console.log('STEP: ', match[1], total_steps);
173
174 if (match[1] == total_steps) {
175 fs.chmodSync(keys_path + droplet.name + '_private.key', 0o600);
176 console.log('Updated private key chmod');
177 }
178
179 if (typeof install_steps[match[1] - 1] != 'undefined') {
180 socket.emit('droplet_configuration', {
181 percent: (match[1] / total_steps) * 100,
182 message: install_steps[match[1] - 1]
183 })
184 }
185 }
186 }
187
188 logger.log('info', str);
189 }
190 });
191
192 stream.stderr.on('data', data => {
193 logger.log('error', data);
194 reject(err)
195 });
196 });
197 });
198};
199// ==================================================
200// Creation of the server
201// ==================================================
202
203// Start the web server and activate request body parsing.
204let app = express();
205app.use(bodyParser.urlencoded({extended: true}));
206
207// Create the http server to make the bridge between socket.io & express.
208// Activate the HTTPS protocol in production.
209let server = process.env.APP_SECRET == 'prod'
210 ? https.createServer(https_config, app)
211 : http.createServer(app);
212
213// Bind the socket.io server to the HTTP/S server.
214let socketio = io.listen(server);
215
216// Configure the socket.io session adapter for redis.
217socketio.adapter(redis({host: config.redis_host, port: config.redis_port}));
218
219// Listen to the HTTP/S server.
220server.listen(config.port);
221logger.log('info', 'Server started with pid #' + process.pid);
222
223// ==================================================
224// Catch post events
225// ==================================================
226app.post('/', (req, res) => {
227
228 // Check the request ip address, force local request only.
229 if (req.connection.remoteAddress == '::1'
230 || req.connection.remoteAddress == '::ffff:127.0.0.1'
231 || req.connection.remoteAddress == '127.0.0.1') {
232
233 // Check for request informations errors.
234 if (typeof req.body.token == 'undefined') {
235 res.status(400);
236 return res.send('The token is missing');
237 } else if (req.body.token == null || req.body.token.length == 0) {
238 res.status(400);
239 return res.send('The token is invalid');
240 } else if (typeof req.body.dropletId == 'undefined') {
241 res.status(400);
242 return res.send('The dropletId is missing');
243 }
244
245 // Verify the JWT request token.
246 jwt.verify(req.body.token, process.env.APP_SECRET, (err, decoded) => {
247 if (err) {
248 console.log(err);
249 res.status(401);
250
251 return res.send('Error while decoding the token.');
252 }
253 else {
254 logger.log('info', 'Server #' + process.pid + ' just received a task from user ' + decoded.user_id + '(' + req.connection.remoteAddress + ')');
255 res.status(200);
256 res.send('Success');
257
258 // Retrieve the user socket given it's database index.
259 let socket = getSocketByUserId(decoded.user_id);
260
261 // Check for status of the newly created droplet, and send an heartbeat signal
262 // to the user. If the status changes to "active", then the corresponding droplet
263 // in database is updated.
264
265 let intervalId = setInterval(() => {
266 getDroplet(req.body.dropletId, droplet => {
267 logger.log('info', 'Checking droplet status: ' + droplet.status);
268
269 // Check the droplet status
270 if (droplet.status == 'new')
271 socket.emit('droplet_booting');
272 else if (droplet.status == 'active') {
273 clearInterval(intervalId);
274
275 if (socket) {
276 logger.log('info', 'Server #' + process.pid + ' notifying socket ' + socket.id);
277
278 // Update the droplet status in the database.
279 db.connection.query('UPDATE droplet SET ip = ?, status = "active" WHERE digitalocean_id = ?',
280 [droplet.networks.v4[0].ip_address, droplet.id], (err, res) => {
281 if (err) console.log(err);
282 else if (res.affectedRows == 1) {
283 // The droplet is now booted, notify the user.
284 socket.emit('droplet_booted', {
285 id: droplet.id,
286 name: droplet.name,
287 status: droplet.status
288 });
289
290 setTimeout(() => {
291 socket.emit('droplet_connecting', {
292 message: 'Establishing ssh connection...'
293 });
294 var connection = new ssh2_client();
295
296 connection.on('ready', () => {
297 console.log('SSH Tunnel connected.');
298 socket.emit('droplet_connecting', {
299 message: 'Connected to server via SSH.'
300 });
301
302 let server_config = {
303 username: 'admin',
304 password: 'soupe457',
305 php_version: '7.3',
306 extensions: [
307 'cli',
308 'common',
309 'curl',
310 'gd',
311 'json',
312 'mbstring',
313 'mysql',
314 'xml',
315 'intl',
316 'exif',
317 'gettext'
318 ],
319 packages: [
320 'openssl',
321 'dialog',
322 'apt-utils',
323 'ca-certificates',
324 'apt-transport-https',
325 'git',
326 'nginx',
327 'mysql-server',
328 'composer'
329 ]
330 };
331
332 let application_domains = 'virax.net virax.tech';
333 let ssl_renewal_email = 'mel.florance@gmail.com';
334 let nginx_project_dir = '/var/www/sites/' + droplet.name;
335 let nginx_config_filename = '/etc/nginx/sites-available/' + droplet.name;
336 let deploy_hook_filename = '/var/www/repositories/' + droplet.name + '/hooks/post-receive';
337 let nginx_symlink = '/etc/nginx/sites-enabled/' + droplet.name;
338
339 let nginx_tpl = mustache.render(fs.readFileSync(tpl_path + 'nginx.tpl', 'utf8'), {
340 project_name: droplet.name,
341 domains: application_domains,
342 php_version: server_config.php_version,
343 root_dir: '/var/www/sites/' + droplet.name
344 });
345
346 let deploy_hook_tpl = mustache.render(fs.readFileSync(tpl_path + 'deploy-hook.tpl', 'utf8'), {
347 work_tree: '/var/www/sites/' + droplet.name,
348 git_dir: '/var/www/repositories/' + droplet.name
349 });
350
351 let mysql_root_passwd = 'azeazeae';
352 let mysql_user_name = 'admin';
353 let mysql_user_passwd = 'azeazeaz';
354
355 // Secure mysql root user.
356 // 'echo -e "\ny\ny\n' + mysql_root_passwd + '\n' + mysql_root_passwd + '\ny\ny\ny\ny" | /usr/bin/mysql_secure_installation',
357
358 // Create new mysql user.
359 // 'mysql -e "CREATE USER \''+ mysql_user_name + '\'@\'localhost\' IDENTIFIED BY \'' + mysql_user_passwd + '\';"',
360
361 // Create the application database.
362 // 'mysql -e "CREATE DATABASE ' + application_db_name + ';"',
363
364 // Associate the created user to the new database.
365 // 'mysql -e "GRANT ALL ON ' + application_db_name + '.* TO \'' + mysql_user_name + '\'@\'localhost\' IDENTIFIED BY \'' + mysql_user_passwd + '\' WITH GRANT OPTION;"',
366
367 // Apply mysql configuration changes.
368 // 'mysql -e "FLUSH PRIVILEGES;"',
369
370 let commands = [
371 //==============================================================
372 // SYSTEM CONFIGURATION
373 //==============================================================
374 // Update system
375 'echo "__STEP__1"',
376 'apt-get update',
377 'echo "__STEP__2"',
378 'apt-get -y upgrade',
379
380 // Install dependencies
381 'echo "__STEP__3"',
382 'apt-get -y install ' + server_config.packages.join(' '),
383
384 // Get PHP demanded version
385 'echo "__STEP__4"',
386 'wget -q https://packages.sury.org/php/apt.gpg -O- | apt-key add -',
387 'echo "deb https://packages.sury.org/php/ stretch main" | tee /etc/apt/sources.list.d/php.list',
388 'apt-get update',
389
390 // Install php and the specified extension
391 'echo "__STEP__5"',
392 'apt-get -y install php' + server_config.php_version + ' php' + server_config.php_version + '-fpm',
393 'apt-get -y install ' + server_config.extensions.map(ext => 'php' + server_config.php_version + '-' + ext).join(' '),
394
395 // Activate the stretch backports repositories
396 'echo "__STEP__6"',
397 'sed -i "s/^# deb/deb/g" /etc/apt/sources.list',
398 'apt-get update',
399
400 // Install certbot for let's encrypt SSL certificate
401 'echo "__STEP__7"',
402 'apt-get -y install certbot python-certbot-nginx -t stretch-backports',
403
404 // Create a default user for the server administration
405 'echo "__STEP__8"',
406 'useradd -d /home/' + server_config.username + ' -s /bin/bash -p $(echo "' + server_config.password + '" | openssl passwd -1 -stdin) ' + server_config.username,
407
408 //==============================================================
409 // APPLICATION INSTALLATION
410 //==============================================================
411 // Remove the default nginx server block configuration
412 'echo "__STEP__9"',
413 'rm /etc/nginx/sites-available/default /etc/nginx/sites-enabled/default',
414
415 // Copy the new nginx application configuration
416 'echo "__STEP__10"',
417 'touch ' + nginx_config_filename + ' && echo \'' + nginx_tpl + '\' > ' + nginx_config_filename,
418
419 // Create a symbolic link for the previous configuration
420 'echo "__STEP__11"',
421 'ln -s ' + nginx_config_filename + ' ' + nginx_symlink,
422
423 // Create the application directory and init an empty repo
424 'echo "__STEP__12"',
425 'mkdir -p ' + nginx_project_dir,
426 '(cd ' + nginx_project_dir + ' && git init)',
427
428 //// Install an SSL certificate for nginx
429 //'echo "__STEP__13"',
430 //'certbot --nginx --non-interactive --agree-tos -m ' + ssl_renewal_email + ' --expand -d ' + application_domains.split(' ').join(','),
431
432 // Create a default index file to test that everything is working.
433 'echo "__STEP__14"',
434 "touch " + nginx_project_dir + "/index.php && echo '<?php phpinfo(); ?>' > " + nginx_project_dir + "/index.php",
435
436 // Restart nginx to take into account the new configuration
437 'echo "__STEP__15"',
438 'service nginx restart',
439
440 //==============================================================
441 // DEPLOYMENT
442 //==============================================================
443 // Create the git deployment folder
444 'echo "__STEP__16"',
445 'mkdir -p /var/www/repositories/' + droplet.name,
446
447 // Init the application deployment folder
448 'echo "__STEP__17"',
449 '(cd /var/www/repositories/' + droplet.name + ' && git init --bare)',
450
451 // Copy the new application hook configuration
452 'echo "__STEP__18"',
453 'touch ' + deploy_hook_filename + ' && echo \'' + deploy_hook_tpl + '\' > ' + deploy_hook_filename,
454
455 // Make the hook executable by the git processus.
456 'echo "__STEP__19"',
457 'chmod +x ' + deploy_hook_filename,
458
459 // Server deployment complete!
460 'echo "__STEP__20"'
461 ];
462
463 socket.emit('droplet_installing', {
464 message: 'Server installation started.'
465 });
466
467 ssh_exec(connection, socket, install_steps.length, commands.join(' && '), droplet);
468
469 }).connect({
470 host: droplet.networks.v4[0].ip_address,
471 port: 22,
472 username: 'root',
473 privateKey: fs.readFileSync(path.join(keys_path, droplet.name + '_private.key'))
474 });
475
476 }, 5000);
477 }
478 });
479 }
480 }
481 });
482 }, 1000);
483 }
484 });
485 }
486 else {
487 logger.log('warn', 'Server #' + process.pid + 'received a bad connection from ' + req.connection.remoteAddress);
488 res.status(401);
489 return res.send('Bad ip address');
490 }
491});
492
493// ==================================================
494// Listen socketio events
495// ==================================================
496socketio.on('connection', socket => {
497 // Accept only connection which own a token, even if the token is null.
498 if (typeof socket.handshake.query != 'undefined') {
499 if (typeof socket.handshake.query.token != 'undefined') {
500 // Accept socket without user to see the guests traffic.
501 if (socket.handshake.query.token == null || socket.handshake.query.token.length == 0) {
502 onConnection(socket, null);
503
504 if (config.debug)
505 logClientsConnected();
506 }
507 else {
508 // If there is a token, verify it and extract the user id from it.
509 jwt.verify(socket.handshake.query.token, process.env.APP_SECRET, (err, decoded) => {
510 if (err)
511 logger.log('warn', 'Socket error: Can\'t decode token.' + err);
512 else {
513 onConnection(socket, decoded.user_id);
514
515 if (config.debug)
516 logClientsConnected();
517 }
518 });
519 }
520 }
521 }
522
523 // Remove the socket from the clients hashmap on disconnect.
524 socket.on('disconnect', () => {
525 onDisconnect(socket);
526
527 if (config.debug)
528 logClientsConnected();
529 });
530});