diff --git a/create-a-container/README.md b/create-a-container/README.md index d0b457bf..c2332ac0 100644 --- a/create-a-container/README.md +++ b/create-a-container/README.md @@ -2,6 +2,56 @@ A web application for managing LXC container creation, configuration, and lifecycle on Proxmox VE infrastructure. Provides a user-friendly interface and REST API for container management with automated database tracking and nginx reverse proxy configuration generation. +## Data Model + +```mermaid +erDiagram + Node ||--o{ Container : "hosts" + Container ||--o{ Service : "exposes" + + Node { + int id PK + string name UK "Proxmox node name" + string apiUrl "Proxmox API URL" + boolean tlsVerify "Verify TLS certificates" + datetime createdAt + datetime updatedAt + } + + Container { + int id PK + string hostname UK "FQDN hostname" + string username "Owner username" + string osRelease "OS distribution" + int nodeId FK "References Node" + int containerId UK "Proxmox VMID" + string macAddress UK "MAC address" + string ipv4Address UK "IPv4 address" + string aiContainer "Node type flag" + datetime createdAt + datetime updatedAt + } + + Service { + int id PK + int containerId FK "References Container" + enum type "tcp, udp, or http" + int internalPort "Port inside container" + int externalPort "External port (tcp/udp only)" + boolean tls "TLS enabled (tcp only)" + string externalHostname UK "Public hostname (http only)" + datetime createdAt + datetime updatedAt + } +``` + +**Key Constraints:** +- `(Node.name)` - Unique +- `(Container.hostname)` - Unique +- `(Container.nodeId, Container.containerId)` - Unique (same VMID can exist on different nodes) +- `(Service.externalHostname)` - Unique when type='http' +- `(Service.type, Service.externalPort)` - Unique when type='tcp' or type='udp' + ## Features - **User Authentication** - Proxmox VE authentication integration @@ -163,6 +213,20 @@ Create or register a container - **Returns (init=true)**: Redirect to status page - **Returns (init=false)**: `{ containerId, message }` +#### `DELETE /containers/:id` (Auth Required) +Delete a container from both Proxmox and the database +- **Path Parameter**: `id` - Container database ID +- **Authorization**: User can only delete their own containers +- **Process**: + 1. Verifies container ownership + 2. Deletes container from Proxmox via API + 3. On success, removes container record from database (cascades to services) +- **Returns**: `{ success: true, message: "Container deleted successfully" }` +- **Errors**: + - `404` - Container not found + - `403` - User doesn't own the container + - `500` - Proxmox API deletion failed or node not configured + #### `GET /status/:jobId` (Auth Required) View container creation progress page diff --git a/create-a-container/config/config.js b/create-a-container/config/config.js index d82773ab..ff00dcd5 100644 --- a/create-a-container/config/config.js +++ b/create-a-container/config/config.js @@ -12,5 +12,8 @@ const config = { module.exports = { development: config, test: config, - production: config, + production: { + ...config, + logging: false + }, }; \ No newline at end of file diff --git a/create-a-container/middlewares/index.js b/create-a-container/middlewares/index.js new file mode 100644 index 00000000..ee3d1bf6 --- /dev/null +++ b/create-a-container/middlewares/index.js @@ -0,0 +1,28 @@ +function isApiRequest(req) { + const acceptsJSON = req.get('Accept') && req.get('Accept').includes('application/json'); + const isAjax = req.get('X-Requested-With') === 'XMLHttpRequest'; + const isApiPath = req.path && req.path.startsWith('/api/'); + return acceptsJSON || isAjax || isApiPath; +} + +// Authentication middleware (single) --- +// Detect API requests and browser requests. API requests return 401 JSON, browser requests redirect to /login. +function requireAuth(req, res, next) { + if (req.session && req.session.user) return next(); + if (isApiRequest(req)) + return res.status(401).json({ error: 'Unauthorized' }); + + // Otherwise treat as a browser route: include the original URL as a redirect parameter + const original = req.originalUrl || req.url || '/'; + const redirectTo = '/login?redirect=' + encodeURIComponent(original); + return res.redirect(redirectTo); +} + +function requireAdmin(req, res, next) { + if (req.session && req.session.isAdmin) return next(); + if (isApiRequest(req)) + return res.status(403).json({ error: 'Forbidden: Admin access required' }); + return res.status(403).send('Forbidden: Admin access required'); +} + +module.exports = { requireAuth, requireAdmin }; diff --git a/create-a-container/migrations/20251104160710-add-service-uniqueness-constraints.js b/create-a-container/migrations/20251104160710-add-service-uniqueness-constraints.js new file mode 100644 index 00000000..401680ec --- /dev/null +++ b/create-a-container/migrations/20251104160710-add-service-uniqueness-constraints.js @@ -0,0 +1,24 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up (queryInterface, Sequelize) { + // For HTTP services: externalHostname must be unique (NULL values are ignored by unique indexes) + await queryInterface.addIndex('Services', ['externalHostname'], { + name: 'services_http_unique_hostname', + unique: true + }); + + // For TCP/UDP services: (type, externalPort) must be unique + await queryInterface.addIndex('Services', ['type', 'externalPort'], { + name: 'services_layer4_unique_port', + unique: true + }); + }, + + async down (queryInterface, Sequelize) { + // Remove unique constraints + await queryInterface.removeIndex('Services', 'services_http_unique_hostname'); + await queryInterface.removeIndex('Services', 'services_layer4_unique_port'); + } +}; diff --git a/create-a-container/migrations/20251104184238-add-node-field-to-containers.js b/create-a-container/migrations/20251104184238-add-node-field-to-containers.js new file mode 100644 index 00000000..5c6b905a --- /dev/null +++ b/create-a-container/migrations/20251104184238-add-node-field-to-containers.js @@ -0,0 +1,62 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up (queryInterface, Sequelize) { + // Step 1: Add the node column (allow NULL temporarily) + await queryInterface.addColumn('Containers', 'node', { + type: Sequelize.STRING(255), + allowNull: true + }); + + // Step 2: Populate node based on aiContainer values + // FORTWAYNE -> mie-phxdc-ai-pve1 + await queryInterface.sequelize.query( + "UPDATE Containers SET node = 'mie-phxdc-ai-pve1' WHERE aiContainer = 'FORTWAYNE'" + ); + + // PHOENIX -> intern-phxdc-pve3-ai + await queryInterface.sequelize.query( + "UPDATE Containers SET node = 'intern-phxdc-pve3-ai' WHERE aiContainer = 'PHOENIX'" + ); + + // N + odd containerId -> intern-phxdc-pve1 + await queryInterface.sequelize.query( + "UPDATE Containers SET node = 'intern-phxdc-pve1' WHERE aiContainer = 'N' AND MOD(containerId, 2) = 1" + ); + + // N + even containerId -> intern-phxdc-pve2 + await queryInterface.sequelize.query( + "UPDATE Containers SET node = 'intern-phxdc-pve2' WHERE aiContainer = 'N' AND MOD(containerId, 2) = 0" + ); + + // Step 3: Make node NOT NULL + await queryInterface.changeColumn('Containers', 'node', { + type: Sequelize.STRING(255), + allowNull: false + }); + + // Step 4: Remove unique constraint from containerId + await queryInterface.removeIndex('Containers', 'containerId'); + + // Step 5: Add unique constraint on (node, containerId) + await queryInterface.addIndex('Containers', ['node', 'containerId'], { + name: 'containers_node_container_id_unique', + unique: true + }); + }, + + async down (queryInterface, Sequelize) { + // Remove the unique constraint on (node, containerId) + await queryInterface.removeIndex('Containers', 'containers_node_container_id_unique'); + + // Restore unique constraint on containerId + await queryInterface.addIndex('Containers', ['containerId'], { + name: 'containerId', + unique: true + }); + + // Remove the node column + await queryInterface.removeColumn('Containers', 'node'); + } +}; diff --git a/create-a-container/migrations/20251104193601-create-node.js b/create-a-container/migrations/20251104193601-create-node.js new file mode 100644 index 00000000..2fdde06e --- /dev/null +++ b/create-a-container/migrations/20251104193601-create-node.js @@ -0,0 +1,46 @@ +'use strict'; +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('Nodes', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + name: { + type: Sequelize.STRING(255), + allowNull: false, + unique: true + }, + apiUrl: { + type: Sequelize.STRING(255), + allowNull: true + }, + tlsVerify: { + type: Sequelize.BOOLEAN, + allowNull: true + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + + // Seed the Nodes table with existing node values from Containers + await queryInterface.sequelize.query(` + INSERT INTO Nodes (name, apiUrl, tlsVerify, createdAt, updatedAt) + SELECT DISTINCT node, NULL, NULL, NOW(), NOW() + FROM Containers + ORDER BY node + `); + }, + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('Nodes'); + } +}; \ No newline at end of file diff --git a/create-a-container/migrations/20251104193722-convert-container-node-to-node-id.js b/create-a-container/migrations/20251104193722-convert-container-node-to-node-id.js new file mode 100644 index 00000000..9e919127 --- /dev/null +++ b/create-a-container/migrations/20251104193722-convert-container-node-to-node-id.js @@ -0,0 +1,78 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up (queryInterface, Sequelize) { + // Step 1: Add nodeId column (temporarily nullable) + await queryInterface.addColumn('Containers', 'nodeId', { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: 'Nodes', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'RESTRICT' + }); + + // Step 2: Populate nodeId based on node string values + await queryInterface.sequelize.query( + "UPDATE Containers c JOIN Nodes n ON c.node = n.name SET c.nodeId = n.id" + ); + + // Step 3: Make nodeId NOT NULL + await queryInterface.changeColumn('Containers', 'nodeId', { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'Nodes', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'RESTRICT' + }); + + // Step 4: Remove old unique constraint on (node, containerId) + await queryInterface.removeIndex('Containers', 'containers_node_container_id_unique'); + + // Step 5: Add new unique constraint on (nodeId, containerId) + await queryInterface.addIndex('Containers', ['nodeId', 'containerId'], { + name: 'containers_node_id_container_id_unique', + unique: true + }); + + // Step 6: Remove the old node column + await queryInterface.removeColumn('Containers', 'node'); + }, + + async down (queryInterface, Sequelize) { + // Add back the node column + await queryInterface.addColumn('Containers', 'node', { + type: Sequelize.STRING(255), + allowNull: true + }); + + // Populate node from nodeId + await queryInterface.sequelize.query( + "UPDATE Containers c JOIN Nodes n ON c.nodeId = n.id SET c.node = n.name" + ); + + // Make node NOT NULL + await queryInterface.changeColumn('Containers', 'node', { + type: Sequelize.STRING(255), + allowNull: false + }); + + // Remove new unique constraint + await queryInterface.removeIndex('Containers', 'containers_node_id_container_id_unique'); + + // Restore old unique constraint + await queryInterface.addIndex('Containers', ['node', 'containerId'], { + name: 'containers_node_container_id_unique', + unique: true + }); + + // Remove nodeId column + await queryInterface.removeColumn('Containers', 'nodeId'); + } +}; diff --git a/create-a-container/migrations/20251105145958-add-api-token-to-nodes.js b/create-a-container/migrations/20251105145958-add-api-token-to-nodes.js new file mode 100644 index 00000000..36bde53e --- /dev/null +++ b/create-a-container/migrations/20251105145958-add-api-token-to-nodes.js @@ -0,0 +1,23 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up (queryInterface, Sequelize) { + await queryInterface.addColumn('Nodes', 'tokenId', { + type: Sequelize.STRING(255), + allowNull: true, + after: 'apiUrl' + }); + + await queryInterface.addColumn('Nodes', 'secret', { + type: Sequelize.STRING(255), + allowNull: true, + after: 'tokenId' + }); + }, + + async down (queryInterface, Sequelize) { + await queryInterface.removeColumn('Nodes', 'tokenId'); + await queryInterface.removeColumn('Nodes', 'secret'); + } +}; diff --git a/create-a-container/migrations/20251105155228-create-sessions-table.js b/create-a-container/migrations/20251105155228-create-sessions-table.js new file mode 100644 index 00000000..01e125ce --- /dev/null +++ b/create-a-container/migrations/20251105155228-create-sessions-table.js @@ -0,0 +1,34 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up (queryInterface, Sequelize) { + await queryInterface.createTable('Sessions', { + session_id: { + type: Sequelize.STRING(32), + primaryKey: true, + allowNull: false + }, + expires: { + type: Sequelize.DATE, + allowNull: true + }, + data: { + type: Sequelize.TEXT, + allowNull: true + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + + async down (queryInterface, Sequelize) { + await queryInterface.dropTable('Sessions'); + } +}; diff --git a/create-a-container/models/container.js b/create-a-container/models/container.js index bf620b07..02d94231 100644 --- a/create-a-container/models/container.js +++ b/create-a-container/models/container.js @@ -12,6 +12,8 @@ module.exports = (sequelize, DataTypes) => { static associate(models) { // a container has many services Container.hasMany(models.Service, { foreignKey: 'containerId', as: 'services' }); + // a container belongs to a node + Container.belongsTo(models.Node, { foreignKey: 'nodeId', as: 'node' }); } } Container.init({ @@ -28,10 +30,17 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.STRING(255), allowNull: true }, + nodeId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'Nodes', + key: 'id' + } + }, containerId: { type: DataTypes.INTEGER.UNSIGNED, - allowNull: false, - unique: true + allowNull: false }, macAddress: { type: DataTypes.STRING(17), @@ -51,6 +60,13 @@ module.exports = (sequelize, DataTypes) => { }, { sequelize, modelName: 'Container', + indexes: [ + { + name: 'containers_node_id_container_id_unique', + unique: true, + fields: ['nodeId', 'containerId'] + } + ] }); return Container; }; \ No newline at end of file diff --git a/create-a-container/models/node.js b/create-a-container/models/node.js new file mode 100644 index 00000000..7766e1f2 --- /dev/null +++ b/create-a-container/models/node.js @@ -0,0 +1,44 @@ +'use strict'; +const { + Model +} = require('sequelize'); +module.exports = (sequelize, DataTypes) => { + class Node extends Model { + /** + * Helper method for defining associations. + * This method is not a part of Sequelize lifecycle. + * The `models/index` file will call this method automatically. + */ + static associate(models) { + // A node has many containers + Node.hasMany(models.Container, { foreignKey: 'nodeId', as: 'containers' }); + } + } + Node.init({ + name: { + type: DataTypes.STRING(255), + allowNull: false, + unique: true + }, + apiUrl: { + type: DataTypes.STRING(255), + allowNull: true + }, + tokenId: { + type: DataTypes.STRING(255), + allowNull: true + }, + secret: { + type: DataTypes.STRING(255), + allowNull: true + }, + tlsVerify: { + type: DataTypes.BOOLEAN, + allowNull: true + } + }, { + sequelize, + modelName: 'Node', + }); + return Node; +}; \ No newline at end of file diff --git a/create-a-container/models/service.js b/create-a-container/models/service.js index f32a2edd..3bf9e22c 100644 --- a/create-a-container/models/service.js +++ b/create-a-container/models/service.js @@ -34,6 +34,18 @@ module.exports = (sequelize, DataTypes) => { }, { sequelize, modelName: 'Service', + indexes: [ + { + name: 'services_http_unique_hostname', + unique: true, + fields: ['externalHostname'] + }, + { + name: 'services_layer4_unique_port', + unique: true, + fields: ['type', 'externalPort'] + } + ] }); return Service; }; diff --git a/create-a-container/package-lock.json b/create-a-container/package-lock.json index 3918295a..35fe94e0 100644 --- a/create-a-container/package-lock.json +++ b/create-a-container/package-lock.json @@ -6,11 +6,14 @@ "": { "dependencies": { "axios": "^1.12.2", + "connect-flash": "^0.1.1", "dotenv": "^17.2.3", "ejs": "^3.1.10", "express": "^5.1.0", "express-rate-limit": "^8.1.0", "express-session": "^1.18.2", + "express-session-sequelize": "^2.3.0", + "method-override": "^3.0.0", "mysql2": "^3.15.2", "nodemailer": "^7.0.9", "sequelize": "^6.37.7", @@ -451,6 +454,14 @@ "proto-list": "~1.2.1" } }, + "node_modules/connect-flash": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/connect-flash/-/connect-flash-0.1.1.tgz", + "integrity": "sha512-2rcfELQt/ZMP+SM/pG8PyhJRaLKp+6Hk2IUBNkEit09X+vwn3QsAL3ZbYtxUn7NVPzbMTSLRDhqe0B/eh30RYA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/content-disposition": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -794,6 +805,12 @@ "node": ">= 0.8.0" } }, + "node_modules/express-session-sequelize": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/express-session-sequelize/-/express-session-sequelize-2.3.0.tgz", + "integrity": "sha512-jwYfB5XueswXKXPstN+vNdU+lbu0oztcrPW5NVV0hgtB0GMm6AQPzrpWLAshvzSBnttXqfkiW1ur8W67k0FMdg==", + "license": "MIT" + }, "node_modules/express-session/node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", @@ -1473,6 +1490,45 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/method-override": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/method-override/-/method-override-3.0.0.tgz", + "integrity": "sha512-IJ2NNN/mSl9w3kzWB92rcdHpz+HjkxhDJWNDBqSlas+zQdP8wBiJzITPg08M/k2uVvMow7Sk41atndNtt/PHSA==", + "license": "MIT", + "dependencies": { + "debug": "3.1.0", + "methods": "~1.1.2", + "parseurl": "~1.3.2", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/method-override/node_modules/debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/method-override/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", diff --git a/create-a-container/package.json b/create-a-container/package.json index f02aed29..05b17e17 100644 --- a/create-a-container/package.json +++ b/create-a-container/package.json @@ -5,11 +5,14 @@ }, "dependencies": { "axios": "^1.12.2", + "connect-flash": "^0.1.1", "dotenv": "^17.2.3", "ejs": "^3.1.10", "express": "^5.1.0", "express-rate-limit": "^8.1.0", "express-session": "^1.18.2", + "express-session-sequelize": "^2.3.0", + "method-override": "^3.0.0", "mysql2": "^3.15.2", "nodemailer": "^7.0.9", "sequelize": "^6.37.7", diff --git a/create-a-container/routers/nodes.js b/create-a-container/routers/nodes.js new file mode 100644 index 00000000..462465a2 --- /dev/null +++ b/create-a-container/routers/nodes.js @@ -0,0 +1,158 @@ +const express = require('express'); +const router = express.Router(); +const { Node, Container } = require('../models'); +const { requireAuth, requireAdmin } = require('../middlewares'); + +// Apply auth and admin check to all routes +router.use(requireAuth); +router.use(requireAdmin); + +// GET /nodes - List all nodes +router.get('/', async (req, res) => { + const nodes = await Node.findAll({ + include: [{ + model: Container, + as: 'containers', + attributes: ['id'] + }], + attributes: { exclude: ['secret'] } // Never send secret to frontend + }); + + const rows = nodes.map(n => ({ + id: n.id, + name: n.name, + apiUrl: n.apiUrl, + tlsVerify: n.tlsVerify, + containerCount: n.containers ? n.containers.length : 0 + })); + + return res.render('nodes/index', { + rows, + isAdmin: req.session.isAdmin || false, + successMessages: req.flash('success'), + errorMessages: req.flash('error') + }); +}); + +// GET /nodes/new - Display form for creating a new node +router.get('/new', (req, res) => { + res.render('nodes/form', { + node: null, + isEdit: false, + isAdmin: req.session.isAdmin || false, + errorMessages: req.flash('error') + }); +}); + +// GET /nodes/:id/edit - Display form for editing an existing node +router.get('/:id/edit', async (req, res) => { + const nodeId = parseInt(req.params.id, 10); + + const node = await Node.findByPk(nodeId, { + attributes: { exclude: ['secret'] } // Never send secret to frontend + }); + + if (!node) { + req.flash('error', 'Node not found'); + return res.redirect('/nodes'); + } + + res.render('nodes/form', { + node, + isEdit: true, + isAdmin: req.session.isAdmin || false, + errorMessages: req.flash('error') + }); +}); + +// POST /nodes - Create a new node +router.post('/', async (req, res) => { + try { + const { name, apiUrl, tokenId, secret, tlsVerify } = req.body; + + await Node.create({ + name, + apiUrl: apiUrl || null, + tokenId: tokenId || null, + secret: secret || null, + tlsVerify: tlsVerify === '' || tlsVerify === null ? null : tlsVerify === 'true' + }); + + req.flash('success', `Node ${name} created successfully`); + return res.redirect('/nodes'); + } catch (err) { + console.error('Error creating node:', err); + req.flash('error', `Failed to create node: ${err.message}`); + return res.redirect('/nodes/new'); + } +}); + +// PUT /nodes/:id - Update an existing node +router.put('/:id', async (req, res) => { + const nodeId = parseInt(req.params.id, 10); + + try { + const node = await Node.findByPk(nodeId); + + if (!node) { + req.flash('error', 'Node not found'); + return res.redirect('/nodes'); + } + + const { name, apiUrl, tokenId, secret, tlsVerify } = req.body; + + const updateData = { + name, + apiUrl: apiUrl || null, + tokenId: tokenId || null, + tlsVerify: tlsVerify === '' || tlsVerify === null ? null : tlsVerify === 'true' + }; + + // Only update secret if a new value was provided + if (secret && secret.trim() !== '') { + updateData.secret = secret; + } + + await node.update(updateData); + + req.flash('success', `Node ${name} updated successfully`); + return res.redirect('/nodes'); + } catch (err) { + console.error('Error updating node:', err); + req.flash('error', `Failed to update node: ${err.message}`); + return res.redirect(`/nodes/${nodeId}/edit`); + } +}); + +// DELETE /nodes/:id - Delete a node +router.delete('/:id', async (req, res) => { + const nodeId = parseInt(req.params.id, 10); + + try { + const node = await Node.findByPk(nodeId, { + include: [{ model: Container, as: 'containers' }] + }); + + if (!node) { + req.flash('error', 'Node not found'); + return res.redirect('/nodes'); + } + + // Check if node has containers + if (node.containers && node.containers.length > 0) { + req.flash('error', `Cannot delete node ${node.name}: ${node.containers.length} container(s) still reference this node`); + return res.redirect('/nodes'); + } + + await node.destroy(); + + req.flash('success', `Node ${node.name} deleted successfully`); + return res.redirect('/nodes'); + } catch (err) { + console.error('Error deleting node:', err); + req.flash('error', `Failed to delete node: ${err.message}`); + return res.redirect('/nodes'); + } +}); + +module.exports = router; diff --git a/create-a-container/server.js b/create-a-container/server.js index 8285ff4c..5601b4f1 100644 --- a/create-a-container/server.js +++ b/create-a-container/server.js @@ -2,6 +2,9 @@ require('dotenv').config(); const express = require('express'); const session = require('express-session'); +const SequelizeStore = require('express-session-sequelize')(session.Store); +const flash = require('connect-flash'); +const methodOverride = require('method-override'); const { spawn, exec } = require('child_process'); const path = require('path'); const crypto = require('crypto'); @@ -10,7 +13,9 @@ const nodemailer = require('nodemailer'); // <-- added const axios = require('axios'); const qs = require('querystring'); const https = require('https'); -const { Container, Service } = require('./models'); +const { Container, Service, Node, sequelize } = require('./models'); +const { requireAuth } = require('./middlewares'); +const { ProxmoxApi } = require('./utils'); const serviceMap = require('./data/services.json'); const app = express(); @@ -23,12 +28,31 @@ app.set('trust proxy', 1); // setup middleware app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Parse form data +app.use(methodOverride((req, res) => { + if (req.body && typeof req.body === 'object' && '_method' in req.body) { + const method = req.body._method; + delete req.body._method; + return method; + } +})); + +// Configure session store +const sessionStore = new SequelizeStore({ + db: sequelize, +}); + app.use(session({ secret: process.env.SESSION_SECRET, + store: sessionStore, resave: false, - saveUninitialized: true, - cookie: { secure: true } + saveUninitialized: false, + cookie: { + secure: true, + maxAge: 24 * 60 * 60 * 1000 // 24 hours + } })); + +app.use(flash()); app.use(express.static('public')); app.use(RateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes @@ -38,27 +62,24 @@ app.use(RateLimit({ // define globals const jobs = {}; -// --- Authentication middleware (single) --- -// Detect API requests and browser requests. API requests return 401 JSON, browser requests redirect to /login. -function requireAuth(req, res, next) { - if (req.session && req.session.user) return next(); - - // Heuristics to detect API requests: - // - X-Requested-With: XMLHttpRequest (old-style AJAX) - // - Accept header prefers JSON (application/json) - // - URL path starts with /api/ - const acceptsJSON = req.get('Accept') && req.get('Accept').includes('application/json'); - const isAjax = req.get('X-Requested-With') === 'XMLHttpRequest'; - const isApiPath = req.path && req.path.startsWith('/api/'); - - if (acceptsJSON || isAjax || isApiPath) { - return res.status(401).json({ error: 'Unauthorized' }); +// Helper function to determine node ID based on aiContainer and containerId +async function getNodeForContainer(aiContainer, containerId) { + let nodeName; + + if (aiContainer === 'FORTWAYNE') { + nodeName = 'mie-phxdc-ai-pve1'; + } else if (aiContainer === 'PHOENIX') { + nodeName = 'intern-phxdc-pve3-ai'; + } else { + nodeName = (containerId % 2 === 1) ? 'intern-phxdc-pve1' : 'intern-phxdc-pve2'; } - - // Otherwise treat as a browser route: include the original URL as a redirect parameter - const original = req.originalUrl || req.url || '/'; - const redirectTo = '/login?redirect=' + encodeURIComponent(original); - return res.redirect(redirectTo); + + const node = await Node.findOne({ where: { name: nodeName } }); + if (!node) { + throw new Error(`Node not found: ${nodeName}`); + } + + return node.id; } // Serve login page from views (moved from public) @@ -79,6 +100,10 @@ const transporter = nodemailer.createTransport({ }, }); +// --- Mount Routers --- +const nodesRouter = require('./routers/nodes'); +app.use('/nodes', nodesRouter); + // --- Routes --- const PORT = 3000; app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`)); @@ -91,34 +116,65 @@ app.get('/containers/new', requireAuth, (req, res) => { // Handles login app.post('/login', async (req, res) => { const { username, password } = req.body; + // Validate username: only allow alphanumerics, underscores, hyphens, 3-32 chars + if (!/^[a-zA-Z0-9_-]{3,32}$/.test(username)) { + return res.status(400).json({ error: 'Invalid username format' }); + } - const response = await axios.request({ - method: 'post', - url: 'https://10.15.0.4:8006/api2/json/access/ticket', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - httpsAgent: new https.Agent({ - rejectUnauthorized: true, // Enable validation - servername: 'opensource.mieweb.org' // Expected hostname in the certificate - }), - data: qs.stringify({ username: username + '@pve', password: password }) - }); + try { + const httpsAgent = new https.Agent({ + rejectUnauthorized: true, + servername: 'opensource.mieweb.org' + }); - if (response.status !== 200) { - return res.status(401).json({ error: 'Invalid credentials' }); - } + const response = await axios.request({ + method: 'post', + url: 'https://10.15.0.4:8006/api2/json/access/ticket', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + httpsAgent, + data: qs.stringify({ username: username + '@pve', password: password }) + }); + + if (response.status !== 200) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + + // Store ticket and CSRF token for subsequent API calls + const { ticket, CSRFPreventionToken } = response.data.data; + + // Query user groups to check for admin privileges + const userResponse = await axios.request({ + method: 'get', + url: `https://10.15.0.4:8006/api2/json/access/users/${username}@pve`, + headers: { + 'CSRFPreventionToken': CSRFPreventionToken, + 'Cookie': `PVEAuthCookie=${ticket}` + }, + httpsAgent + }); + + const groups = userResponse.data?.data?.groups || []; + const isAdmin = groups.includes('administrators'); - req.session.user = username; - req.session.proxmoxUsername = username; - req.session.proxmoxPassword = password; + req.session.user = username; + req.session.proxmoxUsername = username + '@pve'; + req.session.proxmoxPassword = password; + req.session.isAdmin = isAdmin; - return res.json({ success: true, redirect: req?.query?.redirect || '/' }); + return res.json({ success: true, redirect: req?.query?.redirect || '/' }); + } catch (error) { + console.error('Login error:', error.message); + return res.status(401).json({ error: 'Invalid credentials' }); + } }); // Fetch user's containers app.get('/containers', requireAuth, async (req, res) => { - const username = req.session.user.split('@')[0]; // eager-load related services - const containers = await Container.findAll({ where: { username }, include: [{ association: 'services' }] }); + const containers = await Container.findAll({ + where: { username: req.session.user }, + include: [{ association: 'services' }] + }); // Map containers to view models const rows = containers.map(c => { @@ -130,6 +186,7 @@ app.get('/containers', requireAuth, async (req, res) => { const http = services.find(s => s.type === 'http'); const httpPort = http ? http.internalPort : null; return { + id: c.id, hostname: c.hostname, ipv4Address: c.ipv4Address, osRelease: c.osRelease, @@ -138,7 +195,12 @@ app.get('/containers', requireAuth, async (req, res) => { }; }); - return res.render('containers', { rows }); + return res.render('containers', { + rows, + isAdmin: req.session.isAdmin || false, + successMessages: req.flash('success'), + errorMessages: req.flash('error') + }); }); // Generate nginx configuration for a container @@ -194,7 +256,14 @@ app.post('/containers', async (req, res) => { } // handle non-init container creation (e.g., admin API) - const container = await Container.create(req.body); + const aiContainer = req.body.aiContainer || 'N'; + const containerId = req.body.containerId; + const nodeId = await getNodeForContainer(aiContainer, containerId); + + const container = await Container.create({ + ...req.body, + nodeId + }); const httpService = await Service.create({ containerId: container.id, type: 'http', @@ -232,6 +301,66 @@ app.post('/containers', async (req, res) => { return res.json({ success: true }); }); +// Delete container +app.delete('/containers/:id', requireAuth, async (req, res) => { + const containerId = parseInt(req.params.id, 10); + + // Find the container with ownership check in query to prevent information leakage + const container = await Container.findOne({ + where: { + id: containerId, + username: req.session.user + }, + include: [{ + model: Node, + as: 'node', + attributes: ['id', 'name', 'apiUrl', 'tokenId', 'secret', 'tlsVerify'] + }] + }); + + if (!container) { + req.flash('error', 'Container not found'); + return res.redirect('/containers'); + } + + const node = container.node; + if (!node || !node.apiUrl) { + req.flash('error', 'Node API URL not configured'); + return res.redirect('/containers'); + } + + if (!node.tokenId || !node.secret) { + req.flash('error', 'Node API token not configured'); + return res.redirect('/containers'); + } + + // Delete from Proxmox + try { + const api = new ProxmoxApi( + node.apiUrl, + node.tokenId, + node.secret, + { + httpsAgent: new https.Agent({ + rejectUnauthorized: node.tlsVerify !== false, + }) + } + ); + + await api.deleteContainer(node.name, container.containerId, true, true); + } catch (error) { + console.error(error); + req.flash('error', `Failed to delete container from Proxmox: ${error.message}`); + return res.redirect('/containers'); + } + + // Delete from database (cascade deletes associated services) + await container.destroy(); + + req.flash('success', `Container ${container.hostname} deleted successfully`); + return res.redirect('/containers'); +}); + // Job status page app.get('/status/:jobId', requireAuth, (req, res) => { if (!jobs[req.params.jobId]) { diff --git a/create-a-container/utils/index.js b/create-a-container/utils/index.js new file mode 100644 index 00000000..7ae5affd --- /dev/null +++ b/create-a-container/utils/index.js @@ -0,0 +1,5 @@ +const ProxmoxApi = require('./proxmox-api'); + +module.exports = { + ProxmoxApi +}; diff --git a/create-a-container/utils/proxmox-api.js b/create-a-container/utils/proxmox-api.js new file mode 100644 index 00000000..8405b474 --- /dev/null +++ b/create-a-container/utils/proxmox-api.js @@ -0,0 +1,34 @@ +const axios = require('axios'); + +class ProxmoxApi { + constructor(baseUrl, tokenId, secret, options = {}) { + this.baseUrl = baseUrl; + this.tokenId = tokenId; + this.secret = secret; + this.options = options; + } + + async deleteContainer(nodeName, containerId, force = false, purge = false) { + if (!this.tokenId || !this.secret) { + throw new Error('Token ID and secret are required for authentication.'); + } + + const params = {}; + if (force) params.force = 1; + if (purge) params.purge = 1; + + const response = await axios.request({ + method: 'delete', + url: `${this.baseUrl}/api2/json/nodes/${nodeName}/lxc/${containerId}`, + headers: { + 'Authorization': `PVEAPIToken=${this.tokenId}=${this.secret}` + }, + params, + ...this.options + }); + + return response.data; + } +} + +module.exports = ProxmoxApi; diff --git a/create-a-container/views/containers.ejs b/create-a-container/views/containers.ejs index bc0bebb6..9a786788 100644 --- a/create-a-container/views/containers.ejs +++ b/create-a-container/views/containers.ejs @@ -12,6 +12,9 @@