This commit is contained in:
Raven Scott
2026-07-10 20:04:47 -04:00
parent 448087a583
commit d6ce72af66
37 changed files with 3071 additions and 2788 deletions
+3
View File
@@ -1 +1,4 @@
SERVER_KEY=0708bb56dd447a1b6951cc5b92522ab42994bd07d616375f4f0776df22b0b629
SERVER_PUBLIC_KEY=cbb571ff6cf2a0cf24725aa85279777a3ef2b2b333a2097d38f03ea5268ad336
SERVER_SEED=0708bb56dd447a1b6951cc5b92522ab42994bd07d616375f4f0776df22b0b629
+3
View File
@@ -1,3 +1,6 @@
node_modules
package-lock.json
.env
server/.env
*.log
.DS_Store
+120 -366
View File
@@ -1,406 +1,160 @@
# peardock
## Overview
Decentralized Docker management on the modern Holepunch stack.
peardock is a decentralized, peer-to-peer application designed to streamline Docker container management using Hyperswarm. The application connects multiple peers over a distributed hash table (DHT) network and provides full control over Docker containers, including starting, stopping, removing, duplicating, viewing logs, deploying from templates, and monitoring real-time metrics. With its robust server key-based architecture, peardock ensures secure and persistent peer-to-peer communication.
| Layer | Technology |
|-------|------------|
| Transport | **HyperDHT** (Noise-encrypted P2P) |
| RPC | **protomux-rpc** + **compact-encoding** JSON |
| Client | **Pear** desktop app |
| Docker | **dockerode** |
The **server key** forms the foundation of the connection. It is automatically generated, saved, and reused unless explicitly refreshed, making it easy to maintain consistent access while allowing for manual key regeneration when needed.
No central control plane. The server announces a keypair on the DHT; clients connect with its public key.
In addition to a development environment, the client app can be run in **production mode** directly via Pear with the following command:
---
## Quick start
### 1. Install
```bash
pear run pear://7to8bzrk53ab5ufwauqcw57s1kxmuykc9b8cdnjicaqcgoefa4wo
npm install
# Node.js ≥ 20 required
```
### 2. Run the server (machine with Docker)
```bash
npm run server
```
You will see:
```
peardock server ready
Public key (paste into the client):
<64 hex characters>
```
Keep this process running. Identity is stored in `.env`:
| Variable | Meaning |
|----------|---------|
| `SERVER_SEED` | Secret 32-byte seed (never share) |
| `SERVER_PUBLIC_KEY` | Derived public key (share with clients) |
| `SERVER_KEY` | Legacy alias for the seed (still accepted) |
### 3. Run the desktop client
```bash
npm run dev
# or: pear run -d .
```
Paste the **public key** into the sidebar connection field.
### Production Pear app
```bash
pear stage .
pear release .
pear run pear://<your-app-key>
```
---
## Key Features
## Architecture
### Server-Side
```
shared/ Protocol constants + encodings (both sides)
server/
server.js Entry: HyperDHT listen
core/ Keys, peer registry
rpc/ PeerSession (protomux-rpc), handler registration
handlers/ Domain methods (containers, images, volumes, …)
services/ Docker client, stats, event stream
utils/ Validation, rate limit, logging, compose
client/
connection.js Single HyperDHT + protomux-rpc link
manager.js Multi-server connections + persistence
api.js Typed RPC helpers
app.js + libs/ Pear UI
```
- **Persistent Server Key**:
- Generates a `SERVER_KEY` for each instance.
- The key is saved to `.env` for consistent re-use.
- Supports manual key regeneration by deleting `.env`.
### RPC model
- **Real-Time Docker Management**:
- List all containers across peers with statuses.
- Start, stop, restart, and remove containers remotely.
**Client → server** methods (examples): `listContainers`, `startContainer`, `deployContainer`, `startTerminal`, `logs`, `listImages`, …
- **Dynamic Terminal Sessions**:
- Open and manage multiple terminals for running containers.
- Real-time shell sessions streamed to connected peers.
**Server → client** pushes: `push:containers`, `push:allStats`, `push:logs`, `push:terminalOutput`, `push:dockerOutput`, …
- **Docker CLI Terminal**:
- Access a Docker CLI terminal to run Docker commands on the remote peer.
- **Container Duplication**:
- Clone containers with custom configurations for CPUs, memory, network mode, and hostname.
- **Template Deployment**:
- Deploy containers using templates fetched from a remote repository.
- Customize deployment parameters such as ports, volumes, and environment variables.
- **Container Logs**:
- View real-time and historical logs of containers.
- **Live Statistics Streaming**:
- Broadcast CPU, memory, and network stats in real-time to connected peers.
### Client-Side
- **Peer-to-Peer Networking**:
- Connects to servers using unique server keys via Hyperswarm.
- Fully decentralized; no central server is required.
- **Interactive User Interface**:
- Modern, responsive UI built with **Bootstrap**.
- Integrated terminal viewer powered by **Xterm.js**.
- Real-time container stats displayed for each container.
- View container logs directly from the UI.
- Deploy containers using templates with a user-friendly wizard.
- **Production Deployment**:
- Ready-to-use client app available via Pear runtime:
```bash
pear run pear://7to8bzrk53ab5ufwauqcw57s1kxmuykcgoefa4wo
```
Defined in `shared/protocol.js`.
---
## How It Works
## Deployment
### Server Key Architecture
1. **Host** — Linux/macOS with Docker socket access for the server user.
2. **Process**`systemd` / `pm2` / Docker supervising `node server/server.js`.
3. **Network** — HyperDHT holepunches; allow UDP when possible. Bootstrap peers are built into `hyperdht`.
4. **Secrets** — Back up `SERVER_SEED`. Rotating seed changes the public key; clients must reconnect.
5. **Pear** — Stage/release the desktop app separately from the control-plane server.
6. **Security** — Connections are E2E encrypted. Rate limits apply per peer. Docker CLI is allow-listed to read-only style commands.
The server is initialized with a `SERVER_KEY` that uniquely identifies the network. This key is essential for peers to connect and interact with the server.
Example systemd unit:
- **Key Generation**:
- On the first run, the server checks for an existing `SERVER_KEY` in the `.env` file. If absent, a new key is generated:
```javascript
function generateNewKey() {
const newKey = crypto.randomBytes(32);
fs.appendFileSync('.env', `SERVER_KEY=${newKey.toString('hex')}\n`, { flag: 'a' });
return newKey;
}
```
- The key is saved to `.env` for persistence.
```ini
[Unit]
Description=peardock HyperDHT server
After=docker.service
Requires=docker.service
- **Key Usage**:
- The server uses the key to generate a topic buffer for Hyperswarm:
```javascript
const topic = Buffer.from(keyHex, 'hex');
swarm.join(topic, { server: true, client: false });
```
[Service]
WorkingDirectory=/opt/peardock
ExecStart=/usr/bin/node server/server.js
Restart=on-failure
Environment=NODE_ENV=production
- **Key Refresh**:
- To regenerate the key, delete the `.env` file and restart the server.
### Peer Connections
Peers connect to the server using the unique topic derived from the `SERVER_KEY`. The Hyperswarm network ensures secure, low-latency connections.
- **Connecting**:
- Each client app connects to the server by joining the topic buffer:
```javascript
const topicBuffer = b4a.from(topicHex, 'hex');
swarm.join(topicBuffer, { client: true, server: true });
```
- **Communication**:
- Commands (e.g., `listContainers`, `startContainer`) are sent as JSON over the connection.
- Responses and real-time updates are broadcast back to peers.
### Docker Integration
The server interacts with Docker using **Dockerode**:
- **List Containers**:
```javascript
const containers = await docker.listContainers({ all: true });
```
- **Start a Container**:
```javascript
await docker.getContainer(containerId).start();
```
- **Stream Statistics**:
```javascript
container.stats({ stream: true }, (err, stream) => {
stream.on('data', (data) => {
const stats = JSON.parse(data.toString());
broadcastToPeers({ type: 'stats', data: stats });
});
});
```
- **Docker CLI Commands**:
- Execute Docker commands received from the client within controlled parameters to ensure security.
[Install]
WantedBy=multi-user.target
```
---
## Installation
## Scripts
### Prerequisites
1. **Docker**:
- Install Docker and ensure it is running.
- For Linux, add your user to the Docker group:
```bash
sudo usermod -aG docker $USER
```
Log out and back in for changes to take effect.
2. **Node.js**:
- Install Node.js v16 or higher:
```bash
sudo apt install nodejs npm
```
3. **Pear**:
- Install the Pear runtime for running the client and server:
```bash
npm install -g pear
```
| Command | Description |
|---------|-------------|
| `npm run server` | Start HyperDHT Docker control plane |
| `npm run dev` | Pear desktop app (dev) |
| `npm test` | Unit + integration tests |
---
### Server Setup
## Dependencies (current)
1. **Clone the Repository**:
```bash
git clone https://git.ssh.surf/snxraven/peardock.git
cd peardock
```
2. **Change to Server Directory**:
```bash
cd server
```
3. **Install Dependencies**:
```bash
npm install hyperswarm dockerode hypercore-crypto stream dotenv
```
4. **Run the Server**:
```bash
node server.js
```
- `hyperdht` ^6.33
- `protomux-rpc` ^1.10
- `protomux` ^3.11
- `compact-encoding` ^3.3
- `b4a` ^1.8
- `hypercore-crypto` ^3.7
- `dockerode` ^5
- `dotenv` ^17
- `graceful-goodbye` ^1.3
---
### Client Setup
## Breaking changes from v1
1. **For Development**, run:
```bash
pear run --dev .
```
2. **For Production**, use the pre-deployed Pear app:
```bash
pear run pear://7to8bzrk53ab5ufwauqcw57s1kxmuykc9b8cdnjicaqcgoefa4wo
```
| v1 (legacy) | v2 (current) |
|-------------|--------------|
| Hyperswarm topic = `SERVER_KEY` | HyperDHT listen on keypair from seed |
| Share topic hex with clients | Share **public key** with clients |
| Raw JSON on duplex streams | protomux-rpc methods + push channels |
| Monolithic `server.js` switch | Modular handlers under `server/handlers/` |
---
## Usage
## License
### Connecting to a Server
1. Launch the client app.
2. Enter the server's `SERVER_KEY` in the connection form to join its topic.
### Managing Containers
- **Listing Containers**:
- View all containers (running and stopped) with their statuses.
- **Starting/Stopping/Restarting Containers**:
- Use the action buttons (play, stop, restart icons) in the container list.
- **Removing Containers**:
- Click the trash icon to delete a container.
- **Viewing Container Logs**:
- Click the logs icon to view real-time and historical logs of a container.
- **Duplicating Containers**:
- Click the clone icon and customize the duplication form.
### Terminal Access
- **Container Terminal**:
- Open terminals for running containers by clicking the terminal icon.
- Switch between sessions using the tray at the bottom.
- **Docker CLI Terminal**:
- Access a Docker CLI terminal to execute Docker commands on the remote peer.
- Click the Docker terminal icon in the connection list.
### Template Deployment
- **Deploying from Templates**:
- Open the template deployment modal by clicking the deploy template icon.
- Search and select templates from the list.
- Customize deployment parameters such as container name, image, ports, volumes, and environment variables.
- Deploy the container with the specified settings.
---
## Screenshots
### Welcome Screen
![Welcome Screen](https://git.ssh.surf/snxraven/peardock/raw/branch/main/screenshots/screenshot-0.png)
*The initial welcome screen guiding users to add a connection.*
---
### Container List
![Container List](https://git.ssh.surf/snxraven/peardock/raw/branch/main/screenshots/screenshot-1.png)
*Displaying all Docker containers with real-time stats and action buttons.*
---
### Template Deployments
![Template Deployments](https://git.ssh.surf/snxraven/peardock/raw/branch/main/screenshots/screenshot-2.png)
*Browsing and selecting templates for deployment from a remote repository.*
---
### Final Deploy Modal
![Final Deploy Modal](https://git.ssh.surf/snxraven/peardock/raw/branch/main/screenshots/screenshot-3.png)
*Customizing deployment parameters before launching a new container.*
---
### Duplicate Container Form
![Duplicate Container Form](https://git.ssh.surf/snxraven/peardock/raw/branch/main/screenshots/screenshot-4.png)
*Duplicating an existing container with options to modify configurations.*
---
### Container Logs
![Container Logs](https://git.ssh.surf/snxraven/peardock/raw/branch/main/screenshots/screenshot-5.png)
*Viewing real-time logs of a container directly from the UI.*
---
## Customization
### UI Customization
- Modify the layout and styling in `index.html` and the embedded CSS.
### Terminal Behavior
- Adjust terminal settings in `libs/terminal.js`:
```javascript
const xterm = new Terminal({
cursorBlink: true,
theme: { background: '#1a1a1a', foreground: '#ffffff' },
});
```
### Docker Commands
- Add new commands in `server/server.js` under the `switch` statement for additional Docker functionalities:
```javascript
switch (parsedData.command) {
case 'newCommand':
// Implement your command logic here
break;
// Existing cases...
}
```
---
## Security
- The `SERVER_KEY` is sensitive and should be stored securely.
- Refresh the key periodically to enhance security, especially in untrusted environments.
- peardock uses encrypted peer-to-peer connections, but it's recommended to run it within secure networks.
- Limit access to the server by controlling who has the `SERVER_KEY`.
---
## Troubleshooting
### Common Issues
1. **Unable to Connect**:
- Verify the `SERVER_KEY` matches on both server and client.
- Ensure the server is running and accessible.
- Check network configurations and firewall settings.
2. **Docker Errors**:
- Ensure Docker is running and properly configured.
- Check permissions to manage Docker.
- Verify that the user running the server has access to the Docker daemon.
3. **Terminal Issues**:
- Verify the container has a valid shell (e.g., `/bin/bash`).
- Ensure that the container is running before opening a terminal.
- Check for network latency that might affect terminal responsiveness.
4. **Template Deployment Failures**:
- Ensure the Docker image specified in the template is valid and accessible.
- Check network connectivity if pulling images from remote repositories.
- Validate all required parameters in the deployment form.
---
## Contributing
Contributions are welcome! Fork the repository, make your changes, and submit a pull request.
1. **Fork the Repository**:
- Click the "Fork" button at the top of the repository page.
2. **Clone Your Fork**:
```bash
git clone https://github.com/your-username/peardock.git
```
3. **Create a Branch for Your Feature**:
```bash
git checkout -b feature/your-feature-name
```
4. **Make Changes and Commit**:
```bash
git add .
git commit -m "Add your feature"
```
5. **Push to Your Fork**:
```bash
git push origin feature/your-feature-name
```
6. **Submit a Pull Request**:
- Go to your fork on GitHub and click the "New pull request" button.
---
## Acknowledgments
- **Portainer**: For inspiring the creation of a powerful Docker management tool.
- **Hyperswarm**: Providing the peer-to-peer networking backbone.
- **Dockerode**: Facilitating Docker API interactions in Node.js.
---
## Contact
For questions, issues, or suggestions, please open an issue on the [GitHub repository](https://git.ssh.surf/snxraven/peardock).
Apache-2.0
+166 -212
View File
@@ -1,5 +1,4 @@
import Hyperswarm from 'hyperswarm';
import b4a from 'b4a';
import { manager, Methods } from './client/manager.js';
import { startTerminal, appendTerminalOutput } from './libs/terminal.js';
import { startDockerTerminal, cleanUpDockerTerminal } from './libs/dockerTerminal.js';
import { fetchTemplates, displayTemplateList, openDeployModal, collectDuplicateFormData, populateDuplicateForm } from './libs/templateDeploy.js';
@@ -7,6 +6,24 @@ import { showContainerSkeleton, createProgressBar, updateProgressBar, removeProg
import { closeAllModals, showStatusIndicator, hideStatusIndicator, updateStatusIndicator, showAlert } from './libs/uiUtils.js';
import notificationManager from './libs/notifications.js';
// Global RPC push / response routing
manager.on('message', (msg, conn) => {
handleRpcMessage(msg, conn);
});
manager.on('disconnect', (conn) => {
const topicId = conn?.id;
if (topicId && connections[topicId]) {
updateConnectionStatus(topicId, false);
if (window.activePeer === conn || window.activePeer?.id === conn.id) {
window.activePeer = null;
if (dashboard) dashboard.classList.add('hidden');
if (containerList) containerList.innerHTML = '';
stopStatsInterval();
}
}
});
// DOM Elements - Cache frequently accessed elements (will be initialized in DOMContentLoaded)
let containerList = null;
let connectionList = null;
@@ -30,10 +47,23 @@ let notificationTrayInitialized = false;
// Global variables
const connections = {};
window.openTerminals = {};
let activePeer = null;
window.activePeer = null; // Expose to other modules
let statsInterval = null;
// activePeer is always the manager's active PearDockConnection (RPC), never a raw stream
Object.defineProperty(window, 'activePeer', {
configurable: true,
enumerable: true,
get() {
return manager.active;
},
set(value) {
if (value?.id) manager.setActive(value.id);
else if (value == null && manager.active) {
// allow clear without dropping manager map entry
}
},
});
// Centralized volumes cache/store
const volumesStore = {
volumes: [],
@@ -269,11 +299,10 @@ function deleteCookie(name) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
}
// Load connections from cookies or localStorage
// Load saved server public keys from cookies or localStorage
function loadConnections() {
let savedConnections = null;
// Check if we should use localStorage
try {
const useLocalStorage = localStorage.getItem(USE_LOCALSTORAGE_KEY);
if (useLocalStorage === 'true') {
@@ -286,21 +315,24 @@ function loadConnections() {
savedConnections = getCookie('connections');
}
const connections = savedConnections ? JSON.parse(savedConnections) : {};
const parsed = savedConnections ? JSON.parse(savedConnections) : {};
const connections = {};
// Recreate the topic Buffer from the hex string
for (const topicId in connections) {
const { topicHex, alias } = connections[topicId];
connections[topicId] = {
topic: b4a.from(topicHex, 'hex'),
topicHex,
alias: alias || null,
peer: null, // Initialize additional properties
swarm: null,
for (const topicId in parsed) {
const entry = parsed[topicId];
// publicKeyHex is modern; topicHex is legacy hyperswarm topic storage
const publicKeyHex = (entry.publicKeyHex || entry.topicHex || '').toLowerCase();
if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) continue;
const id = publicKeyHex.substring(0, 12);
connections[id] = {
publicKeyHex,
topicHex: publicKeyHex, // keep field name for older UI bindings
alias: entry.alias || null,
peer: null,
connectedAt: null,
lastHealthCheck: null,
latency: null,
healthStatus: 'unknown'
healthStatus: 'unknown',
};
}
@@ -313,11 +345,12 @@ function saveConnections() {
const serializableConnections = {};
for (const topicId in connections) {
const { topic, topicHex, alias } = connections[topicId]; // Only serialize simple properties
const { publicKeyHex, topicHex, alias } = connections[topicId];
const key = publicKeyHex || topicHex;
serializableConnections[topicId] = {
topicHex,
topic: b4a.toString(topic, 'hex'), // Convert Buffer to hex string
alias: alias || null, // Save alias
publicKeyHex: key,
topicHex: key,
alias: alias || null,
};
}
@@ -1253,7 +1286,7 @@ function renderVolumes(volumes) {
loadVolumes();
}
} else if (response.error) {
// Error is already handled by centralized handler in handlePeerData
// Error is already handled by centralized RPC handler
// But we can still show alert for immediate feedback
const errorMsg = handleErrorResponse(response);
if (errorMsg) {
@@ -1351,7 +1384,7 @@ function pullImage() {
}
} else if (response.error) {
hideStatusIndicator();
// Error is already handled by centralized handler in handlePeerData
// Error is already handled by centralized handler in handleRpcMessage
// But we can still show alert for immediate feedback
const errorMsg = handleErrorResponse(response);
if (errorMsg) {
@@ -1401,7 +1434,7 @@ function createNetwork() {
document.getElementById('create-network-form')?.reset();
} else if (response.error) {
hideStatusIndicator();
// Error is already handled by centralized handler in handlePeerData
// Error is already handled by centralized handler in handleRpcMessage
// But we can still show alert for immediate feedback
const errorMsg = handleErrorResponse(response);
if (errorMsg) {
@@ -1491,7 +1524,7 @@ function createVolume() {
window.handlePeerResponse = originalHandler;
} else if (response.error) {
hideStatusIndicator();
// Error is already handled by centralized handler in handlePeerData
// Error is already handled by centralized handler in handleRpcMessage
// But we can still show alert for immediate feedback
const errorMsg = handleErrorResponse(response);
if (errorMsg) {
@@ -2963,17 +2996,16 @@ document.addEventListener('DOMContentLoaded', () => {
// Handle terminal input
const onDataDisposable = xterm.onData((data) => {
if (!window.activePeer) {
if (!manager.active?.connected) {
console.error('[ERROR] No active peer connection.');
return;
}
const encoded = btoa(unescape(encodeURIComponent(data)));
window.activePeer.write(JSON.stringify({
type: 'terminalInput',
manager.event(Methods.terminalInput, {
containerId,
data: encoded,
encoding: 'base64',
}));
});
});
// Handle resize
@@ -2991,11 +3023,8 @@ document.addEventListener('DOMContentLoaded', () => {
};
// Request terminal start from server
if (window.activePeer) {
window.activePeer.write(JSON.stringify({
command: 'startTerminal',
args: { containerId }
}));
if (manager.active?.connected) {
manager.send(Methods.startTerminal, { containerId });
}
// Update font size display
@@ -3905,12 +3934,12 @@ window.filterImages = filterImages;
// Collapse Sidebar Functionality - set up in DOMContentLoaded
function handlePeerData(data, topicId, peer) {
function handleRpcMessage(response, conn) {
const topicId = conn?.id || manager.activeId;
const peer = conn || manager.active;
try {
// Parse the incoming data
const response = JSON.parse(data.toString());
console.log(`[DEBUG] Received data from peer (topic: ${topicId}): ${JSON.stringify(response)}`);
console.log(response.message)
console.log(`[DEBUG] RPC message (${topicId}):`, response);
if (response && response.message) console.log(response.message)
// Handle errors first - check for error responses before processing
if (response.error) {
@@ -3929,13 +3958,22 @@ function handlePeerData(data, topicId, peer) {
hideStatusIndicator();
}
// Ensure the data is for the active connection
if (!connections[topicId]) {
// Ensure the data is for a known connection (pushes always have a conn id)
if (topicId && !connections[topicId] && peer) {
// Still process if this is the active manager connection
if (manager.active && peer.id !== manager.active.id) {
console.warn(`[WARN] No connection found for topic: ${topicId}. Ignoring data.`);
return;
}
}
if (peer !== connections[topicId].peer) {
if (
topicId &&
connections[topicId]?.peer &&
peer &&
peer !== connections[topicId].peer &&
peer.id !== connections[topicId].peer.id
) {
console.warn(`[WARN] Ignoring data from a non-active peer for topic: ${topicId}`);
return;
}
@@ -4136,16 +4174,14 @@ function handlePeerData(data, topicId, peer) {
break;
default:
// Check if this is a directory browser response (no type field, but has contents)
// Success-only RPC replies (start/stop/etc.) have no type — that is normal
if (response.success && Array.isArray(response.contents) && response.path !== undefined) {
// This is a directory browser response, let the directory handler process it
// Don't warn about it
}
// Check if this is a volumes list response (no type field, but has volumes array)
else if (response.success && Array.isArray(response.volumes)) {
// This is a volumes list response, let the volume handler process it
// Don't warn about it
} else {
// directory browser
} else if (response.success && Array.isArray(response.volumes)) {
// volumes list without type
} else if (response.success || response.error) {
// command acknowledgement
} else if (response.type) {
console.warn(`[WARN] Unhandled response type: ${response.type}`);
}
break;
@@ -4192,10 +4228,8 @@ function handlePeerData(data, topicId, peer) {
window.handlePeerResponse(response);
}
} catch (err) {
// Catch and log any parsing or processing errors
console.error(`[ERROR] Failed to process peer data: ${err.message}`);
console.error(`[DEBUG] Raw data received: ${data.toString()}`);
showAlert('danger', 'Failed to process peer data. Check the console for details.');
console.error(`[ERROR] Failed to process RPC message: ${err.message}`, response);
showAlert('danger', 'Failed to process server message. Check the console for details.');
}
}
@@ -4207,28 +4241,31 @@ function handlePeerData(data, topicId, peer) {
// Add a new connection - event listener set up in DOMContentLoaded
function addConnection(topicHex) {
console.log(`[DEBUG] Adding connection with topic: ${topicHex}`);
async function addConnection(publicKeyHex) {
console.log(`[DEBUG] Adding connection with public key: ${publicKeyHex}`);
publicKeyHex = (publicKeyHex || '').trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) {
showAlert('danger', 'Invalid server public key. Expected 64 hex characters.');
return;
}
if (Object.keys(connections).length === 0) {
hideWelcomePage();
}
const topic = b4a.from(topicHex, 'hex');
const topicId = topicHex.substring(0, 12);
const topicId = publicKeyHex.substring(0, 12);
connections[topicId] = {
topic,
publicKeyHex,
topicHex: publicKeyHex,
peer: null,
swarm: null,
topicHex,
alias: null,
connectedAt: null,
lastHealthCheck: null,
latency: null,
healthStatus: 'unknown'
healthStatus: 'connecting',
};
saveConnections(); // Save updated connections to cookies
saveConnections();
const connectionItem = document.createElement('li');
connectionItem.className = 'list-group-item d-flex align-items-center justify-content-between';
@@ -4236,15 +4273,13 @@ function addConnection(topicHex) {
const displayName = connections[topicId].alias || topicId;
connectionItem.innerHTML = `
<div class="connection-item row align-items-center px-2 py-1 border-bottom bg-dark text-light">
<!-- Connection Info -->
<div class="col-7 connection-info">
<span class="d-flex align-items-center">
<span class="connection-status ${connections[topicId].peer ? 'status-connected' : 'status-disconnected'}"></span>
<span class="connection-status status-disconnected"></span>
<span class="connection-name text-truncate">${displayName}</span>
${connections[topicId].latency ? `<small class="text-muted ms-1 flex-shrink-0">(${connections[topicId].latency}ms)</small>` : ''}
<small class="text-muted ms-1 flex-shrink-0"></small>
</span>
</div>
<!-- Action Buttons -->
<div class="col-5 d-flex justify-content-end flex-shrink-0">
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary docker-terminal-btn p-1" title="Open Terminal">
@@ -4257,108 +4292,59 @@ function addConnection(topicHex) {
</div>
</div>
`;
// Add Docker Terminal button event listener
connectionItem.querySelector('.docker-terminal-btn')?.addEventListener('click', (event) => {
event.stopPropagation();
console.log('[DEBUG] Docker terminal button clicked.');
if (!topicId) {
console.error('[ERROR] Missing topicId. Cannot proceed.');
return;
}
const connection = connections[topicId];
console.log(`[DEBUG] Retrieved connection for topicId: ${topicId}`, connection);
if (connection && connection.peer) {
try {
console.log(`[DEBUG] Starting Docker terminal for topicId: ${topicId}`);
if (connection?.peer) {
startDockerTerminal(topicId, connection.peer);
const dockerTerminalModal = document.getElementById('dockerTerminalModal');
if (dockerTerminalModal) {
const modalInstance = new bootstrap.Modal(dockerTerminalModal);
modalInstance.show();
console.log('[DEBUG] Docker Terminal modal displayed.');
} else {
console.error('[ERROR] Docker Terminal modal not found in the DOM.');
}
} catch (error) {
console.error(`[ERROR] Failed to start Docker CLI terminal for topicId: ${topicId}`, error);
new bootstrap.Modal(dockerTerminalModal).show();
}
} else {
console.warn(`[WARNING] No active peer found for topicId: ${topicId}. Unable to start Docker CLI terminal.`);
console.warn(`[WARNING] No active connection for ${topicId}`);
}
});
connectionItem.querySelector('span').addEventListener('click', () => switchConnection(topicId));
connectionItem.querySelector('.disconnect-btn').addEventListener('click', (e) => {
e.stopPropagation();
disconnectConnection(topicId, connectionItem);
});
refreshContainerStats();
connectionList.appendChild(connectionItem);
const swarm = new Hyperswarm();
connections[topicId].swarm = swarm;
swarm.join(topic, { client: true, server: false });
swarm.on('connection', (peer) => {
console.log(`[INFO] Connected to peer for topic: ${topicHex}`);
if (connections[topicId].peer) {
peer.destroy();
return;
}
connections[topicId].peer = peer;
try {
showStatusIndicator('Connecting…');
const conn = await manager.connect(publicKeyHex);
connections[topicId].peer = conn;
connections[topicId].connectedAt = Date.now();
connections[topicId].healthStatus = 'healthy';
updateConnectionStatus(topicId, true);
startHealthMonitoring(topicId);
// Store peer data handler reference for cleanup
const peerDataHandler = (data) => handlePeerData(data, topicId, peer);
peer.on('data', peerDataHandler);
connections[topicId].peerDataHandler = peerDataHandler; // Store for cleanup
peer.on('close', () => {
updateConnectionStatus(topicId, false);
// Remove peer data handler
if (connections[topicId] && connections[topicId].peerDataHandler) {
peer.removeListener('data', connections[topicId].peerDataHandler);
delete connections[topicId].peerDataHandler;
}
if (window.activePeer === peer) {
window.activePeer = null;
dashboard.classList.add('hidden');
containerList.innerHTML = '';
stopStatsInterval(); // Stop stats polling
}
});
if (!window.activePeer) {
window.activePeer = conn;
if (!manager.active || manager.active.id === conn.id) {
switchConnection(topicId);
}
startStatsInterval();
// Hide welcome page and show dashboard
hideWelcomePage();
});
hideStatusIndicator();
showAlert('success', `Connected to ${topicId}`);
} catch (err) {
console.error('[ERROR] Connection failed', err);
connections[topicId].healthStatus = 'error';
updateConnectionStatus(topicId, false);
hideStatusIndicator();
showAlert('danger', `Connection failed: ${err.message}`);
}
// Collapse the sidebar after adding a connection
// Use cached DOM elements
if (sidebar && !sidebar.classList.contains('collapsed')) {
sidebar.classList.add('collapsed');
if (collapseSidebarBtn) {
collapseSidebarBtn.innerHTML = '&gt;';
}
console.log('[DEBUG] Sidebar collapsed after adding connection');
if (collapseSidebarBtn) collapseSidebarBtn.innerHTML = '&gt;';
}
}
// Function to open the template deploy modal
function openTemplateDeployModal(topicId) {
// Pass the topic ID or other connection-specific info if needed
@@ -4426,9 +4412,7 @@ document.addEventListener('DOMContentLoaded', () => {
const topicHex = newConnectionTopic ? newConnectionTopic.value.trim() : '';
if (topicHex) {
addConnection(topicHex);
if (newConnectionTopic) {
newConnectionTopic.value = '';
}
if (newConnectionTopic) newConnectionTopic.value = '';
}
});
}
@@ -4622,12 +4606,9 @@ document.addEventListener('DOMContentLoaded', () => {
// Restore saved connections with error handling
Object.keys(savedConnections).forEach((topicId) => {
try {
let topicHex = savedConnections[topicId].topic;
// Ensure topicHex is a string
if (typeof topicHex !== 'string') {
topicHex = b4a.toString(topicHex, 'hex');
}
addConnection(topicHex);
const entry = savedConnections[topicId];
const publicKeyHex = entry.publicKeyHex || entry.topicHex || entry.topic;
if (publicKeyHex) addConnection(String(publicKeyHex));
} catch (err) {
console.error(`[ERROR] Failed to restore connection ${topicId}: ${err.message}`);
}
@@ -4674,16 +4655,10 @@ function disconnectConnection(topicId, connectionItem) {
clearInterval(connection.healthCheckInterval);
}
// Destroy the peer and swarm
// Close HyperDHT / protomux-rpc connection
if (connection.peer) {
// Remove peer data handler before destroying
if (connection.peerDataHandler) {
connection.peer.removeListener('data', connection.peerDataHandler);
}
connection.peer.destroy();
}
if (connection.swarm) {
connection.swarm.destroy();
manager.disconnect(topicId).catch(() => {});
connection.peer.close?.().catch?.(() => {});
}
// Remove from global connections
@@ -4697,21 +4672,20 @@ function disconnectConnection(topicId, connectionItem) {
connectionList.removeChild(connectionItem);
}
// Reset the connection title if this was the active peer
if (window.activePeer === connection.peer) {
window.activePeer = null;
const connectionTitle = document.getElementById('connection-title');
if (connectionTitle) {
connectionTitle.textContent = 'Choose a Connection'; // Reset the title
// Reset UI if this was the active connection
if (manager.active?.id === topicId || manager.active === connection.peer) {
const connectionTitleEl = document.getElementById('connection-title');
if (connectionTitleEl) {
connectionTitleEl.textContent = 'Choose a Connection';
}
const dashboard = document.getElementById('dashboard');
if (dashboard) {
dashboard.classList.add('hidden');
const dashboardEl = document.getElementById('dashboard');
if (dashboardEl) {
dashboardEl.classList.add('hidden');
}
resetContainerList(); // Clear containers
resetContainerList();
stopStatsInterval();
}
// Show welcome page if no connections remain
@@ -4802,37 +4776,25 @@ function startHealthMonitoring(topicId) {
const connection = connections[topicId];
if (!connection) return;
const healthCheckInterval = setInterval(() => {
if (!connections[topicId] || !connections[topicId].peer) {
const healthCheckInterval = setInterval(async () => {
const entry = connections[topicId];
if (!entry?.peer?.connected) {
clearInterval(healthCheckInterval);
return;
}
const startTime = Date.now();
try {
// Send a lightweight ping command
connections[topicId].peer.write(JSON.stringify({ command: 'listContainers' }));
// Set timeout for health check
setTimeout(() => {
if (connections[topicId]) {
const elapsed = Date.now() - startTime;
connections[topicId].latency = elapsed;
connections[topicId].lastHealthCheck = Date.now();
connections[topicId].healthStatus = elapsed < 5000 ? 'healthy' : 'slow';
const ms = await entry.peer.ping();
entry.latency = ms;
entry.lastHealthCheck = Date.now();
entry.healthStatus = ms < 5000 ? 'healthy' : 'slow';
updateConnectionDisplay(topicId);
}
}, 100);
} catch (err) {
if (connections[topicId]) {
connections[topicId].healthStatus = 'unhealthy';
entry.healthStatus = 'unhealthy';
updateConnectionStatus(topicId, false);
}
clearInterval(healthCheckInterval);
}
}, 10000); // Check every 10 seconds
}, 10000);
// Store interval ID for cleanup
connections[topicId].healthCheckInterval = healthCheckInterval;
}
@@ -4847,34 +4809,34 @@ function switchConnection(topicId) {
return;
}
// Update the active peer
window.activePeer = connection.peer;
if (connection.peer?.id) {
manager.setActive(connection.peer.id);
}
// Clear container list before loading new data
resetContainerList();
console.log(`[INFO] Switched to connection: ${topicId}`);
// Start the stats interval
startStatsInterval();
sendCommand('listContainers'); // Request containers for the new connection
sendCommand(Methods.listContainers);
}
// Attach switchConnection to the global window object
window.switchConnection = switchConnection;
// Send a command to the active peer
// Send a command to the active peer via protomux-rpc
function sendCommand(command, args = {}) {
if (window.activePeer) {
const message = JSON.stringify({ command, args });
console.log(`[DEBUG] Sending command to server: ${message}`);
window.activePeer.write(message);
} else {
// Silently return during initialization - this is expected
if (!manager.active?.connected) {
console.debug('[DEBUG] No active peer to send command (this is normal during initialization).');
return Promise.resolve(null);
}
console.log(`[DEBUG] RPC ${command}`, args);
return manager.send(command, args).then((response) => {
if (response) {
// Route request responses through the same UI pipeline as pushes
handleRpcMessage(response, manager.active);
}
return response;
});
}
// Attach sendCommand to the global window object
@@ -6526,17 +6488,9 @@ function assertVisibility() {
// Attach startTerminal to the global window object
window.startTerminal = startTerminal;
// Handle window unload to clean up swarms and peers
// Handle window unload to clean up DHT connections
window.addEventListener('beforeunload', () => {
for (const topicId in connections) {
const connection = connections[topicId];
if (connection.peer) {
connection.peer.destroy();
}
if (connection.swarm) {
connection.swarm.destroy();
}
}
manager.disconnectAll().catch(() => {});
});
// Initialize Inspect Modal Event Listeners
+107
View File
@@ -0,0 +1,107 @@
/**
* Typed peardock RPC API over an active connection / manager.
* UI and tools should prefer these helpers over raw method strings.
*/
import { Methods } from '../shared/protocol.js'
import { manager } from './manager.js'
function connOrActive(connection) {
const c = connection || manager.active
if (!c?.connected) throw new Error('Not connected')
return c
}
export const api = {
Methods,
ping(connection) {
return connOrActive(connection).ping()
},
listContainers(connection) {
return connOrActive(connection).request(Methods.listContainers, {})
},
inspectContainer(id, connection) {
return connOrActive(connection).request(Methods.inspectContainer, { id })
},
startContainer(id, connection) {
return connOrActive(connection).request(Methods.startContainer, { id })
},
stopContainer(id, connection) {
return connOrActive(connection).request(Methods.stopContainer, { id })
},
restartContainer(id, connection) {
return connOrActive(connection).request(Methods.restartContainer, { id })
},
removeContainer(id, connection) {
return connOrActive(connection).request(Methods.removeContainer, { id })
},
deployContainer(args, connection) {
return connOrActive(connection).request(Methods.deployContainer, args)
},
listImages(connection) {
return connOrActive(connection).request(Methods.listImages, {})
},
listNetworks(connection) {
return connOrActive(connection).request(Methods.listNetworks, {})
},
listVolumes(connection) {
return connOrActive(connection).request(Methods.listVolumes, {})
},
listStacks(connection) {
return connOrActive(connection).request(Methods.listStacks, {})
},
getSystemInfo(connection) {
return connOrActive(connection).request(Methods.getSystemInfo, {})
},
startTerminal(containerId, connection) {
return connOrActive(connection).request(Methods.startTerminal, { containerId })
},
killTerminal(containerId, connection) {
return connOrActive(connection).request(Methods.killTerminal, { containerId })
},
terminalInput(payload, connection) {
return connOrActive(connection).event(Methods.terminalInput, payload)
},
terminalResize(payload, connection) {
return connOrActive(connection).event(Methods.terminalResize, payload)
},
startLogs(id, connection) {
return connOrActive(connection).request(Methods.startLogs, { id })
},
stopLogs(id, connection) {
return connOrActive(connection).request(Methods.stopLogs, { id })
},
dockerCommand(data, connectionId, connection) {
return connOrActive(connection).request(Methods.dockerCommand, { data, connectionId })
},
/** Generic escape hatch */
request(method, args = {}, connection) {
return connOrActive(connection).request(method, args)
},
event(method, args = {}, connection) {
return connOrActive(connection).event(method, args)
},
}
export default api
+217
View File
@@ -0,0 +1,217 @@
/**
* Single peardock server connection via HyperDHT + protomux-rpc.
*/
import DHT from 'hyperdht'
import ProtomuxRPC from 'protomux-rpc'
import b4a from 'b4a'
import { EventEmitter } from 'events'
import { PROTOCOL, Pushes, PushToType, Methods } from '../shared/protocol.js'
import { encodings } from '../shared/encodings.js'
/**
* @typedef {object} ConnectionOptions
* @property {number} [timeoutMs=30000]
*/
export class PearDockConnection extends EventEmitter {
/**
* @param {string} publicKeyHex - Server HyperDHT public key (64 hex chars)
* @param {ConnectionOptions} [opts]
*/
constructor(publicKeyHex, opts = {}) {
super()
if (!/^[0-9a-fA-F]{64}$/.test(publicKeyHex)) {
throw new Error('Server public key must be 64 hex characters')
}
this.publicKeyHex = publicKeyHex.toLowerCase()
this.publicKey = b4a.from(this.publicKeyHex, 'hex')
this.id = this.publicKeyHex.slice(0, 12)
this.timeoutMs = opts.timeoutMs ?? 30000
this.dht = null
this.swarm = null
this.socket = null
this.rpc = null
this.connected = false
this.alias = null
this.connectedAt = null
this.latency = null
this.healthStatus = 'unknown'
}
/**
* Open DHT connection and set up RPC + push handlers.
* @returns {Promise<this>}
*/
async connect() {
if (this.connected) return this
this.dht = new DHT()
this.socket = this.dht.connect(this.publicKey)
await new Promise((resolve, reject) => {
let settled = false
const timer = setTimeout(() => {
if (!settled) {
settled = true
cleanup()
reject(new Error(`Connection timeout after ${this.timeoutMs}ms`))
}
}, this.timeoutMs)
const onOpen = () => {
if (settled) return
settled = true
clearTimeout(timer)
cleanup()
resolve()
}
const onError = (err) => {
if (settled) return
settled = true
clearTimeout(timer)
cleanup()
reject(err)
}
const onClose = () => {
if (settled) return
settled = true
clearTimeout(timer)
cleanup()
reject(new Error('Connection closed before open'))
}
const cleanup = () => {
this.socket.off('open', onOpen)
this.socket.off('connect', onOpen)
this.socket.off('error', onError)
this.socket.off('close', onClose)
}
// HyperDHT secret-stream: 'connect' after Noise handshake; some builds use 'open'
this.socket.once('open', onOpen)
this.socket.once('connect', onOpen)
this.socket.once('error', onError)
this.socket.once('close', onClose)
if (this.socket.publicKey && this.socket.rawStream) {
onOpen()
}
})
this.rpc = new ProtomuxRPC(this.socket, {
id: this.publicKey,
protocol: PROTOCOL,
...encodings,
})
await this.rpc.fullyOpened?.().catch(() => {})
this._registerPushHandlers()
this.socket.on('close', () => this._onDisconnect())
this.socket.on('error', (err) => {
this.emit('error', err)
})
this.rpc.on('close', () => this._onDisconnect())
this.connected = true
this.connectedAt = Date.now()
this.healthStatus = 'healthy'
this.emit('connect')
return this
}
/**
* Register server client push methods.
* Server uses rpc.event(pushName, payload); we respond and re-emit.
*/
_registerPushHandlers() {
for (const push of Object.values(Pushes)) {
this.rpc.respond(push, encodings, (payload) => {
const type = PushToType[push] || payload?.type || push
const message = payload && typeof payload === 'object' ? { ...payload, type } : { type, data: payload }
this.emit('message', message)
this.emit(type, message)
return null
})
}
}
/**
* RPC request to the server.
* @param {string} method
* @param {object} [args]
* @param {object} [opts]
* @returns {Promise<any>}
*/
async request(method, args = {}, opts = {}) {
if (!this.rpc || this.rpc.closed) {
throw new Error('Not connected')
}
return this.rpc.request(method, args, {
...encodings,
timeout: opts.timeout ?? this.timeoutMs,
})
}
/**
* Fire-and-forget event (terminal input, etc.).
* Prefer request() when a response is useful.
* @param {string} method
* @param {object} [args]
*/
event(method, args = {}) {
if (!this.rpc || this.rpc.closed) return
this.rpc.event(method, args, encodings)
}
/**
* Health check round-trip.
* @returns {Promise<number>} latency ms
*/
async ping() {
const start = Date.now()
await this.request(Methods.ping, {})
this.latency = Date.now() - start
this.lastHealthCheck = Date.now()
this.healthStatus = 'healthy'
return this.latency
}
_onDisconnect() {
if (!this.connected) return
this.connected = false
this.healthStatus = 'disconnected'
this.emit('disconnect')
}
/**
* Tear down connection and DHT.
*/
async close() {
this.connected = false
try {
if (this.rpc && !this.rpc.closed) await this.rpc.end?.()
} catch {
try {
this.rpc?.destroy()
} catch {
// ignore
}
}
try {
this.socket?.destroy()
} catch {
// ignore
}
try {
await this.dht?.destroy()
} catch {
// ignore
}
this.rpc = null
this.socket = null
this.dht = null
this.emit('close')
}
}
+7
View File
@@ -0,0 +1,7 @@
/**
* Client networking facade for peardock UI.
*/
export { PearDockConnection } from './connection.js'
export { ConnectionManager, manager, Methods } from './manager.js'
export { api } from './api.js'
export { PROTOCOL, Pushes, PushToType } from '../shared/protocol.js'
+221
View File
@@ -0,0 +1,221 @@
/**
* Multi-server connection manager for the peardock UI.
*/
import { EventEmitter } from 'events'
import { PearDockConnection } from './connection.js'
import { Methods } from '../shared/protocol.js'
import { CONFIG } from '../config.js'
const STORAGE_KEY = CONFIG.STORAGE.CONNECTIONS_KEY
const USE_LS_KEY = CONFIG.STORAGE.USE_LOCALSTORAGE_KEY
export class ConnectionManager extends EventEmitter {
constructor() {
super()
/** @type {Map<string, PearDockConnection>} */
this.connections = new Map()
/** @type {PearDockConnection|null} */
this.active = null
}
get activeId() {
return this.active?.id || null
}
/**
* @param {string} publicKeyHex
* @param {{ alias?: string }} [meta]
* @returns {Promise<PearDockConnection>}
*/
async connect(publicKeyHex, meta = {}) {
const key = publicKeyHex.toLowerCase()
const id = key.slice(0, 12)
if (this.connections.has(id)) {
const existing = this.connections.get(id)
if (existing.connected) {
this.setActive(id)
return existing
}
await existing.close().catch(() => {})
this.connections.delete(id)
}
const conn = new PearDockConnection(key, {
timeoutMs: CONFIG.CONNECTION.TIMEOUT_MS,
})
if (meta.alias) conn.alias = meta.alias
conn.on('message', (msg) => this.emit('message', msg, conn))
conn.on('disconnect', () => {
this.emit('disconnect', conn)
if (this.active === conn) {
this.active = null
this.emit('active', null)
}
})
conn.on('error', (err) => this.emit('error', err, conn))
await conn.connect()
this.connections.set(id, conn)
this.persist()
this.setActive(id)
this.emit('connect', conn)
return conn
}
/**
* @param {string} id
*/
setActive(id) {
const conn = this.connections.get(id)
if (!conn) return
this.active = conn
this.emit('active', conn)
}
/**
* @param {string} id
*/
async disconnect(id) {
const conn = this.connections.get(id)
if (!conn) return
if (this.active === conn) {
this.active = null
this.emit('active', null)
}
this.connections.delete(id)
await conn.close().catch(() => {})
this.persist()
this.emit('remove', id)
}
async disconnectAll() {
const ids = [...this.connections.keys()]
for (const id of ids) {
await this.disconnect(id)
}
}
/**
* RPC on the active connection.
* @param {string} method
* @param {object} [args]
*/
async request(method, args = {}) {
if (!this.active?.connected) {
throw new Error('No active connection')
}
return this.active.request(method, args)
}
/**
* Fire-and-forget on the active connection.
*/
event(method, args = {}) {
if (!this.active?.connected) return
this.active.event(method, args)
}
/**
* Convenience: send a named method (same names as Methods).
* Fire-and-forget request that still awaits (for errors).
* UI historically called sendCommand without awaiting.
*/
send(method, args = {}) {
if (!this.active?.connected) {
console.debug('[DEBUG] No active connection for', method)
return Promise.resolve(null)
}
return this.active.request(method, args).catch((err) => {
this.emit('message', {
error: err.message,
code: err.code || 'UNKNOWN_ERROR',
}, this.active)
return null
})
}
/** Persist connection keys (not live sockets). */
persist() {
const serializable = {}
for (const [id, conn] of this.connections) {
serializable[id] = {
publicKeyHex: conn.publicKeyHex,
alias: conn.alias || null,
}
}
const json = JSON.stringify(serializable)
try {
if (json.length > CONFIG.STORAGE.COOKIE_SIZE_LIMIT) {
localStorage.setItem(STORAGE_KEY, json)
localStorage.setItem(USE_LS_KEY, '1')
} else {
document.cookie = `connections=${encodeURIComponent(json)};path=/;max-age=31536000`
localStorage.removeItem(USE_LS_KEY)
}
} catch (err) {
console.error('[ERROR] Failed to persist connections', err)
try {
localStorage.setItem(STORAGE_KEY, json)
localStorage.setItem(USE_LS_KEY, '1')
} catch {
// ignore
}
}
}
/**
* Load saved public keys (does not auto-connect).
* @returns {Array<{ id: string, publicKeyHex: string, alias: string|null }>}
*/
loadSaved() {
let raw = null
try {
if (localStorage.getItem(USE_LS_KEY) === '1') {
raw = localStorage.getItem(STORAGE_KEY)
}
} catch {
// ignore
}
if (!raw) {
const match = document.cookie.match(/(?:^|; )connections=([^;]*)/)
if (match) raw = decodeURIComponent(match[1])
}
if (!raw) {
try {
raw = localStorage.getItem(STORAGE_KEY)
} catch {
// ignore
}
}
if (!raw) return []
try {
const parsed = JSON.parse(raw)
return Object.entries(parsed).map(([id, value]) => {
// migrate old topicHex → publicKeyHex
const publicKeyHex = (
value.publicKeyHex ||
value.topicHex ||
value.topic ||
''
).toLowerCase()
return {
id: id || publicKeyHex.slice(0, 12),
publicKeyHex,
alias: value.alias || null,
}
}).filter((e) => /^[0-9a-f]{64}$/.test(e.publicKeyHex))
} catch {
return []
}
}
list() {
return [...this.connections.values()]
}
}
export const manager = new ConnectionManager()
export { Methods }
+3 -3
View File
@@ -3294,7 +3294,7 @@
<h4 class="text-center mt-3">Connections</h4>
<ul id="connection-list" class="list-group mb-3"></ul>
<form id="add-connection-form" class="px-3 d-flex align-items-center">
<input type="text" id="new-connection-topic" class="form-control me-2" placeholder="Enter server topic" required>
<input type="text" id="new-connection-topic" class="form-control me-2" placeholder="Server public key (64 hex chars)" required>
<button type="submit" class="btn btn-primary">
<i class="fas fa-plug"></i> Add
</button>
@@ -3306,8 +3306,8 @@
<div id="content">
<div id="welcome-page">
<h1>Welcome to peardock</h1>
<p class="mt-3">Easily manage your Docker containers across peer-to-peer connections.</p>
<p>To get started, add a connection using the form in the sidebar.</p>
<p class="mt-3">Manage Docker over HyperDHT + protomux-rpc — no central server required.</p>
<p>Start the peardock server, copy its <strong>public key</strong>, and paste it in the sidebar to connect.</p>
<!-- <img src="https://via.placeholder.com/500x300" alt="Welcome Graphic" class="img-fluid mt-4"> -->
</div>
<!-- Dashboard View -->
+33 -27
View File
@@ -30,6 +30,8 @@ function getFitAddon() {
const Terminal = getTerminal();
const FitAddon = getFitAddon();
import { manager, Methods } from '../client/manager.js';
// DOM Elements
const dockerTerminalModal = document.getElementById('docker-terminal-modal');
const dockerTerminalTitle = document.getElementById('docker-terminal-title');
@@ -81,10 +83,12 @@ const dockerTerminalThemes = {
* @param {Object} peer - Active peer object for communication.
*/
function startDockerTerminal(connectionId, peer) {
if (!peer) {
const conn = peer || manager.active;
if (!conn || typeof conn.request !== 'function') {
console.error('[ERROR] No active peer for Docker CLI terminal.');
return;
}
peer = conn;
if (dockerTerminalSession) {
console.log('[INFO] Docker CLI terminal session already exists.');
@@ -128,12 +132,12 @@ function startDockerTerminal(connectionId, peer) {
const cols = xterm.cols;
const rows = xterm.rows;
if (peer && cols && rows) {
peer.write(JSON.stringify({
type: 'dockerTerminalResize',
const api = peer.event ? peer : manager;
api.event(Methods.dockerTerminalResize, {
connectionId,
cols,
rows,
}));
});
}
};
@@ -142,26 +146,25 @@ function startDockerTerminal(connectionId, peer) {
sendTerminalResize();
});
// Handle peer data - store handler reference for cleanup
const peerDataHandler = (data) => {
// Handle docker CLI push events via connection EventEmitter
const peerDataHandler = (response) => {
try {
const response = JSON.parse(data.toString());
if (response.connectionId === connectionId) {
if (!response || response.type !== 'dockerOutput') return;
if (response.connectionId && response.connectionId !== connectionId) return;
const decodedData = decodeResponseData(response.data, response.encoding);
if (response.type === 'dockerOutput') {
xterm.write(`${decodedData.trim()}\r\n`);
} else if (response.type === 'terminalErrorOutput') {
xterm.write(`\r\n[ERROR] ${decodedData.trim()}\r\n`);
}
}
} catch (error) {
console.error(`[ERROR] Failed to parse response from peer: ${error.message}`);
console.error(`[ERROR] Failed to handle docker output: ${error.message}`);
}
};
peer.on('data', peerDataHandler);
dockerTerminalSession = { xterm, fitAddon, connectionId, peer, peerDataHandler, onResizeDisposable };
peer.on?.('message', peerDataHandler);
// also listen on manager for active pushes
const managerHandler = (msg, conn) => {
if (conn && peer && conn !== peer && conn.id !== peer.id) return;
peerDataHandler(msg);
};
manager.on('message', managerHandler);
dockerTerminalSession = { xterm, fitAddon, connectionId, peer, peerDataHandler, managerHandler, onResizeDisposable };
// Send initial dimensions after a short delay to ensure terminal is fully rendered
setTimeout(() => {
@@ -188,13 +191,13 @@ function startDockerTerminal(connectionId, peer) {
// User pressed Enter
const fullCommand = prependDockerCommand(inputBuffer.trim());
if (fullCommand) {
peer.write(
JSON.stringify({
command: 'dockerCommand',
const api = peer.request ? peer : manager;
api.request(Methods.dockerCommand, {
connectionId,
data: fullCommand,
})
);
}).catch((err) => {
xterm.write(`\r\n[ERROR] ${err.message}\r\n`);
});
xterm.write('\r\n'); // Move to the next line
} else {
xterm.write('\r\n[ERROR] Invalid or blocked command. Only read-only Docker commands are allowed.\r\n');
@@ -366,7 +369,10 @@ function cleanUpDockerTerminal() {
}
// Remove peer data handler if it exists
if (dockerTerminalSession.peer && dockerTerminalSession.peerDataHandler) {
dockerTerminalSession.peer.removeListener('data', dockerTerminalSession.peerDataHandler);
dockerTerminalSession.peer.removeListener?.('message', dockerTerminalSession.peerDataHandler);
}
if (dockerTerminalSession.managerHandler) {
manager.removeListener('message', dockerTerminalSession.managerHandler);
}
// Remove resize listener if it exists
if (dockerTerminalSession.onResizeDisposable) {
@@ -444,12 +450,12 @@ document.addEventListener('DOMContentLoaded', () => {
const cols = dockerTerminalSession.xterm.cols;
const rows = dockerTerminalSession.xterm.rows;
if (dockerTerminalSession.peer && cols && rows) {
dockerTerminalSession.peer.write(JSON.stringify({
type: 'dockerTerminalResize',
const api = dockerTerminalSession.peer.event ? dockerTerminalSession.peer : manager;
api.event(Methods.dockerTerminalResize, {
connectionId: dockerTerminalSession.connectionId,
cols,
rows,
}));
});
}
}, 50);
}
+22 -28
View File
@@ -32,6 +32,9 @@ function getFitAddon() {
const Terminal = getTerminal();
const FitAddon = getFitAddon();
import { manager, Methods } from '../client/manager.js';
// DOM Elements
const terminalModal = document.getElementById('terminal-modal');
const terminalTitle = document.getElementById('terminal-title');
@@ -125,13 +128,12 @@ mousemoveHandler = (e) => {
setTimeout(() => {
const cols = session.xterm.cols;
const rows = session.xterm.rows;
if (window.activePeer && cols && rows) {
window.activePeer.write(JSON.stringify({
type: 'terminalResize',
if (manager.active && cols && rows) {
manager.event(Methods.terminalResize, {
containerId: activeContainerId,
cols,
rows,
}));
});
}
}, 50);
});
@@ -153,7 +155,7 @@ document.addEventListener('mouseup', mouseupHandler);
// START TERMINAL
// -------------------------------------------------------------------
function startTerminal(containerId, containerName) {
if (!window.activePeer) {
if (!manager.active?.connected) {
console.error('[ERROR] No active peer connection.');
return;
}
@@ -194,24 +196,22 @@ function startTerminal(containerId, containerName) {
const sendTerminalResize = () => {
const cols = xterm.cols;
const rows = xterm.rows;
if (window.activePeer && cols && rows) {
window.activePeer.write(JSON.stringify({
type: 'terminalResize',
if (manager.active && cols && rows) {
manager.event(Methods.terminalResize, {
containerId,
cols,
rows,
}));
});
}
};
const onDataDisposable = xterm.onData((data) => {
const encoded = btoa(unescape(encodeURIComponent(data)));
window.activePeer.write(JSON.stringify({
type: 'terminalInput',
manager.event(Methods.terminalInput, {
containerId,
data: encoded,
encoding: 'base64',
}));
});
});
// Listen for terminal resize events
@@ -235,10 +235,7 @@ function startTerminal(containerId, containerName) {
sendTerminalResize();
}, 100);
window.activePeer.write(JSON.stringify({
command: 'startTerminal',
args: { containerId }
}));
manager.send(Methods.startTerminal, { containerId });
switchTerminal(containerId);
}
@@ -269,13 +266,12 @@ function switchTerminal(containerId) {
setTimeout(() => {
const cols = session.xterm.cols;
const rows = session.xterm.rows;
if (window.activePeer && cols && rows) {
window.activePeer.write(JSON.stringify({
type: 'terminalResize',
if (manager.active && cols && rows) {
manager.event(Methods.terminalResize, {
containerId,
cols,
rows,
}));
});
}
}, 50);
});
@@ -295,13 +291,12 @@ function switchTerminal(containerId) {
setTimeout(() => {
const cols = session.xterm.cols;
const rows = session.xterm.rows;
if (window.activePeer && cols && rows) {
window.activePeer.write(JSON.stringify({
type: 'terminalResize',
if (manager.active && cols && rows) {
manager.event(Methods.terminalResize, {
containerId,
cols,
rows,
}));
});
}
}, 50);
};
@@ -413,13 +408,12 @@ document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
const cols = session.xterm.cols;
const rows = session.xterm.rows;
if (window.activePeer && cols && rows) {
window.activePeer.write(JSON.stringify({
type: 'terminalResize',
if (manager.active && cols && rows) {
manager.event(Methods.terminalResize, {
containerId,
cols,
rows,
}));
});
}
}, 50);
}
+27 -19
View File
@@ -1,40 +1,48 @@
{
"name": "peardock",
"version": "2.0.0",
"description": "Decentralized Docker management over HyperDHT + protomux-rpc",
"main": "index.html",
"type": "module",
"license": "Apache-2.0",
"pear": {
"name": "peardock",
"type": "desktop",
"gui": {
"backgroundColor": "#1F2430",
"height": "400",
"width": "950"
"height": "720",
"width": "1200"
},
"links": [
"links": [
"http://*",
"https://*",
"ws://*",
"wss://*"
]
]
},
"type": "module",
"license": "Apache-2.0",
"scripts": {
"dev": "pear run -d .",
"test": "brittle test/*.test.js"
"start:server": "node server/server.js",
"server": "node server/server.js",
"test": "brittle-node test/*.test.js"
},
"devDependencies": {
"brittle": "^3.0.0",
"pear-interface": "^1.0.0"
"engines": {
"node": ">=20"
},
"dependencies": {
"axios": "^1.7.8",
"dockernode": "^0.1.0",
"dockerode": "^4.0.2",
"dotenv": "^16.4.5",
"hyperswarm": "^4.8.4",
"stream": "^0.0.3",
"util": "^0.12.5",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0"
"b4a": "^1.8.1",
"compact-encoding": "^3.3.0",
"dockerode": "^5.0.1",
"dotenv": "^17.4.2",
"graceful-goodbye": "^1.3.3",
"hypercore-crypto": "^3.7.0",
"hyperdht": "^6.33.0",
"protomux": "^3.11.0",
"protomux-rpc": "^1.10.0",
"safety-catch": "^1.0.3"
},
"devDependencies": {
"brittle": "^4.1.0",
"pear-interface": "^1.1.0"
}
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Persistent HyperDHT keypair management.
* SERVER_SEED (32-byte hex) is the secret seed.
* Clients connect using the derived public key.
*/
import fs from 'fs'
import path from 'path'
import DHT from 'hyperdht'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
import dotenv from 'dotenv'
dotenv.config()
/**
* @param {string} [envPath]
* @returns {{ seed: Uint8Array, keyPair: { publicKey: Uint8Array, secretKey: Uint8Array }, publicKeyHex: string, seedHex: string }}
*/
export function loadOrCreateKeyPair(envPath = '.env') {
let seedHex = process.env.SERVER_SEED || process.env.SERVER_KEY
// SERVER_KEY historically was a 32-byte topic seed; reuse as DHT seed if present
if (!seedHex) {
const seed = crypto.randomBytes(32)
seedHex = b4a.toString(seed, 'hex')
const publicKeyHex = b4a.toString(DHT.keyPair(seed).publicKey, 'hex')
const line = `\nSERVER_SEED=${seedHex}\nSERVER_PUBLIC_KEY=${publicKeyHex}\n`
fs.appendFileSync(envPath, line, { flag: 'a' })
console.log('[INFO] Generated new SERVER_SEED and SERVER_PUBLIC_KEY in', path.resolve(envPath))
}
if (!/^[0-9a-fA-F]{64}$/.test(seedHex)) {
throw new Error('SERVER_SEED / SERVER_KEY must be 64 hex characters (32 bytes)')
}
const seed = b4a.from(seedHex, 'hex')
const keyPair = DHT.keyPair(seed)
const publicKeyHex = b4a.toString(keyPair.publicKey, 'hex')
// Keep PUBLIC_KEY in env for operator convenience
if (process.env.SERVER_PUBLIC_KEY !== publicKeyHex) {
try {
let env = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : ''
if (env.includes('SERVER_PUBLIC_KEY=')) {
env = env.replace(/SERVER_PUBLIC_KEY=.*/g, `SERVER_PUBLIC_KEY=${publicKeyHex}`)
} else {
env += `\nSERVER_PUBLIC_KEY=${publicKeyHex}\n`
}
if (!env.includes('SERVER_SEED=') && !process.env.SERVER_SEED) {
// migrate old SERVER_KEY-only files
if (!env.includes('SERVER_SEED=')) {
env += `SERVER_SEED=${seedHex}\n`
}
}
fs.writeFileSync(envPath, env)
} catch (err) {
console.warn('[WARN] Could not update .env with SERVER_PUBLIC_KEY:', err.message)
}
}
return { seed, keyPair, publicKeyHex, seedHex }
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Tracks live ProtomuxRPC sessions for broadcast and cleanup.
*/
export class PeerRegistry {
constructor() {
/** @type {Map<string, import('../rpc/session.js').PeerSession>} */
this.sessions = new Map()
}
/**
* @param {import('../rpc/session.js').PeerSession} session
*/
add(session) {
this.sessions.set(session.id, session)
}
/**
* @param {string} id
*/
remove(id) {
this.sessions.delete(id)
}
/**
* @param {string} id
*/
get(id) {
return this.sessions.get(id) || null
}
get size() {
return this.sessions.size
}
[Symbol.iterator]() {
return this.sessions.values()
}
/**
* Fire a push event on every open session.
* @param {string} method
* @param {unknown} payload
*/
broadcast(method, payload) {
for (const session of this.sessions.values()) {
try {
session.push(method, payload)
} catch (err) {
console.error(`[ERROR] Broadcast to ${session.id.slice(0, 12)} failed: ${err.message}`)
}
}
}
clear() {
for (const session of this.sessions.values()) {
try {
session.destroy()
} catch {
// ignore
}
}
this.sessions.clear()
}
}
export const peers = new PeerRegistry()
+245
View File
@@ -0,0 +1,245 @@
/**
* Container RPC handlers.
*/
import { PassThrough } from 'stream'
import { docker, extractIpAddress } from '../services/docker.js'
import * as validation from '../utils/validation.js'
import { Pushes } from '../../shared/protocol.js'
import { peers } from '../core/peer-registry.js'
import logger from '../utils/logger.js'
export function registerContainerHandlers(session) {
session.respond('listContainers', async () => {
const containers = await docker.listContainers({ all: true })
const detailed = await Promise.all(
containers.map(async (container) => {
try {
const details = await docker.getContainer(container.Id).inspect()
return { ...container, ipAddress: extractIpAddress(details) }
} catch (error) {
logger.error('Failed to inspect container', { id: container.Id, error: error.message })
return { ...container, ipAddress: 'Error Retrieving IP' }
}
})
)
return { type: 'containers', data: detailed }
})
session.respond('inspectContainer', async (args) => {
const config = await docker.getContainer(args.id).inspect()
return { type: 'containerConfig', data: config }
})
session.respond('startContainer', async (args) => {
await docker.getContainer(args.id).start()
return { success: true, message: `Container ${args.id} started` }
})
session.respond('stopContainer', async (args) => {
await docker.getContainer(args.id).stop()
return { success: true, message: `Container ${args.id} stopped` }
})
session.respond('restartContainer', async (args) => {
await docker.getContainer(args.id).restart()
return { success: true, message: `Container ${args.id} restarted` }
})
session.respond('pauseContainer', async (args) => {
await docker.getContainer(args.id).pause()
return { success: true, message: `Container ${args.id} paused` }
})
session.respond('unpauseContainer', async (args) => {
await docker.getContainer(args.id).unpause()
return { success: true, message: `Container ${args.id} unpaused` }
})
session.respond('removeContainer', async (args) => {
const id = args.id
session._cleanupLogsForContainer?.(id)
await docker.getContainer(id).remove({ force: true })
return { success: true, message: `Container ${id} removed` }
})
session.respond('renameContainer', async (args) => {
const newName = validation.sanitizeString(args.name, 63)
if (!newName || !validation.isValidContainerName(newName)) {
throw new Error('Invalid container name. Must be alphanumeric with dashes/underscores, 1-63 characters.')
}
await docker.getContainer(args.id).rename({ name: newName })
return { success: true, message: `Container renamed to "${newName}"` }
})
session.respond('commitContainer', async (args) => {
const commitOptions = {
repo: validation.sanitizeString(args.repo, 255),
tag: validation.sanitizeString(args.tag || 'latest', 128),
}
if (args.message) commitOptions.comment = validation.sanitizeString(args.message, 500)
if (args.author) commitOptions.author = validation.sanitizeString(args.author, 255)
const image = await docker.getContainer(args.id).commit(commitOptions)
return {
success: true,
message: `Container committed as ${commitOptions.repo}:${commitOptions.tag}`,
data: image.id,
}
})
session.respond('exportContainer', async (args) => {
await docker.getContainer(args.id).getArchive({ path: '/' })
return { success: true, message: `Container ${args.id} export initiated` }
})
session.respond('updateContainer', async () => {
return {
success: true,
message: 'Container update initiated. Note: Some changes require container recreation.',
note: 'Most container properties cannot be updated on running containers. Consider recreating the container with new settings.',
}
})
session.respond('bulkContainerOperation', async (args) => {
const { containerIds, operation } = args
if (!Array.isArray(containerIds) || containerIds.length === 0) {
throw new Error('No containers specified')
}
if (!['start', 'stop', 'restart', 'pause', 'unpause', 'remove'].includes(operation)) {
throw new Error('Invalid operation')
}
const results = []
for (const containerId of containerIds) {
try {
const container = docker.getContainer(containerId)
switch (operation) {
case 'start':
await container.start()
break
case 'stop':
await container.stop()
break
case 'restart':
await container.restart()
break
case 'pause':
await container.pause()
break
case 'unpause':
await container.unpause()
break
case 'remove':
await container.remove({ force: true })
break
}
results.push({ id: containerId, success: true })
} catch (err) {
results.push({ id: containerId, success: false, error: err.message })
}
}
return { success: true, message: 'Bulk operation completed', results }
})
session.respond('duplicateContainer', async (args) => {
return duplicateContainer(args, session)
})
session.respond('execContainer', async (args) => {
const container = docker.getContainer(args.id)
const exec = await container.exec({
Cmd: args.cmd || ['/bin/sh'],
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: args.tty !== false,
})
const stream = await exec.start({ hijack: true, stdin: true })
const stdout = new PassThrough()
const stderr = new PassThrough()
container.modem.demuxStream(stream, stdout, stderr)
const execKey = `exec:${exec.id}`
session.state.set(execKey, { stream, exec, containerId: args.id })
stdout.on('data', (chunk) => {
session.push(Pushes.execOutput, {
type: 'execOutput',
containerId: args.id,
execId: exec.id,
data: chunk.toString('base64'),
encoding: 'base64',
})
})
stderr.on('data', (chunk) => {
session.push(Pushes.execErrorOutput, {
type: 'execErrorOutput',
containerId: args.id,
execId: exec.id,
data: chunk.toString('base64'),
encoding: 'base64',
})
})
return { success: true, message: 'Exec session started', execId: exec.id }
})
session.respond('execInput', async (args) => {
const key = `exec:${args.execId}`
const entry = session.state.get(key)
if (!entry) throw new Error('Exec session not found')
const inputData =
args.encoding === 'base64'
? Buffer.from(args.data, 'base64')
: Buffer.from(args.data || '', 'utf8')
entry.stream.write(inputData)
return { success: true }
})
}
async function duplicateContainer(args, session) {
const { name, image, hostname, netmode, cpu, memory, config } = args
const memoryInMB = memory * 1024 * 1024
const sanitizedConfig = { ...(config || {}) }
for (const key of [
'Id', 'State', 'Created', 'NetworkSettings', 'Mounts', 'Path', 'Args',
'Image', 'Hostname', 'CpuCount', 'Memory', 'CpuShares', 'CpusetCpus',
]) {
delete sanitizedConfig[key]
}
const existing = await docker.listContainers({ all: true })
if (existing.some((c) => c.Names.includes(`/${name}`))) {
throw new Error(`Container name '${name}' already exists.`)
}
const cpusetCpus = Array.from({ length: cpu }, (_, i) => i).join(',')
const nanoCpus = cpu * 1e9
const newContainer = await docker.createContainer({
...sanitizedConfig.Config,
name,
Hostname: hostname,
Image: image,
HostConfig: {
CpusetCpus: cpusetCpus.toString(),
NanoCpus: nanoCpus,
Memory: Number(memoryInMB),
MemoryReservation: Number(memoryInMB),
NetworkMode: String(netmode),
},
})
await newContainer.start()
await broadcastContainers()
return { success: true, message: `Container '${name}' duplicated and started successfully.` }
}
export async function broadcastContainers() {
try {
const containers = await docker.listContainers({ all: true })
peers.broadcast(Pushes.containers, { type: 'containers', data: containers })
} catch (err) {
logger.error('Failed to broadcast containers', { error: err.message })
}
}
+223
View File
@@ -0,0 +1,223 @@
/**
* Template / container deploy RPC handler.
*/
import { docker } from '../services/docker.js'
import * as validation from '../utils/validation.js'
import { broadcastContainers } from './containers.js'
import logger from '../utils/logger.js'
export function registerDeployHandlers(session) {
session.respond('deployContainer', async (args) => {
const containerName = validation.sanitizeString(args.containerName, 63)
if (!containerName || !validation.isValidContainerName(containerName)) {
throw new Error(
'Invalid or missing container name. Must be alphanumeric with dashes/underscores, 1-63 characters.'
)
}
args.containerName = containerName
const image = validation.sanitizeString(args.image, 255)
if (!image || !validation.isValidImageName(image)) {
throw new Error('Invalid or missing Docker image name.')
}
args.image = image
const existingContainers = await docker.listContainers({ all: true })
if (existingContainers.some((c) => c.Names.includes(`/${args.containerName}`))) {
throw new Error(`Container name '${args.containerName}' already exists.`)
}
logger.info(`Pulling Docker image: ${args.image}`)
const pullStream = await docker.pull(args.image)
await new Promise((resolve, reject) => {
docker.modem.followProgress(pullStream, (err) => (err ? reject(err) : resolve()))
})
const containerConfig = {
name: args.containerName,
Image: args.image,
}
if (args.command) containerConfig.Cmd = args.command.split(' ')
if (args.entrypoint) containerConfig.Entrypoint = args.entrypoint.split(' ')
if (args.workingDir) containerConfig.WorkingDir = args.workingDir
if (args.env && Array.isArray(args.env)) {
containerConfig.Env = args.env
.filter((e) => e.name && e.value !== undefined)
.map((e) => {
const name = validation.sanitizeEnvVarName(e.name)
const value = validation.sanitizeEnvVarValue(e.value)
return name && value !== null ? `${name}=${value}` : null
})
.filter(Boolean)
}
if (args.labels && typeof args.labels === 'object') {
containerConfig.Labels = {}
for (const [key, value] of Object.entries(args.labels)) {
const sanitizedKey = validation.sanitizeLabelKey(key)
const sanitizedValue = validation.sanitizeLabelValue(value)
if (sanitizedKey && sanitizedValue !== null) {
containerConfig.Labels[sanitizedKey] = sanitizedValue
}
}
}
if (args.hostname) {
const hostname = validation.sanitizeString(args.hostname, 253)
if (validation.isValidHostname(hostname)) containerConfig.Hostname = hostname
}
if (args.domainname) {
const domainname = validation.sanitizeString(args.domainname, 253)
if (validation.isValidHostname(domainname)) containerConfig.Domainname = domainname
}
if (args.user) containerConfig.User = args.user
if (args.healthCmd) {
containerConfig.Healthcheck = {
Test: args.healthCmd.startsWith('CMD-SHELL')
? args.healthCmd.split(' ').slice(1)
: ['CMD-SHELL', args.healthCmd],
Interval: args.healthInterval ? args.healthInterval * 1e9 : 30e9,
Timeout: args.healthTimeout ? args.healthTimeout * 1e9 : 10e9,
Retries: args.healthRetries || 3,
StartPeriod: args.healthStartPeriod ? args.healthStartPeriod * 1e9 : 0,
}
}
containerConfig.Tty = args.tty === true
containerConfig.OpenStdin = args.stdinOpen === true
containerConfig.AttachStdin = args.stdinOpen === true
containerConfig.AttachStdout = true
containerConfig.AttachStderr = true
if (args.readonlyRootfs === true) containerConfig.ReadonlyRootfs = true
const hostConfig = {
NetworkMode: args.networkMode || 'bridge',
}
if (args.ports && Array.isArray(args.ports)) {
hostConfig.PortBindings = {}
for (const portStr of args.ports) {
const sanitizedPort = validation.sanitizeString(portStr, 50)
if (!validation.isValidPortMapping(sanitizedPort)) continue
if (sanitizedPort.includes(':')) {
const [hostPort, rest] = sanitizedPort.split(':')
const [containerPort, protocol] = rest.split('/')
hostConfig.PortBindings[`${containerPort}/${protocol || 'tcp'}`] = [
{ HostPort: hostPort },
]
} else {
const [containerPort, protocol] = sanitizedPort.split('/')
hostConfig.PortBindings[`${containerPort}/${protocol || 'tcp'}`] = [
{ HostPort: containerPort },
]
}
}
}
if (args.volumes && Array.isArray(args.volumes)) {
hostConfig.Binds = args.volumes
.map((v) => validation.sanitizeString(v, 500))
.filter((v) => v && validation.isValidVolumeMount(v))
}
if (args.tmpfs && Array.isArray(args.tmpfs)) {
hostConfig.Tmpfs = {}
for (const tmpfsStr of args.tmpfs) {
const [p, ...opts] = tmpfsStr.split(':')
if (p) hostConfig.Tmpfs[p] = opts.join(':') || ''
}
}
if (args.cpuLimit) hostConfig.NanoCpus = args.cpuLimit * 1e9
if (args.cpuReservation) hostConfig.CpuQuota = args.cpuReservation * 1e9
if (args.cpuShares) hostConfig.CpuShares = args.cpuShares
if (args.memoryLimit) hostConfig.Memory = args.memoryLimit * 1024 * 1024
if (args.memoryReservation) {
hostConfig.MemoryReservation = args.memoryReservation * 1024 * 1024
}
if (args.memorySwap !== undefined && args.memorySwap !== null) {
hostConfig.MemorySwap = args.memorySwap === -1 ? -1 : args.memorySwap * 1024 * 1024
}
if (args.devices && Array.isArray(args.devices)) {
hostConfig.Devices = args.devices.map((deviceStr) => {
const parts = deviceStr.split(':')
return {
PathOnHost: parts[0],
PathInContainer: parts[1] || parts[0],
CgroupPermissions: parts[2] || 'rwm',
}
})
}
if (args.dns && Array.isArray(args.dns)) {
hostConfig.Dns = args.dns
.map((d) => validation.sanitizeString(d, 50))
.filter((d) => validation.isValidDnsServer(d))
}
if (args.extraHosts && Array.isArray(args.extraHosts)) {
hostConfig.ExtraHosts = args.extraHosts
}
if (args.restartPolicy) {
hostConfig.RestartPolicy = {
Name: args.restartPolicy,
MaximumRetryCount: args.restartMaxRetries || 0,
}
}
if (args.autoRemove === true) hostConfig.AutoRemove = true
if (args.privileged === true) hostConfig.Privileged = true
if (args.capabilities && Array.isArray(args.capabilities)) {
hostConfig.CapAdd = args.capabilities
}
if (args.securityOpts && Array.isArray(args.securityOpts)) {
hostConfig.SecurityOpt = args.securityOpts
}
if (args.sysctls && typeof args.sysctls === 'object') hostConfig.Sysctls = args.sysctls
if (args.ulimits && Array.isArray(args.ulimits)) hostConfig.Ulimits = args.ulimits
if (args.oomKillDisable === true) hostConfig.OomKillDisable = true
if (args.pidsLimit !== undefined && args.pidsLimit !== null) {
hostConfig.PidsLimit = args.pidsLimit === -1 ? 0 : args.pidsLimit
}
if (args.shmSize) hostConfig.ShmSize = args.shmSize * 1024 * 1024
if (args.init === true) hostConfig.Init = true
if (args.logDriver) {
hostConfig.LogConfig = { Type: args.logDriver, Config: args.logOpts || {} }
}
if (args.networkMode === 'container' && args.customNetwork) {
hostConfig.NetworkMode = `container:${args.customNetwork}`
}
containerConfig.HostConfig = hostConfig
logger.info('Creating container', { name: args.containerName })
const container = await docker.createContainer(containerConfig)
if (
args.customNetwork &&
args.networkMode !== 'container' &&
args.networkMode !== 'host' &&
args.networkMode !== 'none'
) {
try {
await docker.getNetwork(args.customNetwork).connect({ Container: container.id })
} catch (netErr) {
console.warn(`[WARN] Failed to connect to network ${args.customNetwork}: ${netErr.message}`)
}
}
await container.start()
logger.info('Container deployed successfully', {
name: args.containerName,
image: args.image,
})
await broadcastContainers()
return {
success: true,
message: `Container "${args.containerName}" deployed successfully from image "${args.image}"`,
}
})
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Restricted Docker CLI command execution.
*/
import { spawn } from 'child_process'
import * as validation from '../utils/validation.js'
import { Pushes } from '../../shared/protocol.js'
export function registerDockerCliHandlers(session) {
session.respond('dockerCommand', async (args) => {
const commandStr = validation.sanitizeString(args.data || args.command, 500)
if (!commandStr || !commandStr.startsWith('docker ')) {
throw new Error('Invalid command format')
}
const dangerousPatterns = ['exec', 'run', 'rm -f', 'prune', 'system prune']
if (dangerousPatterns.some((pattern) => commandStr.includes(pattern))) {
throw new Error('Command not allowed for security reasons')
}
const parts = commandStr.split(' ')
const executable = parts[0]
const cmdArgs = parts.slice(1)
if (executable !== 'docker') {
throw new Error('Only docker commands are allowed')
}
const connectionId = args.connectionId
return new Promise((resolve, reject) => {
const child = spawn(executable, cmdArgs)
let settled = false
child.stdout.on('data', (data) => {
session.push(Pushes.dockerOutput, {
type: 'dockerOutput',
connectionId,
data: data.toString('base64'),
encoding: 'base64',
})
})
child.stderr.on('data', (data) => {
session.push(Pushes.dockerOutput, {
type: 'dockerOutput',
connectionId,
data: Buffer.from(`[ERROR] ${data.toString()}`).toString('base64'),
encoding: 'base64',
})
})
child.on('close', (code) => {
session.push(Pushes.dockerOutput, {
type: 'dockerOutput',
connectionId,
data: `[INFO] Command exited with code ${code}`,
})
if (!settled) {
settled = true
resolve({ success: true, exitCode: code })
}
})
child.on('error', (err) => {
if (!settled) {
settled = true
reject(err)
}
})
})
})
session.respond('dockerTerminalResize', async () => {
// No PTY for docker CLI yet — acknowledge for UI
return { success: true }
})
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Image RPC handlers.
*/
import { docker } from '../services/docker.js'
import * as validation from '../utils/validation.js'
export function registerImageHandlers(session) {
session.respond('listImages', async () => {
const images = await docker.listImages({ all: true })
const containers = await docker.listContainers({ all: true })
const imageUsage = {}
for (const container of containers) {
const imageId = container.ImageID
if (!imageUsage[imageId]) imageUsage[imageId] = []
imageUsage[imageId].push({
id: container.Id,
name: container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12),
state: container.State,
})
}
const imagesWithUsage = images.map((image) => ({
...image,
usage: imageUsage[image.Id] || [],
}))
return { type: 'images', data: imagesWithUsage }
})
session.respond('pullImage', async (args) => {
const imageName = validation.sanitizeString(args.image, 255)
if (!imageName || !validation.isValidImageName(imageName)) {
throw new Error('Invalid image name')
}
const pullStream = await docker.pull(imageName)
await new Promise((resolve, reject) => {
docker.modem.followProgress(pullStream, (err) => (err ? reject(err) : resolve()))
})
return { success: true, message: `Image "${imageName}" pulled successfully` }
})
session.respond('removeImage', async (args) => {
await docker.getImage(args.id).remove({ force: args.force || false })
return { success: true, message: `Image ${args.id} removed` }
})
session.respond('inspectImage', async (args) => {
const imageData = await docker.getImage(args.id).inspect()
return { type: 'imageConfig', data: imageData }
})
session.respond('tagImage', async (args) => {
const repo = validation.sanitizeString(args.repo, 255)
const tag = validation.sanitizeString(args.tag || 'latest', 128)
if (!repo) throw new Error('Repository name required')
await docker.getImage(args.id).tag({ repo, tag })
return { success: true, message: `Image tagged as ${repo}:${tag}` }
})
session.respond('buildImage', async (args) => {
const { dockerfile, tag } = args
if (!dockerfile) throw new Error('Dockerfile content required')
const DockerfileBuffer = Buffer.from(dockerfile)
const tarHeader = Buffer.alloc(512)
tarHeader.write('Dockerfile', 0)
tarHeader.write('100644', 156, 6)
// ustar size field is octal ASCII at offset 124 (12 bytes)
const sizeOctal = DockerfileBuffer.length.toString(8).padStart(11, '0') + '\0'
tarHeader.write(sizeOctal, 124, 12)
let checksum = 0
for (let i = 0; i < 512; i++) {
checksum += i >= 148 && i < 156 ? 32 : tarHeader[i]
}
tarHeader.write(checksum.toString(8).padStart(6, '0') + '\0 ', 148)
const padding = (512 - (DockerfileBuffer.length % 512)) % 512
const tarData = Buffer.concat([
tarHeader,
DockerfileBuffer,
Buffer.alloc(padding),
Buffer.alloc(1024),
])
const buildOptions = tag ? { dockerfile: 'Dockerfile', t: tag } : { dockerfile: 'Dockerfile' }
const buildStream = await docker.buildImage(tarData, buildOptions)
let buildOutput = ''
await new Promise((resolve, reject) => {
docker.modem.followProgress(
buildStream,
(err, output) => {
if (err) reject(err)
else {
if (output) buildOutput = output.map((o) => o.stream || '').join('')
resolve(output)
}
},
(event) => {
if (event.stream) console.log(`[BUILD] ${event.stream.trim()}`)
}
)
})
return {
success: true,
message: `Image built successfully: ${tag || 'untagged:latest'}`,
output: buildOutput,
}
})
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Container log streaming over protomux-rpc pushes.
*/
import { docker } from '../services/docker.js'
import { Pushes } from '../../shared/protocol.js'
import logger from '../utils/logger.js'
async function startLogStream(session, streams, args) {
const containerId = args.id || args.containerId
if (!containerId) throw new Error('container id required')
if (streams.has(containerId)) {
try {
streams.get(containerId).destroy()
} catch {
// ignore
}
streams.delete(containerId)
}
const logsStream = await docker.getContainer(containerId).logs({
stdout: true,
stderr: true,
tail: args.tail ?? 100,
follow: true,
})
streams.set(containerId, logsStream)
logsStream.on('data', (chunk) => {
session.push(Pushes.logs, {
type: 'logs',
containerId,
data: chunk.toString('base64'),
encoding: 'base64',
})
})
logsStream.on('end', () => {
streams.delete(containerId)
})
logsStream.on('error', (err) => {
logger.error('Log stream error', { containerId, error: err.message })
session.push(Pushes.error, {
error: `Log stream error: ${err.message}`,
containerId,
})
streams.delete(containerId)
})
return { success: true, message: `Log stream started for ${containerId}` }
}
export function registerLogsHandlers(session) {
/** @type {Map<string, import('stream').Readable>} */
const streams = new Map()
session.state.set('logsStreams', streams)
session._cleanupLogsForContainer = (containerId) => {
for (const [key, stream] of streams.entries()) {
if (key === containerId) {
try {
stream.destroy()
} catch {
// ignore
}
streams.delete(key)
}
}
}
session.respond('startLogs', (args) => startLogStream(session, streams, args))
session.respond('logs', (args) => startLogStream(session, streams, args))
session.respond('stopLogs', async (args) => {
const containerId = args.id || args.containerId
if (containerId && streams.has(containerId)) {
try {
streams.get(containerId).destroy()
} catch {
// ignore
}
streams.delete(containerId)
}
return { success: true }
})
}
export function cleanupLogsOnClose(session) {
const streams = session.state.get('logsStreams')
if (!streams) return
for (const stream of streams.values()) {
try {
stream.destroy()
} catch {
// ignore
}
}
streams.clear()
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Network RPC handlers.
*/
import { docker } from '../services/docker.js'
import * as validation from '../utils/validation.js'
export function registerNetworkHandlers(session) {
session.respond('listNetworks', async () => {
const networks = await docker.listNetworks()
const containers = await docker.listContainers({ all: true })
const networkUsage = {}
for (const container of containers) {
const nets = container.NetworkSettings?.Networks
if (!nets) continue
for (const networkName of Object.keys(nets)) {
if (!networkUsage[networkName]) networkUsage[networkName] = []
networkUsage[networkName].push({
id: container.Id,
name: container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12),
state: container.State,
})
}
}
return {
type: 'networks',
data: networks.map((network) => ({
...network,
usage: networkUsage[network.Name] || [],
})),
}
})
session.respond('createNetwork', async (args) => {
const networkConfig = {
Name: validation.sanitizeString(args.name, 128),
Driver: args.driver || 'bridge',
CheckDuplicate: true,
}
if (args.subnet) {
networkConfig.IPAM = { Config: [{ Subnet: args.subnet }] }
}
if (args.options && typeof args.options === 'object') {
networkConfig.Options = args.options
}
const network = await docker.createNetwork(networkConfig)
return {
success: true,
message: `Network "${args.name}" created successfully`,
data: network.id,
}
})
session.respond('removeNetwork', async (args) => {
await docker.getNetwork(args.id).remove()
return { success: true, message: `Network ${args.id} removed` }
})
session.respond('inspectNetwork', async (args) => {
const networkData = await docker.getNetwork(args.id).inspect()
return { type: 'networkConfig', data: networkData }
})
session.respond('connectNetwork', async (args) => {
await docker.getNetwork(args.networkId).connect({ Container: args.containerId })
return { success: true, message: 'Container connected to network' }
})
session.respond('disconnectNetwork', async (args) => {
await docker
.getNetwork(args.networkId)
.disconnect({ Container: args.containerId, Force: args.force || false })
return { success: true, message: 'Container disconnected from network' }
})
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Docker Compose stack RPC handlers.
*/
import * as composeManager from '../utils/composeManager.js'
import * as validation from '../utils/validation.js'
import { docker } from '../services/docker.js'
import { broadcastContainers } from './containers.js'
export function registerStackHandlers(session) {
session.respond('deployStack', async (args) => {
const { composeContent, stackName } = args
if (!composeContent || !stackName) {
throw new Error('Compose content and stack name required')
}
const sanitizedStackName = validation.sanitizeString(stackName, 63)
const result = await composeManager.deployComposeStack(
docker,
composeContent,
sanitizedStackName
)
await broadcastContainers()
return { success: true, ...result }
})
session.respond('listStacks', async () => {
const stacks = await composeManager.listStacks(docker)
return { type: 'stacks', data: stacks }
})
session.respond('removeStack', async (args) => {
const result = await composeManager.removeComposeStack(docker, args.stackName)
await broadcastContainers()
return { success: true, ...result }
})
}
+82
View File
@@ -0,0 +1,82 @@
/**
* System info and host filesystem browse handlers.
*/
import fs from 'fs'
import path from 'path'
import { docker } from '../services/docker.js'
import * as validation from '../utils/validation.js'
export function registerSystemHandlers(session) {
session.respond('ping', async () => {
return { success: true, pong: Date.now() }
})
session.respond('getSystemInfo', async () => {
const [info, version] = await Promise.all([docker.info(), docker.version()])
return { type: 'systemInfo', data: { info, version } }
})
session.respond('getDockerEvents', async () => {
return {
type: 'dockerEvents',
data: [],
note: 'Real-time events are already streamed. Historical events require Docker API enhancement.',
}
})
session.respond('browseDirectory', async (args) => {
const requestedPath = args?.path || '/'
if (!validation.isValidDirectoryPath(requestedPath)) {
throw new Error('Invalid directory path')
}
const safePath = validation.sanitizeDirectoryPath(requestedPath)
try {
const stats = fs.statSync(safePath)
if (!stats.isDirectory()) {
throw new Error('Not a directory: The specified path is not a directory')
}
} catch (statError) {
if (statError.code === 'ENOENT') {
throw new Error('Directory not found: The specified path does not exist')
}
if (statError.code === 'EACCES') {
throw new Error('Permission denied: You do not have permission to access this directory')
}
throw statError
}
const contents = []
try {
const items = fs.readdirSync(safePath, { withFileTypes: true })
for (const item of items) {
try {
const itemPath = path.join(safePath, item.name)
const stats = fs.statSync(itemPath)
contents.push({
name: item.name,
type: item.isDirectory() ? 'directory' : 'file',
size: stats.size,
modified: stats.mtime.toISOString(),
permissions: stats.mode.toString(8).slice(-3),
})
} catch {
// skip unreadable entries
}
}
return { success: true, contents, path: safePath }
} catch (readError) {
if (readError.code === 'EACCES') {
throw new Error('Permission denied: You do not have permission to access this directory')
}
if (readError.code === 'ENOENT') {
throw new Error('Directory not found: The specified path does not exist')
}
if (readError.code === 'ENOTDIR') {
throw new Error('Not a directory: The specified path is not a directory')
}
throw new Error(`Failed to read directory: ${readError.message}`)
}
})
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Interactive container terminal over protomux-rpc.
*/
import { PassThrough } from 'stream'
import { docker } from '../services/docker.js'
import { Pushes } from '../../shared/protocol.js'
import logger from '../utils/logger.js'
const SESSION_KEY = 'terminal'
export function registerTerminalHandlers(session) {
session.respond('startTerminal', async (args) => {
const containerId = args.containerId
if (!containerId) throw new Error('containerId required')
// Replace existing session
endTerminal(session)
const container = docker.getContainer(containerId)
const exec = await container.exec({
Cmd: ['/bin/bash'],
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
})
const stream = await exec.start({ hijack: true, stdin: true })
const stdout = new PassThrough()
const stderr = new PassThrough()
container.modem.demuxStream(stream, stdout, stderr)
session.state.set(SESSION_KEY, { containerId, exec, stream })
stdout.on('data', (chunk) => {
session.push(Pushes.terminalOutput, {
type: 'terminalOutput',
containerId,
data: chunk.toString('base64'),
encoding: 'base64',
})
})
stderr.on('data', (chunk) => {
session.push(Pushes.terminalErrorOutput, {
type: 'terminalErrorOutput',
containerId,
data: chunk.toString('base64'),
encoding: 'base64',
})
})
logger.info('Terminal session started', { containerId, peer: session.id.slice(0, 12) })
return { success: true, message: `Terminal started for ${containerId}` }
})
session.respond('terminalInput', async (args) => {
const entry = session.state.get(SESSION_KEY)
if (!entry) throw new Error('No active terminal session')
if (args.containerId && args.containerId !== entry.containerId) {
throw new Error('Terminal session container mismatch')
}
const inputData =
args.encoding === 'base64'
? Buffer.from(args.data, 'base64')
: Buffer.from(args.data || '', 'utf8')
entry.stream.write(inputData)
return { success: true }
})
session.respond('terminalResize', async (args) => {
const entry = session.state.get(SESSION_KEY)
if (!entry) return { success: false, message: 'No terminal session' }
if (args.containerId && args.containerId !== entry.containerId) {
return { success: false, message: 'Container mismatch' }
}
const cols = Number(args.cols)
const rows = Number(args.rows)
if (cols > 0 && rows > 0) {
await entry.exec.resize({ h: rows, w: cols })
}
return { success: true }
})
session.respond('killTerminal', async (args) => {
const entry = session.state.get(SESSION_KEY)
if (entry && (!args.containerId || entry.containerId === args.containerId)) {
endTerminal(session)
return {
success: true,
message: `Terminal for container ${args.containerId || entry.containerId} killed`,
}
}
return { success: false, message: 'No terminal session found' }
})
}
export function endTerminal(session) {
const entry = session.state.get(SESSION_KEY)
if (!entry) return
try {
entry.stream.end()
} catch {
// ignore
}
session.state.delete(SESSION_KEY)
}
export function cleanupTerminalOnClose(session) {
endTerminal(session)
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Volume RPC handlers.
*/
import { docker, extractVolumesList } from '../services/docker.js'
import * as validation from '../utils/validation.js'
import { Pushes } from '../../shared/protocol.js'
import { peers } from '../core/peer-registry.js'
import logger from '../utils/logger.js'
export function registerVolumeHandlers(session) {
session.respond('listVolumes', async () => {
try {
const volumesResult = await docker.listVolumes()
const volumesList = extractVolumesList(volumesResult)
return {
type: 'volumes',
data: volumesList,
success: true,
volumes: volumesList,
}
} catch (error) {
return {
type: 'volumes',
success: false,
error: `Failed to list volumes: ${error.message}`,
data: [],
volumes: [],
}
}
})
session.respond('createVolume', async (args) => {
const volumeConfig = {
Name: validation.sanitizeString(args.name, 128),
}
if (args.driver) volumeConfig.Driver = args.driver
if (args.options && typeof args.options === 'object') {
volumeConfig.DriverOpts = args.options
}
const volume = await docker.createVolume(volumeConfig)
await broadcastVolumes()
return {
success: true,
message: `Volume "${args.name}" created successfully`,
data: volume.name,
}
})
session.respond('removeVolume', async (args) => {
await docker.getVolume(args.name).remove()
await broadcastVolumes()
return { success: true, message: `Volume ${args.name} removed` }
})
session.respond('inspectVolume', async (args) => {
const volumeData = await docker.getVolume(args.name).inspect()
return { type: 'volumeConfig', data: volumeData }
})
}
export async function broadcastVolumes() {
try {
const volumesResult = await docker.listVolumes()
const volumesList = extractVolumesList(volumesResult)
peers.broadcast(Pushes.volumes, {
type: 'volumes',
data: volumesList,
success: true,
volumes: volumesList,
})
} catch (err) {
logger.error('Failed to broadcast volumes', { error: err.message })
}
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Wire all domain handlers onto a PeerSession.
*/
import { registerContainerHandlers } from '../handlers/containers.js'
import { registerImageHandlers } from '../handlers/images.js'
import { registerNetworkHandlers } from '../handlers/networks.js'
import { registerVolumeHandlers } from '../handlers/volumes.js'
import { registerStackHandlers } from '../handlers/stacks.js'
import { registerDeployHandlers } from '../handlers/deploy.js'
import { registerTerminalHandlers, cleanupTerminalOnClose } from '../handlers/terminal.js'
import { registerLogsHandlers, cleanupLogsOnClose } from '../handlers/logs.js'
import { registerDockerCliHandlers } from '../handlers/docker-cli.js'
import { registerSystemHandlers } from '../handlers/system.js'
/**
* @param {import('./session.js').PeerSession} session
*/
export function registerAllHandlers(session) {
registerSystemHandlers(session)
registerContainerHandlers(session)
registerImageHandlers(session)
registerNetworkHandlers(session)
registerVolumeHandlers(session)
registerStackHandlers(session)
registerDeployHandlers(session)
registerTerminalHandlers(session)
registerLogsHandlers(session)
registerDockerCliHandlers(session)
}
/**
* @param {import('./session.js').PeerSession} session
*/
export function cleanupSession(session) {
cleanupTerminalOnClose(session)
cleanupLogsOnClose(session)
for (const [key, value] of session.state.entries()) {
if (key.startsWith('exec:') && value?.stream) {
try {
value.stream.end()
} catch {
// ignore
}
}
}
session.state.clear()
}
+110
View File
@@ -0,0 +1,110 @@
/**
* ProtomuxRPC session wrapping a HyperDHT secret stream.
*/
import ProtomuxRPC from 'protomux-rpc'
import b4a from 'b4a'
import { PROTOCOL } from '../../shared/protocol.js'
import { encodings } from '../../shared/encodings.js'
import rateLimiter from '../utils/rateLimiter.js'
import logger from '../utils/logger.js'
export class PeerSession {
/**
* @param {import('stream').Duplex} stream - HyperDHT / secret-stream connection
* @param {object} opts
* @param {Uint8Array} opts.serverPublicKey
* @param {(session: PeerSession) => void} [opts.onClose]
*/
constructor(stream, { serverPublicKey, onClose } = {}) {
this.stream = stream
this.id = stream.remotePublicKey
? b4a.toString(stream.remotePublicKey, 'hex')
: `anon-${Date.now()}`
this.remotePublicKey = stream.remotePublicKey
this.closed = false
this.onClose = onClose
/** @type {Map<string, any>} */
this.state = new Map()
this.rpc = new ProtomuxRPC(stream, {
id: serverPublicKey,
protocol: PROTOCOL,
...encodings,
})
this.rpc.on('close', () => this._handleClose())
this.rpc.on('destroy', () => this._handleClose())
stream.on('close', () => this._handleClose())
stream.on('error', (err) => {
logger.error('Peer stream error', { peerId: this.id.slice(0, 12), error: err.message })
})
}
/**
* Register an RPC method with rate limiting and error normalization.
* @param {string} method
* @param {(args: any, session: PeerSession) => Promise<any>|any} handler
*/
respond(method, handler) {
this.rpc.respond(method, encodings, async (args) => {
if (!rateLimiter.isAllowed(this, method)) {
const err = new Error('Rate limit exceeded. Please wait before making more requests.')
err.code = 'RATE_LIMIT_EXCEEDED'
throw err
}
try {
return await handler(args ?? {}, this)
} catch (err) {
logger.error('RPC handler failed', {
method,
peerId: this.id.slice(0, 12),
error: err.message,
})
const safe = new Error(sanitizeError(err))
safe.code = err.code || 'UNKNOWN_ERROR'
throw safe
}
})
}
/**
* Server client fire-and-forget push.
* @param {string} method
* @param {unknown} payload
*/
push(method, payload) {
if (this.closed || this.rpc.closed) return
this.rpc.event(method, payload, encodings)
}
destroy() {
if (this.closed) return
this.closed = true
try {
this.rpc.destroy()
} catch {
// ignore
}
try {
this.stream.destroy()
} catch {
// ignore
}
}
_handleClose() {
if (this.closed) return
this.closed = true
if (this.onClose) this.onClose(this)
}
}
function sanitizeError(err) {
const msg = err?.message || 'Unknown error'
if (msg.includes('ENOENT') || msg.includes('EACCES')) {
return 'Operation failed. Please check permissions and try again.'
}
if (msg.length > 200) return 'An error occurred. Please try again.'
return msg
}
+62 -2086
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
/**
* Shared Dockerode client.
*/
import Docker from 'dockerode'
import os from 'os'
const socketPath =
os.platform() === 'win32'
? '//./pipe/dockerDesktopLinuxEngine'
: '/var/run/docker.sock'
export const docker = new Docker({ socketPath })
/**
* Normalize docker.listVolumes() response shapes across API versions.
* @param {object|Array} volumesResult
* @returns {Array}
*/
export function extractVolumesList(volumesResult) {
if (Array.isArray(volumesResult)) return volumesResult
if (volumesResult?.Volumes && Array.isArray(volumesResult.Volumes)) {
return volumesResult.Volumes
}
if (volumesResult?.volumes && Array.isArray(volumesResult.volumes)) {
return volumesResult.volumes
}
return []
}
/**
* First attached network IP for a container inspect result.
* @param {object} details
* @returns {string}
*/
export function extractIpAddress(details) {
const networks = details?.NetworkSettings?.Networks
if (!networks) return 'No IP Assigned'
const list = Object.values(networks)
if (list.length > 0 && list[0].IPAddress) return list[0].IPAddress
return 'No IP Assigned'
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Docker event stream peer broadcasts.
*/
import { docker, extractVolumesList } from './docker.js'
import { peers } from '../core/peer-registry.js'
import { Pushes } from '../../shared/protocol.js'
import logger from '../utils/logger.js'
let dockerEventStream = null
export async function startDockerEventStream() {
try {
const stream = await new Promise((resolve, reject) => {
docker.getEvents({}, (err, s) => (err ? reject(err) : resolve(s)))
})
dockerEventStream = stream
stream.on('data', async (chunk) => {
try {
const event = JSON.parse(chunk.toString())
if (event.status === 'undefined') return
logger.info('Docker event', {
status: event.status,
id: event.id,
type: event.Type,
})
if (event.Type === 'container') {
const containers = await docker.listContainers({ all: true })
peers.broadcast(Pushes.containers, { type: 'containers', data: containers })
}
if (event.Type === 'volume' && (event.Action === 'create' || event.Action === 'destroy')) {
const volumesResult = await docker.listVolumes()
const volumesList = extractVolumesList(volumesResult)
peers.broadcast(Pushes.volumes, {
type: 'volumes',
data: volumesList,
success: true,
volumes: volumesList,
})
}
} catch (err) {
logger.error('Failed to process Docker event', { error: err.message })
}
})
stream.on('error', (err) => {
logger.error('Docker event stream error', { error: err.message })
})
stream.on('end', () => {
dockerEventStream = null
})
} catch (err) {
logger.error('Failed to start Docker event stream', { error: err.message })
}
}
export function stopDockerEventStream() {
if (dockerEventStream) {
try {
dockerEventStream.destroy()
} catch {
// ignore
}
dockerEventStream = null
}
}
+209
View File
@@ -0,0 +1,209 @@
/**
* Container stats collection and broadcast.
*/
import { docker, extractIpAddress } from './docker.js'
import { peers } from '../core/peer-registry.js'
import { Pushes } from '../../shared/protocol.js'
import logger from '../utils/logger.js'
const STATS_CACHE_TTL = 1000
const STATS_BROADCAST_INTERVAL = 2000
/** @type {Record<string, object>} */
const containerStats = {}
const statsCache = new Map()
const containerActivity = new Map()
let intervalHandle = null
function calculateCPUPercent(stats) {
try {
if (
!stats?.cpu_stats?.cpu_usage ||
!stats?.precpu_stats?.cpu_usage
) {
return 0.0
}
const cpuDelta =
stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage
const systemDelta =
stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage
let cpuCount = 1
if (stats.cpu_stats.online_cpus) {
cpuCount = stats.cpu_stats.online_cpus
} else if (Array.isArray(stats.cpu_stats.cpu_usage.percpu_usage)) {
cpuCount = stats.cpu_stats.cpu_usage.percpu_usage.length
}
if (systemDelta > 0 && cpuDelta > 0) {
return (cpuDelta / systemDelta) * cpuCount * 100.0
}
return 0.0
} catch {
return 0.0
}
}
function isContainerActive(statsData) {
return statsData.cpu > 1.0 || statsData.memory > 1024 * 1024
}
async function initializeContainerStats(containerInfo) {
const container = docker.getContainer(containerInfo.Id)
let ipAddress = 'No IP Assigned'
try {
const details = await container.inspect()
ipAddress = extractIpAddress(details)
} catch (err) {
logger.debug('inspect failed for stats', { id: containerInfo.Id, error: err.message })
}
const statsData = {
id: containerInfo.Id,
name: containerInfo.Names[0]?.replace(/^\//, '') || 'Unknown',
cpu: 0,
memory: 0,
ip: ipAddress,
stream: null,
}
try {
const statsStream = await container.stats({ stream: true })
statsData.stream = statsStream
statsStream.on('data', (data) => {
try {
const stats = JSON.parse(data.toString())
statsData.cpu = calculateCPUPercent(stats)
statsData.memory = stats.memory_stats?.usage || 0
} catch {
// ignore parse errors
}
})
statsStream.on('error', (err) => {
logger.error('Stats stream error', { id: containerInfo.Id, error: err.message })
})
statsStream.on('close', () => {
statsData.stream = null
})
} catch (err) {
logger.error('Failed to start stats stream', { id: containerInfo.Id, error: err.message })
}
return statsData
}
async function collectContainerStats() {
const currentContainers = await docker.listContainers({ all: true })
const currentIds = currentContainers.map((c) => c.Id)
for (const containerInfo of currentContainers) {
if (!containerStats[containerInfo.Id]) {
try {
containerStats[containerInfo.Id] = await initializeContainerStats(containerInfo)
} catch (err) {
logger.error('Failed to init stats', { id: containerInfo.Id, error: err.message })
}
}
}
for (const id of Object.keys(containerStats)) {
if (!currentIds.includes(id)) {
const statsData = containerStats[id]
if (statsData?.stream) {
try {
statsData.stream.destroy()
} catch {
// ignore
}
}
delete containerStats[id]
}
}
}
export function startStatsBroadcast() {
if (intervalHandle) return
let lastBroadcast = 0
let dockerDownLogged = false
let dockerBackoffUntil = 0
intervalHandle = setInterval(async () => {
try {
const now = Date.now()
if (now < dockerBackoffUntil) return
await collectContainerStats()
dockerDownLogged = false
if (now - lastBroadcast < STATS_BROADCAST_INTERVAL) return
if (peers.size === 0) return
const aggregatedStats = []
for (const [containerId, statsData] of Object.entries(containerStats)) {
const cached = statsCache.get(containerId)
if (
cached &&
now - cached.timestamp < STATS_CACHE_TTL &&
!isContainerActive(statsData)
) {
aggregatedStats.push(cached.data)
continue
}
if (isContainerActive(statsData)) {
containerActivity.set(containerId, now)
}
const statsObj = {
id: statsData.id,
name: statsData.name,
cpu: statsData.cpu,
memory: statsData.memory,
ip: statsData.ip,
}
statsCache.set(containerId, { data: statsObj, timestamp: now })
aggregatedStats.push(statsObj)
}
if (aggregatedStats.length > 0) {
peers.broadcast(Pushes.allStats, { type: 'allStats', data: aggregatedStats })
lastBroadcast = now
}
for (const [id, cached] of statsCache.entries()) {
if (now - cached.timestamp > STATS_CACHE_TTL * 10) statsCache.delete(id)
}
for (const [id, ts] of containerActivity.entries()) {
if (now - ts > 60000) containerActivity.delete(id)
}
} catch (err) {
const msg = err.message || ''
const dockerDown =
msg.includes('ENOENT') ||
msg.includes('ECONNREFUSED') ||
msg.includes('docker.sock')
if (dockerDown) {
dockerBackoffUntil = Date.now() + 15000
if (!dockerDownLogged) {
logger.error('Docker unavailable; stats paused', { error: msg })
dockerDownLogged = true
}
} else {
logger.error('Stats broadcast failed', { error: msg })
}
}
}, 1000)
}
export function stopStatsBroadcast() {
if (intervalHandle) {
clearInterval(intervalHandle)
intervalHandle = null
}
for (const statsData of Object.values(containerStats)) {
if (statsData?.stream) {
try {
statsData.stream.destroy()
} catch {
// ignore
}
}
}
}
+7 -1
View File
@@ -26,7 +26,13 @@ class RateLimiter {
* @returns {string} - Peer identifier
*/
getPeerId(peer) {
return peer.remotePublicKey?.toString('hex') || 'unknown';
if (peer?.id && typeof peer.id === 'string') return peer.id
if (peer?.remotePublicKey) {
return typeof peer.remotePublicKey === 'string'
? peer.remotePublicKey
: Buffer.from(peer.remotePublicKey).toString('hex')
}
return 'unknown'
}
/**
+20
View File
@@ -0,0 +1,20 @@
/**
* Value encodings for protomux-rpc.
* Uses compact-encoding JSON for structured payloads.
*/
import c from 'compact-encoding'
/** Default encoding for all peardock RPC methods and pushes. */
export const json = c.json
/** Raw buffer passthrough when needed. */
export const raw = c.raw
/** Empty / none encoding. */
export const none = c.none
export const encodings = {
valueEncoding: json,
requestEncoding: json,
responseEncoding: json,
}
+105
View File
@@ -0,0 +1,105 @@
/**
* peardock P2P protocol constants.
* Shared by the HyperDHT server and Pear client.
*/
export const PROTOCOL = 'peardock/rpc'
export const PROTOCOL_VERSION = 1
/** Request/response RPC methods (client → server). */
export const Methods = Object.freeze({
// Containers
listContainers: 'listContainers',
inspectContainer: 'inspectContainer',
startContainer: 'startContainer',
stopContainer: 'stopContainer',
restartContainer: 'restartContainer',
pauseContainer: 'pauseContainer',
unpauseContainer: 'unpauseContainer',
removeContainer: 'removeContainer',
renameContainer: 'renameContainer',
commitContainer: 'commitContainer',
exportContainer: 'exportContainer',
execContainer: 'execContainer',
duplicateContainer: 'duplicateContainer',
deployContainer: 'deployContainer',
updateContainer: 'updateContainer',
bulkContainerOperation: 'bulkContainerOperation',
// Images
listImages: 'listImages',
pullImage: 'pullImage',
removeImage: 'removeImage',
inspectImage: 'inspectImage',
buildImage: 'buildImage',
tagImage: 'tagImage',
// Networks
listNetworks: 'listNetworks',
createNetwork: 'createNetwork',
removeNetwork: 'removeNetwork',
inspectNetwork: 'inspectNetwork',
connectNetwork: 'connectNetwork',
disconnectNetwork: 'disconnectNetwork',
// Volumes
listVolumes: 'listVolumes',
createVolume: 'createVolume',
removeVolume: 'removeVolume',
inspectVolume: 'inspectVolume',
// Stacks / compose
deployStack: 'deployStack',
listStacks: 'listStacks',
removeStack: 'removeStack',
// System / host
getSystemInfo: 'getSystemInfo',
getDockerEvents: 'getDockerEvents',
browseDirectory: 'browseDirectory',
dockerCommand: 'dockerCommand',
// Terminal / streams (control plane)
startTerminal: 'startTerminal',
killTerminal: 'killTerminal',
terminalInput: 'terminalInput',
terminalResize: 'terminalResize',
startLogs: 'startLogs',
stopLogs: 'stopLogs',
execInput: 'execInput',
dockerTerminalResize: 'dockerTerminalResize',
// Health
ping: 'ping',
})
/**
* Server client push channels (registered as respond handlers on the client,
* fired with rpc.event on the server).
*/
export const Pushes = Object.freeze({
containers: 'push:containers',
volumes: 'push:volumes',
allStats: 'push:allStats',
logs: 'push:logs',
terminalOutput: 'push:terminalOutput',
terminalErrorOutput: 'push:terminalErrorOutput',
execOutput: 'push:execOutput',
execErrorOutput: 'push:execErrorOutput',
dockerOutput: 'push:dockerOutput',
error: 'push:error',
})
/** Map legacy response `type` field → push channel (for UI handlers). */
export const PushToType = Object.freeze({
[Pushes.containers]: 'containers',
[Pushes.volumes]: 'volumes',
[Pushes.allStats]: 'allStats',
[Pushes.logs]: 'logs',
[Pushes.terminalOutput]: 'terminalOutput',
[Pushes.terminalErrorOutput]: 'terminalErrorOutput',
[Pushes.execOutput]: 'execOutput',
[Pushes.execErrorOutput]: 'execErrorOutput',
[Pushes.dockerOutput]: 'dockerOutput',
[Pushes.error]: 'error',
})
+11 -11
View File
@@ -3,35 +3,35 @@ import { AppError, ErrorType, createErrorResponse, sanitizeErrorMessage } from '
test('AppError - creates error with type and code', (t) => {
const error = new AppError('Test error', ErrorType.VALIDATION, 'TEST_CODE');
t.equal(error.message, 'Test error');
t.equal(error.type, ErrorType.VALIDATION);
t.equal(error.code, 'TEST_CODE');
t.is(error.message, 'Test error');
t.is(error.type, ErrorType.VALIDATION);
t.is(error.code, 'TEST_CODE');
t.ok(error.timestamp);
});
test('AppError - toJSON serialization', (t) => {
const error = new AppError('Test error', ErrorType.NETWORK);
const json = error.toJSON();
t.equal(json.error, 'Test error');
t.equal(json.type, ErrorType.NETWORK);
t.is(json.error, 'Test error');
t.is(json.type, ErrorType.NETWORK);
t.ok(json.timestamp);
});
test('createErrorResponse - AppError', (t) => {
const error = new AppError('Test error', ErrorType.VALIDATION, 'TEST_CODE');
const response = createErrorResponse(error);
t.equal(response.error, 'Test error');
t.equal(response.code, 'TEST_CODE');
t.equal(response.type, ErrorType.VALIDATION);
t.is(response.error, 'Test error');
t.is(response.code, 'TEST_CODE');
t.is(response.type, ErrorType.VALIDATION);
});
test('createErrorResponse - standard Error', (t) => {
const error = new Error('Standard error');
error.code = 'ETIMEDOUT';
const response = createErrorResponse(error);
t.equal(response.error, 'Standard error');
t.equal(response.code, 'ETIMEDOUT');
t.equal(response.type, ErrorType.NETWORK);
t.is(response.error, 'Standard error');
t.is(response.code, 'ETIMEDOUT');
t.is(response.type, ErrorType.NETWORK);
});
test('sanitizeErrorMessage - removes sensitive info', (t) => {
+99
View File
@@ -0,0 +1,99 @@
/**
* Integration: HyperDHT + protomux-rpc protocol (no Docker required).
*/
import test from 'brittle'
import DHT from 'hyperdht'
import createTestnet from 'hyperdht/testnet.js'
import ProtomuxRPC from 'protomux-rpc'
import { PROTOCOL, Methods, Pushes } from '../shared/protocol.js'
import { encodings } from '../shared/encodings.js'
test('protomux-rpc over HyperDHT: request + push', async (t) => {
t.plan(5)
const testnet = await createTestnet()
const { bootstrap } = testnet
const serverDht = new DHT({ bootstrap })
const keyPair = DHT.keyPair()
const server = serverDht.createServer()
let pushed = false
server.on('connection', (socket) => {
const rpc = new ProtomuxRPC(socket, {
id: keyPair.publicKey,
protocol: PROTOCOL,
...encodings,
})
rpc.respond(Methods.ping, encodings, () => ({ success: true, pong: Date.now() }))
rpc.respond(Methods.listContainers, encodings, () => ({
type: 'containers',
data: [{ Id: 'abc', Names: ['/demo'] }],
}))
// After client is up, push stats (simulates server broadcast)
setTimeout(() => {
rpc.event(Pushes.allStats, {
type: 'allStats',
data: [{ id: 'abc', cpu: 1.5, memory: 1024 }],
}, encodings)
}, 50)
})
await server.listen(keyPair)
const clientDht = new DHT({ bootstrap })
const socket = clientDht.connect(keyPair.publicKey)
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('connect timeout')), 15000)
const done = () => {
clearTimeout(timer)
resolve()
}
socket.once('connect', done)
socket.once('open', done)
socket.once('error', (err) => {
clearTimeout(timer)
reject(err)
})
})
const rpc = new ProtomuxRPC(socket, {
id: keyPair.publicKey,
protocol: PROTOCOL,
...encodings,
})
rpc.respond(Pushes.allStats, encodings, (payload) => {
pushed = true
t.is(payload.type, 'allStats')
t.is(payload.data[0].id, 'abc')
return null
})
const pong = await rpc.request(Methods.ping, {}, encodings)
t.ok(pong.success)
const list = await rpc.request(Methods.listContainers, {}, encodings)
t.is(list.type, 'containers')
// Wait for push
await new Promise((r) => setTimeout(r, 200))
t.ok(pushed)
socket.destroy()
await server.close()
await serverDht.destroy()
await clientDht.destroy()
await testnet.destroy()
})
test('protocol method names are stable strings', (t) => {
t.is(Methods.listContainers, 'listContainers')
t.is(Methods.startTerminal, 'startTerminal')
t.is(Pushes.containers, 'push:containers')
t.is(PROTOCOL, 'peardock/rpc')
})
+74
View File
@@ -0,0 +1,74 @@
/**
* Full PeerSession + handler registration over HyperDHT.
*/
import test from 'brittle'
import DHT from 'hyperdht'
import createTestnet from 'hyperdht/testnet.js'
import { PeerSession } from '../server/rpc/session.js'
import { registerAllHandlers } from '../server/rpc/register.js'
import { Methods } from '../shared/protocol.js'
test('PearDockConnection + PeerSession ping round-trip', async (t) => {
const testnet = await createTestnet()
const { bootstrap } = testnet
const serverDht = new DHT({ bootstrap })
const keyPair = DHT.keyPair()
const server = serverDht.createServer()
server.on('connection', (socket) => {
const session = new PeerSession(socket, {
serverPublicKey: keyPair.publicKey,
})
registerAllHandlers(session)
})
await server.listen(keyPair)
const publicKeyHex = Buffer.from(keyPair.publicKey).toString('hex')
// Inject bootstrap into HyperDHT by temporarily using a custom connect path:
// PearDockConnection creates its own DHT; for testnet we connect manually.
const clientDht = new DHT({ bootstrap })
const socket = clientDht.connect(keyPair.publicKey)
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('timeout')), 15000)
const done = () => {
clearTimeout(timer)
resolve()
}
socket.once('connect', done)
socket.once('open', done)
socket.once('error', (e) => {
clearTimeout(timer)
reject(e)
})
})
// Use raw protomux via a temporary connection object by reusing PearDockConnection
// after monkey-patching connect to use testnet DHT is heavy — call session methods via
// a lightweight client instead:
const { default: ProtomuxRPC } = await import('protomux-rpc')
const { PROTOCOL } = await import('../shared/protocol.js')
const { encodings } = await import('../shared/encodings.js')
const rpc = new ProtomuxRPC(socket, {
id: keyPair.publicKey,
protocol: PROTOCOL,
...encodings,
})
const pong = await rpc.request(Methods.ping, {}, encodings)
t.ok(pong.success)
t.ok(pong.pong)
// browseDirectory should work without Docker
const browse = await rpc.request(Methods.browseDirectory, { path: '/' }, encodings)
t.ok(browse.success)
t.ok(Array.isArray(browse.contents))
socket.destroy()
await server.close()
await serverDht.destroy()
await clientDht.destroy()
await testnet.destroy()
})
+12 -12
View File
@@ -51,27 +51,27 @@ test('isValidVolumeMount - invalid volumes', (t) => {
});
test('sanitizeEnvVarName - valid names', (t) => {
t.equal(validation.sanitizeEnvVarName('MY_VAR'), 'MY_VAR');
t.equal(validation.sanitizeEnvVarName('_PRIVATE'), '_PRIVATE');
t.equal(validation.sanitizeEnvVarName('var123'), 'var123');
t.is(validation.sanitizeEnvVarName('MY_VAR'), 'MY_VAR');
t.is(validation.sanitizeEnvVarName('_PRIVATE'), '_PRIVATE');
t.is(validation.sanitizeEnvVarName('var123'), 'var123');
});
test('sanitizeEnvVarName - invalid names', (t) => {
t.equal(validation.sanitizeEnvVarName('123VAR'), null);
t.equal(validation.sanitizeEnvVarName('var-with-dash'), null);
t.equal(validation.sanitizeEnvVarName(''), null);
t.is(validation.sanitizeEnvVarName('123VAR'), null);
t.is(validation.sanitizeEnvVarName('var-with-dash'), null);
t.is(validation.sanitizeEnvVarName(''), null);
});
test('validateNumber - valid numbers', (t) => {
t.equal(validation.validateNumber(5, 0, 10), 5);
t.equal(validation.validateNumber('5', 0, 10), 5);
t.equal(validation.validateNumber(0, 0, 10), 0);
t.is(validation.validateNumber(5, 0, 10), 5);
t.is(validation.validateNumber('5', 0, 10), 5);
t.is(validation.validateNumber(0, 0, 10), 0);
});
test('validateNumber - invalid numbers', (t) => {
t.equal(validation.validateNumber(15, 0, 10), null);
t.equal(validation.validateNumber('invalid', 0, 10), null);
t.equal(validation.validateNumber(null, 0, 10), null);
t.is(validation.validateNumber(15, 0, 10), null);
t.is(validation.validateNumber('invalid', 0, 10), null);
t.is(validation.validateNumber(null, 0, 10), null);
});