Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
860c0b2
fix #88: add uniqueness constraints for services
runleveldev Nov 4, 2025
c81803f
fix #99: disable sequelize logging in production
runleveldev Nov 4, 2025
88ab113
add node to containers model
runleveldev Nov 4, 2025
c3bf3e2
fix #85: add node determination to server.js
runleveldev Nov 4, 2025
607784b
Add Nodes model
runleveldev Nov 4, 2025
be01358
fix #81: add delete /containers/:id route
runleveldev Nov 4, 2025
9d93641
fix: improve error handling in container deletion route
runleveldev Nov 4, 2025
ba44e69
feat: add flash messages for success and error notifications in conta…
runleveldev Nov 5, 2025
1200eec
feat: implement authentication middleware and add nodes management ro…
runleveldev Nov 5, 2025
20734cc
feat: add tokenId and secret fields to Nodes model and update related…
runleveldev Nov 5, 2025
c7ef020
feat: add admin access control and update views for admin users
runleveldev Nov 5, 2025
04e2e37
fix #105: add sessions table migration and configure Sequelize sessio…
runleveldev Nov 5, 2025
6b19422
Potential fix for code scanning alert no. 31: Server-side request for…
runleveldev Nov 5, 2025
d7c746e
refactor: simplify admin access check in requireAdmin middleware
runleveldev Nov 5, 2025
3f267fa
fix: update tlsVerify handling in node creation and update routes
runleveldev Nov 5, 2025
58f5e48
fix: update container retrieval to use full username from session
runleveldev Nov 5, 2025
9772336
fix: enhance accessibility by adding aria-labels to buttons in contai…
runleveldev Nov 5, 2025
8dba8d0
fix: remove error handling for unsuccessful container deletion response
runleveldev Nov 5, 2025
843c2bd
refactor: streamline API request detection in authentication middleware
runleveldev Nov 5, 2025
f123f63
Merge branch 'main' into 88-uniqueness-testing-for-http-services-exte…
runleveldev Nov 5, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions create-a-container/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
runleveldev marked this conversation as resolved.

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
Expand Down Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion create-a-container/config/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,8 @@ const config = {
module.exports = {
development: config,
test: config,
production: config,
production: {
...config,
logging: false
},
};
28 changes: 28 additions & 0 deletions create-a-container/middlewares/index.js
Original file line number Diff line number Diff line change
@@ -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 };
Original file line number Diff line number Diff line change
@@ -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');
}
};
Original file line number Diff line number Diff line change
@@ -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');
}
};
46 changes: 46 additions & 0 deletions create-a-container/migrations/20251104193601-create-node.js
Original file line number Diff line number Diff line change
@@ -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');
}
};
Original file line number Diff line number Diff line change
@@ -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');
}
};
Original file line number Diff line number Diff line change
@@ -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');
}
};
Original file line number Diff line number Diff line change
@@ -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');
}
};
Loading