From 860c0b290a5c484638ae6d36ae5cc5ade5b9dba3 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 4 Nov 2025 18:13:37 +0000 Subject: [PATCH 01/19] fix #88: add uniqueness constraints for services --- ...0710-add-service-uniqueness-constraints.js | 24 +++++++++++++++++++ create-a-container/models/service.js | 12 ++++++++++ 2 files changed, 36 insertions(+) create mode 100644 create-a-container/migrations/20251104160710-add-service-uniqueness-constraints.js 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/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; }; From c81803fe47f8ace31e16a19bf89aa43dad213dc7 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 4 Nov 2025 18:29:22 +0000 Subject: [PATCH 02/19] fix #99: disable sequelize logging in production --- create-a-container/config/config.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 From 88ab113ffb64a7b65315eec73ed00bcce7f55f6b Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 4 Nov 2025 19:02:52 +0000 Subject: [PATCH 03/19] add node to containers model --- ...1104184238-add-node-field-to-containers.js | 62 +++++++++++++++++++ create-a-container/models/container.js | 14 ++++- 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 create-a-container/migrations/20251104184238-add-node-field-to-containers.js 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/models/container.js b/create-a-container/models/container.js index bf620b07..d5385035 100644 --- a/create-a-container/models/container.js +++ b/create-a-container/models/container.js @@ -28,10 +28,13 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.STRING(255), allowNull: true }, + node: { + type: DataTypes.STRING(255), + allowNull: false + }, containerId: { type: DataTypes.INTEGER.UNSIGNED, - allowNull: false, - unique: true + allowNull: false }, macAddress: { type: DataTypes.STRING(17), @@ -51,6 +54,13 @@ module.exports = (sequelize, DataTypes) => { }, { sequelize, modelName: 'Container', + indexes: [ + { + name: 'containers_node_container_id_unique', + unique: true, + fields: ['node', 'containerId'] + } + ] }); return Container; }; \ No newline at end of file From c3bf3e24bae659b43e405112eb196e3480f116e9 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 4 Nov 2025 19:10:14 +0000 Subject: [PATCH 04/19] fix #85: add node determination to server.js --- create-a-container/server.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/create-a-container/server.js b/create-a-container/server.js index 8285ff4c..da1515d5 100644 --- a/create-a-container/server.js +++ b/create-a-container/server.js @@ -38,6 +38,17 @@ app.use(RateLimit({ // define globals const jobs = {}; +// Helper function to determine node based on aiContainer and containerId +function getNodeForContainer(aiContainer, containerId) { + if (aiContainer === 'FORTWAYNE') { + return 'mie-phxdc-ai-pve1'; + } else if (aiContainer === 'PHOENIX') { + return 'intern-phxdc-pve3-ai'; + } + + return (containerId % 2 === 1) ? 'intern-phxdc-pve1' : 'intern-phxdc-pve2'; +} + // --- Authentication middleware (single) --- // Detect API requests and browser requests. API requests return 401 JSON, browser requests redirect to /login. function requireAuth(req, res, next) { @@ -194,7 +205,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 node = getNodeForContainer(aiContainer, containerId); + + const container = await Container.create({ + ...req.body, + node + }); const httpService = await Service.create({ containerId: container.id, type: 'http', From 607784b1ef38c9366cd9334a12a5aff3e2b29b62 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 4 Nov 2025 19:51:09 +0000 Subject: [PATCH 05/19] Add Nodes model --- create-a-container/README.md | 50 ++++++++++++ .../migrations/20251104193601-create-node.js | 46 +++++++++++ ...93722-convert-container-node-to-node-id.js | 78 +++++++++++++++++++ create-a-container/models/container.js | 16 ++-- create-a-container/models/node.js | 36 +++++++++ create-a-container/server.js | 27 ++++--- 6 files changed, 239 insertions(+), 14 deletions(-) create mode 100644 create-a-container/migrations/20251104193601-create-node.js create mode 100644 create-a-container/migrations/20251104193722-convert-container-node-to-node-id.js create mode 100644 create-a-container/models/node.js diff --git a/create-a-container/README.md b/create-a-container/README.md index d0b457bf..0452b272 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 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/models/container.js b/create-a-container/models/container.js index d5385035..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,9 +30,13 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.STRING(255), allowNull: true }, - node: { - type: DataTypes.STRING(255), - allowNull: false + nodeId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'Nodes', + key: 'id' + } }, containerId: { type: DataTypes.INTEGER.UNSIGNED, @@ -56,9 +62,9 @@ module.exports = (sequelize, DataTypes) => { modelName: 'Container', indexes: [ { - name: 'containers_node_container_id_unique', + name: 'containers_node_id_container_id_unique', unique: true, - fields: ['node', 'containerId'] + fields: ['nodeId', 'containerId'] } ] }); diff --git a/create-a-container/models/node.js b/create-a-container/models/node.js new file mode 100644 index 00000000..81257fe9 --- /dev/null +++ b/create-a-container/models/node.js @@ -0,0 +1,36 @@ +'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 + }, + tlsVerify: { + type: DataTypes.BOOLEAN, + allowNull: true + } + }, { + sequelize, + modelName: 'Node', + }); + return Node; +}; \ No newline at end of file diff --git a/create-a-container/server.js b/create-a-container/server.js index da1515d5..1fe82915 100644 --- a/create-a-container/server.js +++ b/create-a-container/server.js @@ -10,7 +10,7 @@ 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 } = require('./models'); const serviceMap = require('./data/services.json'); const app = express(); @@ -38,15 +38,24 @@ app.use(RateLimit({ // define globals const jobs = {}; -// Helper function to determine node based on aiContainer and containerId -function getNodeForContainer(aiContainer, containerId) { +// Helper function to determine node ID based on aiContainer and containerId +async function getNodeForContainer(aiContainer, containerId) { + let nodeName; + if (aiContainer === 'FORTWAYNE') { - return 'mie-phxdc-ai-pve1'; + nodeName = 'mie-phxdc-ai-pve1'; } else if (aiContainer === 'PHOENIX') { - return 'intern-phxdc-pve3-ai'; + nodeName = 'intern-phxdc-pve3-ai'; + } else { + nodeName = (containerId % 2 === 1) ? 'intern-phxdc-pve1' : 'intern-phxdc-pve2'; } - - return (containerId % 2 === 1) ? 'intern-phxdc-pve1' : 'intern-phxdc-pve2'; + + const node = await Node.findOne({ where: { name: nodeName } }); + if (!node) { + throw new Error(`Node not found: ${nodeName}`); + } + + return node.id; } // --- Authentication middleware (single) --- @@ -207,11 +216,11 @@ app.post('/containers', async (req, res) => { // handle non-init container creation (e.g., admin API) const aiContainer = req.body.aiContainer || 'N'; const containerId = req.body.containerId; - const node = getNodeForContainer(aiContainer, containerId); + const nodeId = await getNodeForContainer(aiContainer, containerId); const container = await Container.create({ ...req.body, - node + nodeId }); const httpService = await Service.create({ containerId: container.id, From be0135843595032b9a9f12ddfc4550193bb1b549 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 4 Nov 2025 20:13:47 +0000 Subject: [PATCH 06/19] fix #81: add delete /containers/:id route --- create-a-container/README.md | 14 +++++++++ create-a-container/server.js | 61 +++++++++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/create-a-container/README.md b/create-a-container/README.md index 0452b272..c2332ac0 100644 --- a/create-a-container/README.md +++ b/create-a-container/README.md @@ -213,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/server.js b/create-a-container/server.js index 1fe82915..3841dd24 100644 --- a/create-a-container/server.js +++ b/create-a-container/server.js @@ -127,9 +127,14 @@ app.post('/login', async (req, res) => { return res.status(401).json({ error: 'Invalid credentials' }); } + // Store ticket and CSRF token for subsequent API calls + const { ticket, CSRFPreventionToken } = response.data.data; + req.session.user = username; - req.session.proxmoxUsername = username; + req.session.proxmoxUsername = username + '@pve'; req.session.proxmoxPassword = password; + req.session.proxmoxTicket = ticket; + req.session.proxmoxCSRFToken = CSRFPreventionToken; return res.json({ success: true, redirect: req?.query?.redirect || '/' }); }); @@ -259,6 +264,60 @@ 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 its associated node + const container = await Container.findOne({ + where: { id: containerId }, + include: [{ model: Node, as: 'node' }] + }); + + if (!container) { + return res.status(404).json({ error: 'Container not found' }); + } + + // Verify ownership (only the owner can delete their container) + const username = req.session.user.split('@')[0]; + if (container.username !== username) { + return res.status(403).json({ error: 'Forbidden: You can only delete your own containers' }); + } + + const node = container.node; + if (!node || !node.apiUrl) { + return res.status(500).json({ error: 'Node API URL not configured' }); + } + + // Delete from Proxmox + const proxmoxUrl = `${node.apiUrl}/api2/json/nodes/${node.name}/lxc/${container.containerId}`; + + try { + await axios.request({ + method: 'delete', + url: proxmoxUrl, + headers: { + 'CSRFPreventionToken': req.session.proxmoxCSRFToken, + 'Cookie': `PVEAuthCookie=${req.session.proxmoxTicket}` + }, + httpsAgent: new https.Agent({ + rejectUnauthorized: node.tlsVerify !== false, + }) + }); + } catch (err) { + console.error('Proxmox API error:', err.response?.data || err.message); + return res.status(500).json({ + error: 'Failed to delete container from Proxmox', + details: err.response?.data || err.message + }); + } + + // Delete from database (cascade deletes associated services) + await container.destroy(); + + return res.json({ success: true, message: 'Container deleted successfully' }); +}); + // Job status page app.get('/status/:jobId', requireAuth, (req, res) => { if (!jobs[req.params.jobId]) { From 9d93641ca52e599fcc7f2b561fde55e2ed052749 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 4 Nov 2025 20:36:54 +0000 Subject: [PATCH 07/19] fix: improve error handling in container deletion route --- create-a-container/server.js | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/create-a-container/server.js b/create-a-container/server.js index 3841dd24..32b278ca 100644 --- a/create-a-container/server.js +++ b/create-a-container/server.js @@ -292,23 +292,24 @@ app.delete('/containers/:id', requireAuth, async (req, res) => { // Delete from Proxmox const proxmoxUrl = `${node.apiUrl}/api2/json/nodes/${node.name}/lxc/${container.containerId}`; - try { - await axios.request({ - method: 'delete', - url: proxmoxUrl, - headers: { - 'CSRFPreventionToken': req.session.proxmoxCSRFToken, - 'Cookie': `PVEAuthCookie=${req.session.proxmoxTicket}` - }, - httpsAgent: new https.Agent({ - rejectUnauthorized: node.tlsVerify !== false, - }) - }); - } catch (err) { - console.error('Proxmox API error:', err.response?.data || err.message); + const response = await axios.request({ + method: 'delete', + url: proxmoxUrl, + headers: { + 'CSRFPreventionToken': req.session.proxmoxCSRFToken, + 'Authorization': `PVEAuthCookie=${req.session.proxmoxTicket}` + }, + httpsAgent: new https.Agent({ + rejectUnauthorized: node.tlsVerify !== false, + }), + }); + + if (response.status !== 200) { + console.error('Proxmox API error:', response.status, response.data); return res.status(500).json({ error: 'Failed to delete container from Proxmox', - details: err.response?.data || err.message + status: response.status, + details: response.data }); } From ba44e69161da84715b485d43d0ec46eb97c3e75d Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 5 Nov 2025 13:41:32 +0000 Subject: [PATCH 08/19] feat: add flash messages for success and error notifications in containers view --- create-a-container/package-lock.json | 49 +++++++++++++++++++++++++ create-a-container/package.json | 2 + create-a-container/server.js | 47 +++++++++++++++--------- create-a-container/views/containers.ejs | 28 +++++++++++++- 4 files changed, 108 insertions(+), 18 deletions(-) diff --git a/create-a-container/package-lock.json b/create-a-container/package-lock.json index 3918295a..2c801211 100644 --- a/create-a-container/package-lock.json +++ b/create-a-container/package-lock.json @@ -6,11 +6,13 @@ "": { "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", + "method-override": "^3.0.0", "mysql2": "^3.15.2", "nodemailer": "^7.0.9", "sequelize": "^6.37.7", @@ -451,6 +453,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", @@ -1473,6 +1483,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..a8c78362 100644 --- a/create-a-container/package.json +++ b/create-a-container/package.json @@ -5,11 +5,13 @@ }, "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", + "method-override": "^3.0.0", "mysql2": "^3.15.2", "nodemailer": "^7.0.9", "sequelize": "^6.37.7", diff --git a/create-a-container/server.js b/create-a-container/server.js index 32b278ca..6605a3f8 100644 --- a/create-a-container/server.js +++ b/create-a-container/server.js @@ -2,6 +2,8 @@ require('dotenv').config(); const express = require('express'); const session = require('express-session'); +const flash = require('connect-flash'); +const methodOverride = require('method-override'); const { spawn, exec } = require('child_process'); const path = require('path'); const crypto = require('crypto'); @@ -23,12 +25,20 @@ 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; + } +})); app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: true, cookie: { secure: true } })); +app.use(flash()); app.use(express.static('public')); app.use(RateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes @@ -155,6 +165,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, @@ -163,7 +174,11 @@ app.get('/containers', requireAuth, async (req, res) => { }; }); - return res.render('containers', { rows }); + return res.render('containers', { + rows, + successMessages: req.flash('success'), + errorMessages: req.flash('error') + }); }); // Generate nginx configuration for a container @@ -267,26 +282,26 @@ app.post('/containers', async (req, res) => { // Delete container app.delete('/containers/:id', requireAuth, async (req, res) => { const containerId = parseInt(req.params.id, 10); + const username = req.session.user.split('@')[0]; - // Find the container with its associated node + // Find the container with ownership check in query to prevent information leakage const container = await Container.findOne({ - where: { id: containerId }, + where: { + id: containerId, + username: username + }, include: [{ model: Node, as: 'node' }] }); if (!container) { - return res.status(404).json({ error: 'Container not found' }); - } - - // Verify ownership (only the owner can delete their container) - const username = req.session.user.split('@')[0]; - if (container.username !== username) { - return res.status(403).json({ error: 'Forbidden: You can only delete your own containers' }); + req.flash('error', 'Container not found'); + return res.redirect('/containers'); } const node = container.node; if (!node || !node.apiUrl) { - return res.status(500).json({ error: 'Node API URL not configured' }); + req.flash('error', 'Node API URL not configured'); + return res.redirect('/containers'); } // Delete from Proxmox @@ -306,17 +321,15 @@ app.delete('/containers/:id', requireAuth, async (req, res) => { if (response.status !== 200) { console.error('Proxmox API error:', response.status, response.data); - return res.status(500).json({ - error: 'Failed to delete container from Proxmox', - status: response.status, - details: response.data - }); + req.flash('error', `Failed to delete container from Proxmox: ${response.data?.errors || response.statusText}`); + return res.redirect('/containers'); } // Delete from database (cascade deletes associated services) await container.destroy(); - return res.json({ success: true, message: 'Container deleted successfully' }); + req.flash('success', `Container ${container.hostname} deleted successfully`); + return res.redirect('/containers'); }); // Job status page diff --git a/create-a-container/views/containers.ejs b/create-a-container/views/containers.ejs index bc0bebb6..f9e5b3cb 100644 --- a/create-a-container/views/containers.ejs +++ b/create-a-container/views/containers.ejs @@ -22,6 +22,25 @@
+ + <% if (successMessages && successMessages.length > 0) { %> + <% successMessages.forEach(msg => { %> + + <% }) %> + <% } %> + + <% if (errorMessages && errorMessages.length > 0) { %> + <% errorMessages.forEach(msg => { %> + + <% }) %> + <% } %> +
@@ -38,6 +57,7 @@ OS Release SSH Port HTTP Port + Actions @@ -49,11 +69,17 @@ <%= r.osRelease || '-' %> <%= r.sshPort || '-' %> <%= r.httpPort || '-' %> + +
+ + +
+ <% }) %> <% } else { %> - + No containers found. Click "New Container" to create your first one. From 1200eecdda0d283a13ae951800c54eea8828ea37 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 5 Nov 2025 13:57:13 +0000 Subject: [PATCH 09/19] feat: implement authentication middleware and add nodes management routes and views --- create-a-container/middlewares/index.js | 24 ++++ create-a-container/routers/nodes.js | 141 +++++++++++++++++++++++ create-a-container/server.js | 28 +---- create-a-container/views/containers.ejs | 1 + create-a-container/views/nodes/form.ejs | 101 ++++++++++++++++ create-a-container/views/nodes/index.ejs | 109 ++++++++++++++++++ 6 files changed, 381 insertions(+), 23 deletions(-) create mode 100644 create-a-container/middlewares/index.js create mode 100644 create-a-container/routers/nodes.js create mode 100644 create-a-container/views/nodes/form.ejs create mode 100644 create-a-container/views/nodes/index.ejs diff --git a/create-a-container/middlewares/index.js b/create-a-container/middlewares/index.js new file mode 100644 index 00000000..dc164e27 --- /dev/null +++ b/create-a-container/middlewares/index.js @@ -0,0 +1,24 @@ +// 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' }); + } + + // 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); +} + +module.exports = { requireAuth }; diff --git a/create-a-container/routers/nodes.js b/create-a-container/routers/nodes.js new file mode 100644 index 00000000..c40bb3e6 --- /dev/null +++ b/create-a-container/routers/nodes.js @@ -0,0 +1,141 @@ +const express = require('express'); +const router = express.Router(); +const { Node, Container } = require('../models'); +const { requireAuth } = require('../middlewares'); + +// Apply auth to all routes +router.use(requireAuth); + +// GET /nodes - List all nodes +router.get('/', async (req, res) => { + const nodes = await Node.findAll({ + include: [{ + model: Container, + as: 'containers', + attributes: ['id'] + }] + }); + + 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, + 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, + 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); + + if (!node) { + req.flash('error', 'Node not found'); + return res.redirect('/nodes'); + } + + res.render('nodes/form', { + node, + isEdit: true, + errorMessages: req.flash('error') + }); +}); + +// POST /nodes - Create a new node +router.post('/', async (req, res) => { + try { + const { name, apiUrl, tlsVerify } = req.body; + + await Node.create({ + name, + apiUrl: apiUrl || null, + tlsVerify: tlsVerify === 'true' || 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, tlsVerify } = req.body; + + await node.update({ + name, + apiUrl: apiUrl || null, + tlsVerify: tlsVerify === 'true' || tlsVerify === true + }); + + 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 6605a3f8..d4046619 100644 --- a/create-a-container/server.js +++ b/create-a-container/server.js @@ -13,6 +13,7 @@ const axios = require('axios'); const qs = require('querystring'); const https = require('https'); const { Container, Service, Node } = require('./models'); +const { requireAuth } = require('./middlewares'); const serviceMap = require('./data/services.json'); const app = express(); @@ -68,29 +69,6 @@ async function getNodeForContainer(aiContainer, containerId) { return node.id; } -// --- 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' }); - } - - // 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); -} - // Serve login page from views (moved from public) app.get('/login', (req, res) => { res.sendFile(path.join(__dirname, 'views', 'login.html')); @@ -109,6 +87,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}`)); diff --git a/create-a-container/views/containers.ejs b/create-a-container/views/containers.ejs index f9e5b3cb..fdf701ef 100644 --- a/create-a-container/views/containers.ejs +++ b/create-a-container/views/containers.ejs @@ -12,6 +12,7 @@
diff --git a/create-a-container/views/nodes/form.ejs b/create-a-container/views/nodes/form.ejs new file mode 100644 index 00000000..7d6b2658 --- /dev/null +++ b/create-a-container/views/nodes/form.ejs @@ -0,0 +1,101 @@ + + + + + <%= isEdit ? 'Edit Node' : 'New Node' %> - MIE + + + + + +
+ +
+
+
+ + <% if (errorMessages && errorMessages.length > 0) { %> + <% errorMessages.forEach(msg => { %> + + <% }) %> + <% } %> + +
+
+

<%= isEdit ? 'Edit Node' : 'Create New Node' %>

+ +
+ <% if (isEdit) { %> + + <% } %> + +
+ + +
The Proxmox node name (must be unique)
+
+ +
+ + +
The Proxmox API endpoint URL (optional)
+
+ +
+ + +
Whether to verify TLS certificates when connecting to this node
+
+ +
+ Cancel + +
+
+
+ +
+
+
+
+ + + + diff --git a/create-a-container/views/nodes/index.ejs b/create-a-container/views/nodes/index.ejs new file mode 100644 index 00000000..16455be4 --- /dev/null +++ b/create-a-container/views/nodes/index.ejs @@ -0,0 +1,109 @@ + + + + + Nodes - MIE + + + + + + + +
+
+
+ + <% if (successMessages && successMessages.length > 0) { %> + <% successMessages.forEach(msg => { %> + + <% }) %> + <% } %> + + <% if (errorMessages && errorMessages.length > 0) { %> + <% errorMessages.forEach(msg => { %> + + <% }) %> + <% } %> + +
+
+
+

Proxmox Nodes

+ New Node +
+ +
+ + + + + + + + + + + + <% if (rows && rows.length) { %> + <% rows.forEach(r => { %> + + + + + + + + <% }) %> + <% } else { %> + + + + <% } %> + +
NameAPI URLTLS VerifyContainersActions
<%= r.name %><%= r.apiUrl || '-' %> + <% if (r.tlsVerify === true) { %> + Yes + <% } else if (r.tlsVerify === false) { %> + No + <% } else { %> + Not Set + <% } %> + <%= r.containerCount %> + Edit +
+ + +
+
+ No nodes found. Click "New Node" to create your first one. +
+
+
+ +
+
+
+
+ + + + From 20734cc6d648a726c7f14e4432954ed101bcda6a Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 5 Nov 2025 15:23:38 +0000 Subject: [PATCH 10/19] feat: add tokenId and secret fields to Nodes model and update related routes and views --- .../20251105145958-add-api-token-to-nodes.js | 23 ++++++++ create-a-container/models/node.js | 8 +++ create-a-container/routers/nodes.js | 25 ++++++--- create-a-container/server.js | 53 +++++++++++-------- create-a-container/utils/index.js | 5 ++ create-a-container/utils/proxmox-api.js | 38 +++++++++++++ create-a-container/views/nodes/form.ejs | 30 +++++++++++ 7 files changed, 154 insertions(+), 28 deletions(-) create mode 100644 create-a-container/migrations/20251105145958-add-api-token-to-nodes.js create mode 100644 create-a-container/utils/index.js create mode 100644 create-a-container/utils/proxmox-api.js 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/models/node.js b/create-a-container/models/node.js index 81257fe9..7766e1f2 100644 --- a/create-a-container/models/node.js +++ b/create-a-container/models/node.js @@ -24,6 +24,14 @@ module.exports = (sequelize, DataTypes) => { 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 diff --git a/create-a-container/routers/nodes.js b/create-a-container/routers/nodes.js index c40bb3e6..bac34f00 100644 --- a/create-a-container/routers/nodes.js +++ b/create-a-container/routers/nodes.js @@ -13,7 +13,8 @@ router.get('/', async (req, res) => { model: Container, as: 'containers', attributes: ['id'] - }] + }], + attributes: { exclude: ['secret'] } // Never send secret to frontend }); const rows = nodes.map(n => ({ @@ -44,7 +45,9 @@ router.get('/new', (req, res) => { router.get('/:id/edit', async (req, res) => { const nodeId = parseInt(req.params.id, 10); - const node = await Node.findByPk(nodeId); + const node = await Node.findByPk(nodeId, { + attributes: { exclude: ['secret'] } // Never send secret to frontend + }); if (!node) { req.flash('error', 'Node not found'); @@ -61,11 +64,13 @@ router.get('/:id/edit', async (req, res) => { // POST /nodes - Create a new node router.post('/', async (req, res) => { try { - const { name, apiUrl, tlsVerify } = req.body; + const { name, apiUrl, tokenId, secret, tlsVerify } = req.body; await Node.create({ name, apiUrl: apiUrl || null, + tokenId: tokenId || null, + secret: secret || null, tlsVerify: tlsVerify === 'true' || tlsVerify === true }); @@ -90,13 +95,21 @@ router.put('/:id', async (req, res) => { return res.redirect('/nodes'); } - const { name, apiUrl, tlsVerify } = req.body; + const { name, apiUrl, tokenId, secret, tlsVerify } = req.body; - await node.update({ + const updateData = { name, apiUrl: apiUrl || null, + tokenId: tokenId || null, tlsVerify: tlsVerify === 'true' || 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'); diff --git a/create-a-container/server.js b/create-a-container/server.js index d4046619..fb758d29 100644 --- a/create-a-container/server.js +++ b/create-a-container/server.js @@ -14,6 +14,7 @@ const qs = require('querystring'); const https = require('https'); const { Container, Service, Node } = require('./models'); const { requireAuth } = require('./middlewares'); +const { ProxmoxApi } = require('./utils'); const serviceMap = require('./data/services.json'); const app = express(); @@ -108,20 +109,20 @@ app.post('/login', async (req, res) => { 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({ + 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 }) }); 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; - + req.session.user = username; req.session.proxmoxUsername = username + '@pve'; req.session.proxmoxPassword = password; @@ -272,7 +273,11 @@ app.delete('/containers/:id', requireAuth, async (req, res) => { id: containerId, username: username }, - include: [{ model: Node, as: 'node' }] + include: [{ + model: Node, + as: 'node', + attributes: ['id', 'name', 'apiUrl', 'tokenId', 'secret', 'tlsVerify'] + }] }); if (!container) { @@ -285,25 +290,29 @@ app.delete('/containers/:id', requireAuth, async (req, res) => { 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 - const proxmoxUrl = `${node.apiUrl}/api2/json/nodes/${node.name}/lxc/${container.containerId}`; - - const response = await axios.request({ - method: 'delete', - url: proxmoxUrl, - headers: { - 'CSRFPreventionToken': req.session.proxmoxCSRFToken, - 'Authorization': `PVEAuthCookie=${req.session.proxmoxTicket}` - }, - httpsAgent: new https.Agent({ - rejectUnauthorized: node.tlsVerify !== false, - }), - }); - - if (response.status !== 200) { - console.error('Proxmox API error:', response.status, response.data); - req.flash('error', `Failed to delete container from Proxmox: ${response.data?.errors || response.statusText}`); + 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'); } 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..80afef29 --- /dev/null +++ b/create-a-container/utils/proxmox-api.js @@ -0,0 +1,38 @@ +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 + }); + + if (response.status !== 200) { + throw new Error(`Failed to delete container: ${response.data?.errors || response.statusText}`); + } + + return response.data; + } +} + +module.exports = ProxmoxApi; diff --git a/create-a-container/views/nodes/form.ejs b/create-a-container/views/nodes/form.ejs index 7d6b2658..c62cc46b 100644 --- a/create-a-container/views/nodes/form.ejs +++ b/create-a-container/views/nodes/form.ejs @@ -70,6 +70,36 @@
The Proxmox API endpoint URL (optional)
+
+ + +
API token identifier (optional)
+
+ +
+ + +
+ <%= isEdit ? 'Leave blank to keep existing secret. Enter new secret to update.' : 'API token secret (optional)' %> +
+
+
- + diff --git a/create-a-container/views/nodes/form.ejs b/create-a-container/views/nodes/form.ejs index c62cc46b..df2d5c68 100644 --- a/create-a-container/views/nodes/form.ejs +++ b/create-a-container/views/nodes/form.ejs @@ -111,7 +111,7 @@
- Cancel + Cancel diff --git a/create-a-container/views/nodes/index.ejs b/create-a-container/views/nodes/index.ejs index 16455be4..7fdbd4be 100644 --- a/create-a-container/views/nodes/index.ejs +++ b/create-a-container/views/nodes/index.ejs @@ -46,7 +46,7 @@

Proxmox Nodes

- New Node + New Node
@@ -77,10 +77,20 @@ <%= r.containerCount %> - Edit + Edit
- +
From 8dba8d0c4bd15aa12605df65f853b804a7993183 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 5 Nov 2025 20:23:37 +0000 Subject: [PATCH 18/19] fix: remove error handling for unsuccessful container deletion response --- create-a-container/utils/proxmox-api.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/create-a-container/utils/proxmox-api.js b/create-a-container/utils/proxmox-api.js index 80afef29..8405b474 100644 --- a/create-a-container/utils/proxmox-api.js +++ b/create-a-container/utils/proxmox-api.js @@ -27,10 +27,6 @@ class ProxmoxApi { ...this.options }); - if (response.status !== 200) { - throw new Error(`Failed to delete container: ${response.data?.errors || response.statusText}`); - } - return response.data; } } From 843c2bd7930232b9c135ffedca3496c0de6bd1d3 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 5 Nov 2025 20:23:41 +0000 Subject: [PATCH 19/19] refactor: streamline API request detection in authentication middleware --- create-a-container/middlewares/index.js | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/create-a-container/middlewares/index.js b/create-a-container/middlewares/index.js index 4e5f9a96..ee3d1bf6 100644 --- a/create-a-container/middlewares/index.js +++ b/create-a-container/middlewares/index.js @@ -1,19 +1,16 @@ -// 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/ +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; +} - if (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 || '/'; @@ -23,6 +20,8 @@ function requireAuth(req, res, next) { 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'); }