commit 43e27eb713d2e2d129a3c70cf4a316370c1a4463 Author: Andreas Claesson Date: Thu Sep 11 22:36:16 2025 +0200 0.1.0-alpha diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aba5c70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +yarn.lock +.env \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d865bd --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +# Bitstream (Docker Management Server) + +This project provides a Node.js backend server for managing Docker containers and images, with real-time stats via WebSocket. It is built using Express, Dockerode, and supports CORS for frontend integration. + +## Features + +- List all Docker containers (running and stopped) +- Start, stop, restart, and delete containers +- Create and run new containers with custom options +- View container stats (CPU, memory, network, block I/O) +- View container logs +- List available Docker images +- Pull new images from Docker Hub +- Get Docker system info +- Real-time container stats updates via WebSocket + +## API Endpoints + +### Containers +- `GET /api/containers` — List all containers +- `GET /api/containers/:id/stats` — Get stats for a container +- `POST /api/containers/:id/start` — Start a container +- `POST /api/containers/:id/stop` — Stop a container +- `POST /api/containers/:id/restart` — Restart a container +- `POST /api/containers/create` — Create and start a new container +- `DELETE /api/containers/:id` — Delete a container +- `GET /api/containers/:id/logs` — Get logs for a container + +### Images +- `GET /api/images` — List Docker images +- `POST /api/images/pull` — Pull a Docker image (body: `{ image: "" }`) + +### System +- `GET /api/system/info` — Get Docker system info + +### WebSocket +- Connect to the server via WebSocket (same port) to receive real-time container stats updates. + +## Getting Started + +### Prerequisites +- [Node.js](https://nodejs.org/) (v22 recommended) +- [Docker](https://www.docker.com/) running locally +- On Windows: Docker must be configured to expose the API on TCP (see below) + +### Installation + +1. Clone the repository: + ```sh + git clone https://git.netbyte.host/netbyte/bitstream.git + cd bitstream + ``` +2. Install dependencies: + ```sh + yarn + # or + npm install + ``` + +### Configuration + +- **Linux/Mac:** Uses Docker socket at `/var/run/docker.sock` by default. +- **Windows:** + - Edit `server.js` and set Docker connection to `{ host: '127.0.0.1', port: 2375 }`. + - Ensure Docker is configured to expose the API on TCP port 2375 (insecure). + +### Running the Server + +```sh +node server.js +``` + +The server will start on port `3001` by default (or set `PORT` environment variable). + +### API Authentication (Secret Key) + +Bitstream requires a secret API key for all requests. This prevents unauthorized access (like Pterodactyl's Wings). + +#### Generating a Key + +Run the following command to generate a new API secret: + +```sh +npm run keygen +``` + +Copy the printed key and store it in a `.env` file at the root of your project: + +``` +BITSTREAM_API_SECRET=your-generated-key-here +``` + +You can use `.env.example` as a template. + +**All API requests must include the header:** + +``` +x-api-key: your-generated-key-here +``` + +If the key is missing or incorrect, the server will return `401 Unauthorized`. + +## Example: Creating a Container + +POST `/api/containers/create` +```json +{ + "name": "my-nginx", + "image": "nginx:latest", + "ports": { "80/tcp": [{ "HostPort": "8080" }] }, + "environment": ["ENV_VAR=value"], + "volumes": ["/host/path:/container/path"] +} +``` + +## Real-Time Stats + +Connect to the WebSocket endpoint (same port) to receive periodic stats updates for all running containers. + +## Running as a systemd Service + +A sample systemd unit file is provided in `systemd/bitstream.service`. To use: + +1. Copy the file to `/etc/systemd/system/bitstream.service` (edit paths as needed). +2. Set `WorkingDirectory` and `EnvironmentFile` to your Bitstream install location. +3. Create a dedicated user (e.g., `bitstream`) for security. (optional) +4. Reload systemd and start the service: + ```sh + sudo systemctl daemon-reload + sudo systemctl enable --now bitstream + sudo systemctl status bitstream + ``` + +## Debug Mode + +You can run Bitstream in debug mode to log all API requests and see more detailed output: + +```sh +node server.js --debug +# or +yarn dev --debug +``` + +When debug mode is enabled, all API interactions (method, path, status, duration) are logged to the console. + +## License + +MIT diff --git a/docker/containerService.js b/docker/containerService.js new file mode 100644 index 0000000..557da2b --- /dev/null +++ b/docker/containerService.js @@ -0,0 +1,101 @@ +// containerService.js +const docker = require('./dockerClient'); +const calculateCPUPercent = require('../utils/calculateCPU'); +const chalk = require('chalk').default; + +async function listContainers() { + const containers = await docker.listContainers({ all: true }); + return Promise.all(containers.map(async (containerInfo) => { + const container = docker.getContainer(containerInfo.Id); + const inspect = await container.inspect(); + return { + id: containerInfo.Id, + name: containerInfo.Names[0].replace('/', ''), + image: containerInfo.Image, + status: containerInfo.State, + state: containerInfo.Status, + ports: containerInfo.Ports, + created: containerInfo.Created, + labels: containerInfo.Labels || {}, + inspect: { + config: inspect.Config, + hostConfig: inspect.HostConfig, + networkSettings: inspect.NetworkSettings + } + }; + })); +} + +async function getContainerStats(id) { + const container = docker.getContainer(id); + const stats = await container.stats({ stream: false }); + const cpuPercent = calculateCPUPercent(stats); + const memoryUsage = stats.memory_stats.usage || 0; + const memoryLimit = stats.memory_stats.limit || 0; + const memoryPercent = memoryLimit > 0 ? (memoryUsage / memoryLimit) * 100 : 0; + const networks = stats.networks || {}; + const networkIO = Object.keys(networks).reduce((acc, key) => ({ + rx_bytes: acc.rx_bytes + (networks[key].rx_bytes || 0), + tx_bytes: acc.tx_bytes + (networks[key].tx_bytes || 0) + }), { rx_bytes: 0, tx_bytes: 0 }); + return { + cpu_percent: cpuPercent, + memory: { usage: memoryUsage, limit: memoryLimit, percent: memoryPercent }, + network: networkIO, + block_io: stats.blkio_stats + }; +} + +async function startContainer(id) { + const container = docker.getContainer(id); + await container.start(); + console.log(chalk.yellow('Container started:'), chalk.cyan(id)); +} + +async function stopContainer(id) { + const container = docker.getContainer(id); + await container.stop(); + console.log(chalk.yellow('Container stopped:'), chalk.cyan(id)); +} + +async function restartContainer(id) { + const container = docker.getContainer(id); + await container.restart(); + console.log(chalk.yellow('Container restarted:'), chalk.cyan(id)); +} + +async function createContainer(options) { + const container = await docker.createContainer(options); + await container.start(); + console.log(chalk.green('Container created and started:'), chalk.cyan(container.id)); + return container.id; +} + +async function removeContainer(id) { + const container = docker.getContainer(id); + try { await container.stop(); } catch (e) {} + await container.remove(); + console.log(chalk.red('Container deleted:'), chalk.cyan(id)); +} + +async function getContainerLogs(id) { + const container = docker.getContainer(id); + const logs = await container.logs({ + stdout: true, + stderr: true, + tail: 100, + timestamps: true + }); + return logs.toString(); +} + +module.exports = { + listContainers, + getContainerStats, + startContainer, + stopContainer, + restartContainer, + createContainer, + removeContainer, + getContainerLogs +}; diff --git a/docker/dockerClient.js b/docker/dockerClient.js new file mode 100644 index 0000000..d253c79 --- /dev/null +++ b/docker/dockerClient.js @@ -0,0 +1,10 @@ +// dockerClient.js +const Docker = require('dockerode'); + +// For Linux/Mac: socketPath, for Windows: host/port +const docker = new Docker({ + socketPath: '/var/run/docker.sock' + // For Windows: { host: '127.0.0.1', port: 2375 } +}); + +module.exports = docker; diff --git a/docker/imageService.js b/docker/imageService.js new file mode 100644 index 0000000..5156e7a --- /dev/null +++ b/docker/imageService.js @@ -0,0 +1,27 @@ +// imageService.js + +const docker = require('./dockerClient'); +const chalk = require('chalk').default; + +async function listImages() { + return docker.listImages(); +} + +async function pullImage(image) { + const stream = await docker.pull(image); + return new Promise((resolve, reject) => { + docker.modem.followProgress(stream, (err, result) => { + if (err) { + console.error(chalk.red('Error pulling image:'), chalk.cyan(image), err.message); + return reject(err); + } + console.log(chalk.green('Image pulled successfully:'), chalk.cyan(image)); + resolve(result); + }); + }); +} + +module.exports = { + listImages, + pullImage +}; diff --git a/middleware/auth.js b/middleware/auth.js new file mode 100644 index 0000000..076b072 --- /dev/null +++ b/middleware/auth.js @@ -0,0 +1,8 @@ +// middleware/auth.js +module.exports = function (req, res, next) { + const apiKey = req.headers['x-api-key']; + if (apiKey && apiKey === process.env.BITSTREAM_API_SECRET) { + return next(); + } + return res.status(401).json({ error: 'Unauthorized' }); +}; diff --git a/middleware/logApi.js b/middleware/logApi.js new file mode 100644 index 0000000..f34498f --- /dev/null +++ b/middleware/logApi.js @@ -0,0 +1,20 @@ +// middleware/logApi.js +const chalk = require('chalk').default; + +module.exports = function (req, res, next) { + if (global.DEBUG_MODE) { + const start = Date.now(); + res.on('finish', () => { + const duration = Date.now() - start; + console.log( + chalk.magenta('[API]'), + chalk.cyan(req.method), + chalk.white(req.originalUrl), + chalk.yellow(res.statusCode), + chalk.gray(`${duration}ms`), + req.headers['x-api-key'] ? chalk.green('[x-api-key]') : '' + ); + }); + } + next(); +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..3f27459 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "bitstream", + "version": "0.1.0-alpha", + "description": "Docker API built for Navigator", + "main": "server.js", + "scripts": { + "debug": "node server.js --debug", + "dev": "node server.js", + "keygen": "node scripts/keygen.js" + }, + "repository": "https://git.netbyte.host/netbyte/bitstream", + "author": "Andreas Claesson ", + "license": "Apache2.0", + "private": true, + "dependencies": { + "chalk": "^5.6.2", + "cors": "^2.8.5", + "dockerode": "^4.0.7", + "express": "^5.1.0", + "ws": "^8.18.3" + }, + "devDependencies": { + "dotenv": "^17.2.2" + } +} diff --git a/routes/containers.js b/routes/containers.js new file mode 100644 index 0000000..3bf34a0 --- /dev/null +++ b/routes/containers.js @@ -0,0 +1,101 @@ +// containers.js +const express = require('express'); +const router = express.Router(); +const containerService = require('../docker/containerService'); + +// List all containers +router.get('/', async (req, res) => { + try { + const containers = await containerService.listContainers(); + res.json(containers); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch containers' }); + } +}); + +// Get container stats +router.get('/:id/stats', async (req, res) => { + try { + const stats = await containerService.getContainerStats(req.params.id); + res.json(stats); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch container stats' }); + } +}); + +// Start container +router.post('/:id/start', async (req, res) => { + try { + await containerService.startContainer(req.params.id); + res.json({ message: 'Container started successfully' }); + } catch (error) { + res.status(500).json({ error: 'Failed to start container' }); + } +}); + +// Stop container +router.post('/:id/stop', async (req, res) => { + try { + await containerService.stopContainer(req.params.id); + res.json({ message: 'Container stopped successfully' }); + } catch (error) { + res.status(500).json({ error: 'Failed to stop container' }); + } +}); + +// Restart container +router.post('/:id/restart', async (req, res) => { + try { + await containerService.restartContainer(req.params.id); + res.json({ message: 'Container restarted successfully' }); + } catch (error) { + res.status(500).json({ error: 'Failed to restart container' }); + } +}); + +// Create and run new container +router.post('/create', async (req, res) => { + try { + const { name, image, ports = {}, environment = [], volumes = [], restart = 'unless-stopped' } = req.body; + const createOptions = { + Image: image, + name: name, + Env: environment, + HostConfig: { + PortBindings: ports, + Binds: volumes, + RestartPolicy: { Name: restart } + }, + ExposedPorts: Object.keys(ports).reduce((acc, port) => { + acc[port] = {}; + return acc; + }, {}) + }; + const id = await containerService.createContainer(createOptions); + res.json({ id, message: 'Container created and started successfully' }); + } catch (error) { + res.status(500).json({ error: 'Failed to create container' }); + } +}); + +// Delete container +router.delete('/:id', async (req, res) => { + try { + await containerService.removeContainer(req.params.id); + res.json({ message: 'Container deleted successfully' }); + } catch (error) { + res.status(500).json({ error: 'Failed to delete container' }); + } +}); + +// Get container logs +router.get('/:id/logs', async (req, res) => { + try { + const logs = await containerService.getContainerLogs(req.params.id); + res.json({ logs }); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch logs' }); + } +}); + +module.exports = router; diff --git a/routes/images.js b/routes/images.js new file mode 100644 index 0000000..d352983 --- /dev/null +++ b/routes/images.js @@ -0,0 +1,27 @@ +// images.js +const express = require('express'); +const router = express.Router(); +const imageService = require('../docker/imageService'); + +// List images +router.get('/', async (req, res) => { + try { + const images = await imageService.listImages(); + res.json(images); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch images' }); + } +}); + +// Pull image +router.post('/pull', async (req, res) => { + try { + const { image } = req.body; + await imageService.pullImage(image); + res.json({ message: 'Image pull started' }); + } catch (error) { + res.status(500).json({ error: 'Failed to pull image' }); + } +}); + +module.exports = router; diff --git a/routes/system.js b/routes/system.js new file mode 100644 index 0000000..1106cfa --- /dev/null +++ b/routes/system.js @@ -0,0 +1,16 @@ +// system.js +const express = require('express'); +const router = express.Router(); +const docker = require('../docker/dockerClient'); + +// Get Docker system info +router.get('/info', async (req, res) => { + try { + const info = await docker.info(); + res.json(info); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch system info' }); + } +}); + +module.exports = router; diff --git a/scripts/keygen.js b/scripts/keygen.js new file mode 100644 index 0000000..142332d --- /dev/null +++ b/scripts/keygen.js @@ -0,0 +1,44 @@ +// scripts/keygen.js + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const envPath = path.join(__dirname, '../.env'); +const force = process.argv.includes('--force'); + +let envContent = ''; +let exists = false; +let alreadySet = false; + +if (fs.existsSync(envPath)) { + envContent = fs.readFileSync(envPath, 'utf8'); + exists = true; + alreadySet = /^BITSTREAM_API_SECRET=.+/m.test(envContent); +} + +if (alreadySet && !force) { + console.warn('BITSTREAM_API_SECRET already exists in .env!'); + console.warn('If you want to overwrite it, run: npm run keygen --force / yarn keygen --force'); + process.exit(1); +} + +const key = crypto.randomBytes(32).toString('hex'); + +let newEnvContent = ''; +if (exists) { + // Remove any existing BITSTREAM_API_SECRET line + newEnvContent = envContent.replace(/^BITSTREAM_API_SECRET=.*$/m, '').trim(); + if (newEnvContent.length > 0 && !newEnvContent.endsWith('\n')) newEnvContent += '\n'; + newEnvContent += `BITSTREAM_API_SECRET=${key}\n`; +} else { + newEnvContent = `# Bitstream API Secret\nBITSTREAM_API_SECRET=${key}\n`; +} + +fs.writeFileSync(envPath, newEnvContent, 'utf8'); +console.log('Your new Bitstream API secret key:'); +console.log(key); +console.log('\nThe key has been written to .env as BITSTREAM_API_SECRET.'); +if (alreadySet && force) { + console.log('Previous BITSTREAM_API_SECRET was overwritten.'); +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..f1b3ca1 --- /dev/null +++ b/server.js @@ -0,0 +1,47 @@ +// server.js - Main backend server +require('dotenv').config({ quiet: true }); +const express = require('express'); +const chalk = require('chalk').default; +const auth = require('./middleware/auth'); +const logApi = require('./middleware/logApi'); +const cors = require('cors'); +const http = require('http'); +const containersRouter = require('./routes/containers'); +const imagesRouter = require('./routes/images'); +const systemRouter = require('./routes/system'); +const setupWebSocket = require('./ws/statsSocket'); + +// Debug mode flag +const DEBUG_MODE = process.argv.includes('--debug'); +global.DEBUG_MODE = DEBUG_MODE; + +const app = express(); + +const server = http.createServer(app); + +app.use(cors()); +app.use(express.json()); + + +// Setup WebSocket for real-time stats +setupWebSocket(server); + + +// Log all API requests if debug mode is enabled +app.use('/api', logApi); +// Protect all /api routes with API key auth +app.use('/api', auth); +app.use('/api/containers', containersRouter); +app.use('/api/images', imagesRouter); +app.use('/api/system', systemRouter); + +const PORT = process.env.PORT || 6560; + +server.listen(PORT, () => { + console.log(chalk.green.bold('Bitstream server running on port'), chalk.cyan.bold(PORT)); + if (DEBUG_MODE) { + console.log(chalk.yellow.bold('[DEBUG MODE ENABLED] All API requests will be logged.')); + } +}); + +module.exports = app; diff --git a/systemd/bitstream.service b/systemd/bitstream.service new file mode 100644 index 0000000..ea75c33 --- /dev/null +++ b/systemd/bitstream.service @@ -0,0 +1,16 @@ +[Unit] +Description=Bitstream Docker Management API +After=network.target docker.service +Requires=docker.service + +[Service] +Type=simple +WorkingDirectory=/path/to/bitstream +ExecStart=/usr/bin/node server.js +Restart=on-failure +EnvironmentFile=/path/to/bitstream/.env +User=bitstream +Group=bitstream + +[Install] +WantedBy=multi-user.target diff --git a/utils/calculateCPU.js b/utils/calculateCPU.js new file mode 100644 index 0000000..3dec807 --- /dev/null +++ b/utils/calculateCPU.js @@ -0,0 +1,13 @@ +// calculateCPU.js +function calculateCPUPercent(stats) { + const cpuStats = stats.cpu_stats; + const preCpuStats = stats.precpu_stats; + const cpuDelta = cpuStats.cpu_usage.total_usage - preCpuStats.cpu_usage.total_usage; + const systemDelta = cpuStats.system_cpu_usage - preCpuStats.system_cpu_usage; + if (systemDelta > 0 && cpuDelta > 0) { + const cpuPercent = (cpuDelta / systemDelta) * cpuStats.online_cpus * 100; + return Math.round(cpuPercent * 100) / 100; + } + return 0; +} +module.exports = calculateCPUPercent; diff --git a/ws/statsSocket.js b/ws/statsSocket.js new file mode 100644 index 0000000..715114b --- /dev/null +++ b/ws/statsSocket.js @@ -0,0 +1,56 @@ +// statsSocket.js +const WebSocket = require('ws'); +const docker = require('../docker/dockerClient'); +const calculateCPUPercent = require('../utils/calculateCPU'); + +let clients = new Set(); + +function setupWebSocket(server) { + const wss = new WebSocket.Server({ server }); + wss.on('connection', (ws) => { + clients.add(ws); + ws.on('close', () => clients.delete(ws)); + }); + startStatsMonitoring(); +} + +function broadcastStats(data) { + clients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + client.send(JSON.stringify(data)); + } + }); +} + +function startStatsMonitoring() { + setInterval(async () => { + try { + const containers = await docker.listContainers(); + const statsPromises = containers.map(async (containerInfo) => { + try { + const container = docker.getContainer(containerInfo.Id); + const stats = await container.stats({ stream: false }); + return { + id: containerInfo.Id, + name: containerInfo.Names[0].replace('/', ''), + stats: { + cpu_percent: calculateCPUPercent(stats), + memory: { + usage: stats.memory_stats.usage || 0, + limit: stats.memory_stats.limit || 0, + percent: stats.memory_stats.limit > 0 ? (stats.memory_stats.usage / stats.memory_stats.limit) * 100 : 0 + } + } + }; + } catch (error) { + return null; + } + }); + const allStats = await Promise.all(statsPromises); + const validStats = allStats.filter(stat => stat !== null); + broadcastStats({ type: 'stats_update', data: validStats }); + } catch (error) {} + }, 5000); +} + +module.exports = setupWebSocket;