0.1.0-alpha

This commit is contained in:
Andreas Claesson 2025-09-11 22:36:16 +02:00
commit 43e27eb713
16 changed files with 662 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
yarn.lock
.env

148
README.md Normal file
View File

@ -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: "<image-name>" }`)
### 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

101
docker/containerService.js Normal file
View File

@ -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
};

10
docker/dockerClient.js Normal file
View File

@ -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;

27
docker/imageService.js Normal file
View File

@ -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
};

8
middleware/auth.js Normal file
View File

@ -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' });
};

20
middleware/logApi.js Normal file
View File

@ -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();
};

25
package.json Normal file
View File

@ -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 <andreas@netbyte.host>",
"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"
}
}

101
routes/containers.js Normal file
View File

@ -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;

27
routes/images.js Normal file
View File

@ -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;

16
routes/system.js Normal file
View File

@ -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;

44
scripts/keygen.js Normal file
View File

@ -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.');
}

47
server.js Normal file
View File

@ -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;

16
systemd/bitstream.service Normal file
View File

@ -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

13
utils/calculateCPU.js Normal file
View File

@ -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;

56
ws/statsSocket.js Normal file
View File

@ -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;