swarmy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # Swarm
2
+
3
+ Distributed container management with browser-based VNC and terminal access.
4
+
5
+ Provision ephemeral Docker containers across multiple nodes and interact with them through a web dashboard — full desktop GUI via VNC and interactive terminal sessions, all in the browser.
6
+
7
+ ## Architecture
8
+
9
+ ```text
10
+ ┌─────────────────────────────────────────────────────┐
11
+ │ Browser Client │
12
+ │ React + xterm.js + noVNC │
13
+ └──────────┬───────────────────────────┬──────────────┘
14
+ │ WebSocket (UI/session/VNC)│ REST API
15
+ ▼ ▼
16
+ ┌─────────────────────────────────────────────────────┐
17
+ │ Manager │
18
+ │ Express.js · SQLite · WebSocket Hub │
19
+ └──────────┬──────────────────────────────────────────┘
20
+ │ WebSocket (/ws/worker)
21
+ ┌─────┴─────┐
22
+ ▼ ▼
23
+ ┌──────────┐ ┌──────────┐
24
+ │ Worker │ │ Worker │ ...
25
+ │ Docker │ │ Docker │
26
+ │ node-pty │ │ node-pty │
27
+ └──────────┘ └──────────┘
28
+ ```
29
+
30
+ - **Manager** — Central server with React dashboard. Maintains state in SQLite, brokers WebSocket communication between workers and browser clients.
31
+ - **Worker** — Node agent on each machine. Manages Docker containers, PTY sessions, and VNC proxy connections.
32
+ - **swarm-base** — Docker image (Ubuntu 24.04) with TigerVNC, Fluxbox, noVNC, Chrome, and s6-overlay for process supervision.
33
+
34
+ ## Quick Start
35
+
36
+ ### Prerequisites
37
+
38
+ - Node.js (v24+ recommended)
39
+ - Docker
40
+ - npm
41
+
42
+ ### Manager
43
+
44
+ ```bash
45
+ cd manager
46
+ npm install
47
+ npm run dev
48
+ ```
49
+
50
+ The manager starts on port 5174. An auth token is auto-generated at `manager/data/token` on first run.
51
+
52
+ ### Worker
53
+
54
+ On each node that will run containers:
55
+
56
+ ```bash
57
+ cd worker
58
+ npm install
59
+ SWARM_URL=http://<manager-host>:5174 SWARM_TOKEN=<token> npm run dev
60
+ ```
61
+
62
+ ### Docker Image
63
+
64
+ ```bash
65
+ scripts/build.sh # Build swarm-base image (linux/amd64)
66
+ scripts/run.sh # Run a standalone container
67
+ PORT=8080 RESOLUTION=1280x720 scripts/run.sh my-browser # Custom settings
68
+ ```
69
+
70
+ ## Production Deployment
71
+
72
+ The install script handles cloning, building, and registering system services (systemd on Linux, launchd on macOS):
73
+
74
+ **Manager:**
75
+
76
+ ```bash
77
+ curl -sfL https://raw.githubusercontent.com/tkhoa87/swarm/main/install.sh | sh -
78
+ ```
79
+
80
+ **Worker:**
81
+
82
+ ```bash
83
+ curl -sfL https://raw.githubusercontent.com/tkhoa87/swarm/main/install.sh | \
84
+ SWARM_URL=http://<manager-host>:5174 SWARM_TOKEN=<token> sh -
85
+ ```
86
+
87
+ Installs to `/opt/swarm`. Services auto-restart on failure.
88
+
89
+ ## Environment Variables
90
+
91
+ | Variable | Component | Description |
92
+ | --------------- | --------- | -------------------------------------- |
93
+ | `PORT` | Manager | HTTP port (default: 3000) |
94
+ | `SWARM_URL` | Worker | Manager URL to connect to |
95
+ | `SWARM_TOKEN` | Worker | Auth token for registration |
96
+ | `SWARM_NODE_ID` | Worker | Node identifier (default: hostname) |
97
+ | `RESOLUTION` | Docker | Screen resolution (default: 1920x1080) |
98
+
99
+ ## API
100
+
101
+ All REST endpoints require `Authorization: Bearer <token>` header.
102
+
103
+ | Method | Endpoint | Description |
104
+ | -------- | ------------------------- | ----------------------------------- |
105
+ | `GET` | `/api/nodes` | List all nodes |
106
+ | `GET` | `/api/nodes/:id` | Get node with containers |
107
+ | `DELETE` | `/api/nodes/:id` | Remove node (stops containers) |
108
+ | `POST` | `/api/containers` | Create container on a node |
109
+ | `DELETE` | `/api/containers/:id` | Stop and remove container |
110
+ | `POST` | `/api/sessions` | Spawn terminal session in container |
111
+ | `DELETE` | `/api/sessions/:id` | Kill session |
112
+ | `POST` | `/api/sessions/:id/input` | Send input to session |
113
+ | `GET` | `/api/status` | Server status |
114
+
115
+ ## Tech Stack
116
+
117
+ **Manager:** Express.js, React 19, Tailwind CSS 4, xterm.js, noVNC, better-sqlite3, Vite
118
+
119
+ **Worker:** Dockerode, node-pty, ws
120
+
121
+ **Container:** Ubuntu 24.04, TigerVNC, Fluxbox, noVNC, Google Chrome, s6-overlay v3
package/bin/swarmy.js ADDED
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn, execFileSync } from "child_process";
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, openSync } from "fs";
5
+ import { join, dirname } from "path";
6
+ import { fileURLToPath } from "url";
7
+ import crypto from "crypto";
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const ROOT = join(__dirname, "..");
11
+ const DATA_DIR = join(ROOT, ".swarmy");
12
+
13
+ // ── Helpers ──────────────────────────────────────────────────
14
+
15
+ function ensureDataDir() {
16
+ mkdirSync(DATA_DIR, { recursive: true });
17
+ }
18
+
19
+ function pidFile(role) {
20
+ return join(DATA_DIR, `${role}.pid`);
21
+ }
22
+
23
+ function logFile(role) {
24
+ return join(DATA_DIR, `${role}.log`);
25
+ }
26
+
27
+ function readPid(role) {
28
+ const f = pidFile(role);
29
+ if (!existsSync(f)) return null;
30
+ const pid = parseInt(readFileSync(f, "utf-8").trim(), 10);
31
+ if (isNaN(pid)) return null;
32
+ return pid;
33
+ }
34
+
35
+ function isRunning(pid) {
36
+ try {
37
+ process.kill(pid, 0);
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ function getStatus(role) {
45
+ const pid = readPid(role);
46
+ if (!pid) return { running: false, pid: null };
47
+ if (isRunning(pid)) return { running: true, pid };
48
+ try { unlinkSync(pidFile(role)); } catch {}
49
+ return { running: false, pid: null };
50
+ }
51
+
52
+ function ensureBuilt(role) {
53
+ const distDir = join(ROOT, role, "dist");
54
+ if (!existsSync(distDir)) {
55
+ console.log(`Building ${role}...`);
56
+ const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
57
+ execFileSync(npmCmd, ["install"], { cwd: join(ROOT, role), stdio: "inherit" });
58
+ execFileSync(npmCmd, ["run", "build"], { cwd: join(ROOT, role), stdio: "inherit" });
59
+ }
60
+ }
61
+
62
+ // ── Token ────────────────────────────────────────────────────
63
+
64
+ function ensureToken() {
65
+ const tokenDir = join(ROOT, "manager", "data");
66
+ const tokenFile = join(tokenDir, "token");
67
+ mkdirSync(tokenDir, { recursive: true });
68
+ if (existsSync(tokenFile)) {
69
+ return readFileSync(tokenFile, "utf-8").trim();
70
+ }
71
+ const token = crypto.randomBytes(32).toString("hex");
72
+ writeFileSync(tokenFile, token, { mode: 0o600 });
73
+ return token;
74
+ }
75
+
76
+ // ── Start ────────────────────────────────────────────────────
77
+
78
+ function startProcess(role, args, env = {}) {
79
+ const status = getStatus(role);
80
+ if (status.running) {
81
+ console.log(`${role} is already running (PID ${status.pid})`);
82
+ return;
83
+ }
84
+
85
+ ensureDataDir();
86
+ ensureBuilt(role);
87
+
88
+ const log = logFile(role);
89
+ const out = openSync(log, "a");
90
+
91
+ const child = spawn("node", args, {
92
+ cwd: join(ROOT, role),
93
+ env: { ...process.env, ...env },
94
+ detached: true,
95
+ stdio: ["ignore", out, out],
96
+ });
97
+
98
+ writeFileSync(pidFile(role), String(child.pid));
99
+ child.unref();
100
+
101
+ console.log(`${role} started (PID ${child.pid})`);
102
+ console.log(`Log: ${log}`);
103
+ }
104
+
105
+ // ── Stop ─────────────────────────────────────────────────────
106
+
107
+ function stopProcess(role) {
108
+ const status = getStatus(role);
109
+ if (!status.running) {
110
+ console.log(`${role} is not running`);
111
+ return;
112
+ }
113
+
114
+ try {
115
+ process.kill(-status.pid, "SIGTERM");
116
+ } catch {
117
+ try { process.kill(status.pid, "SIGTERM"); } catch {}
118
+ }
119
+
120
+ try { unlinkSync(pidFile(role)); } catch {}
121
+ console.log(`${role} stopped (was PID ${status.pid})`);
122
+ }
123
+
124
+ // ── Commands ─────────────────────────────────────────────────
125
+
126
+ function showStatus(role) {
127
+ const status = getStatus(role);
128
+ if (status.running) {
129
+ console.log(`${role}: running (PID ${status.pid})`);
130
+ console.log(`Log: ${logFile(role)}`);
131
+ } else {
132
+ console.log(`${role}: stopped`);
133
+ }
134
+ }
135
+
136
+ // ── CLI ──────────────────────────────────────────────────────
137
+
138
+ const cliArgs = process.argv.slice(2);
139
+ const role = cliArgs[0];
140
+ const action = cliArgs[1];
141
+
142
+ function usage() {
143
+ console.log(`
144
+ swarmy - Distributed container management
145
+
146
+ Usage:
147
+ swarmy manager start [--port PORT]
148
+ swarmy manager stop
149
+ swarmy manager restart [--port PORT]
150
+ swarmy manager status
151
+
152
+ swarmy worker start --url URL --token TOKEN [--hostname NAME]
153
+ swarmy worker stop
154
+ swarmy worker restart --url URL --token TOKEN [--hostname NAME]
155
+ swarmy worker status
156
+ `);
157
+ process.exit(1);
158
+ }
159
+
160
+ function parseFlag(flag) {
161
+ const idx = cliArgs.indexOf(flag);
162
+ if (idx === -1 || idx + 1 >= cliArgs.length) return undefined;
163
+ return cliArgs[idx + 1];
164
+ }
165
+
166
+ if (!role || !action) usage();
167
+
168
+ function startManager() {
169
+ const port = parseFlag("--port") ?? "5174";
170
+ const token = ensureToken();
171
+ startProcess("manager", ["dist/server/index.js"], { PORT: port, NODE_ENV: "production" });
172
+ console.log(`\nManager: http://localhost:${port}`);
173
+ console.log(`Token: ${token}`);
174
+ console.log(`\nTo connect a worker:\n npx swarmy worker start --url http://<this-ip>:${port} --token ${token}`);
175
+ }
176
+
177
+ function startWorker() {
178
+ const url = parseFlag("--url");
179
+ const token = parseFlag("--token");
180
+ const hostname = parseFlag("--hostname");
181
+ if (!url || !token) {
182
+ console.error("Error: --url and --token are required");
183
+ process.exit(1);
184
+ }
185
+ const env = { SWARM_URL: url, SWARM_TOKEN: token };
186
+ if (hostname) env.HOSTNAME = hostname;
187
+ startProcess("worker", ["dist/index.js"], env);
188
+ console.log(`\nConnecting to: ${url}`);
189
+ }
190
+
191
+ if (role === "manager") {
192
+ switch (action) {
193
+ case "start":
194
+ startManager();
195
+ break;
196
+ case "stop":
197
+ stopProcess("manager");
198
+ break;
199
+ case "restart":
200
+ stopProcess("manager");
201
+ setTimeout(startManager, 1000);
202
+ break;
203
+ case "status":
204
+ showStatus("manager");
205
+ if (getStatus("manager").running) {
206
+ console.log(`Token: ${ensureToken()}`);
207
+ }
208
+ break;
209
+ default:
210
+ usage();
211
+ }
212
+ } else if (role === "worker") {
213
+ switch (action) {
214
+ case "start":
215
+ startWorker();
216
+ break;
217
+ case "stop":
218
+ stopProcess("worker");
219
+ break;
220
+ case "restart":
221
+ stopProcess("worker");
222
+ setTimeout(startWorker, 1000);
223
+ break;
224
+ case "status":
225
+ showStatus("worker");
226
+ break;
227
+ default:
228
+ usage();
229
+ }
230
+ } else {
231
+ usage();
232
+ }
package/bin/swarmy.mjs ADDED
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn, execFileSync } from "child_process";
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, openSync } from "fs";
5
+ import { join, dirname } from "path";
6
+ import { fileURLToPath } from "url";
7
+ import crypto from "crypto";
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const ROOT = join(__dirname, "..");
11
+ const DATA_DIR = join(ROOT, ".swarmy");
12
+
13
+ // ── Helpers ──────────────────────────────────────────────────
14
+
15
+ function ensureDataDir() {
16
+ mkdirSync(DATA_DIR, { recursive: true });
17
+ }
18
+
19
+ function pidFile(role) {
20
+ return join(DATA_DIR, `${role}.pid`);
21
+ }
22
+
23
+ function logFile(role) {
24
+ return join(DATA_DIR, `${role}.log`);
25
+ }
26
+
27
+ function readPid(role) {
28
+ const f = pidFile(role);
29
+ if (!existsSync(f)) return null;
30
+ const pid = parseInt(readFileSync(f, "utf-8").trim(), 10);
31
+ if (isNaN(pid)) return null;
32
+ return pid;
33
+ }
34
+
35
+ function isRunning(pid) {
36
+ try {
37
+ process.kill(pid, 0);
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ function getStatus(role) {
45
+ const pid = readPid(role);
46
+ if (!pid) return { running: false, pid: null };
47
+ if (isRunning(pid)) return { running: true, pid };
48
+ try { unlinkSync(pidFile(role)); } catch {}
49
+ return { running: false, pid: null };
50
+ }
51
+
52
+ function ensureBuilt(role) {
53
+ const distDir = join(ROOT, role, "dist");
54
+ if (!existsSync(distDir)) {
55
+ console.log(`Building ${role}...`);
56
+ const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
57
+ execFileSync(npmCmd, ["install"], { cwd: join(ROOT, role), stdio: "inherit" });
58
+ execFileSync(npmCmd, ["run", "build"], { cwd: join(ROOT, role), stdio: "inherit" });
59
+ }
60
+ }
61
+
62
+ // ── Token ────────────────────────────────────────────────────
63
+
64
+ function ensureToken() {
65
+ const tokenDir = join(ROOT, "manager", "data");
66
+ const tokenFile = join(tokenDir, "token");
67
+ mkdirSync(tokenDir, { recursive: true });
68
+ if (existsSync(tokenFile)) {
69
+ return readFileSync(tokenFile, "utf-8").trim();
70
+ }
71
+ const token = crypto.randomBytes(32).toString("hex");
72
+ writeFileSync(tokenFile, token, { mode: 0o600 });
73
+ return token;
74
+ }
75
+
76
+ // ── Start ────────────────────────────────────────────────────
77
+
78
+ function startProcess(role, args, env = {}) {
79
+ const status = getStatus(role);
80
+ if (status.running) {
81
+ console.log(`${role} is already running (PID ${status.pid})`);
82
+ return;
83
+ }
84
+
85
+ ensureDataDir();
86
+ ensureBuilt(role);
87
+
88
+ const log = logFile(role);
89
+ const out = openSync(log, "a");
90
+
91
+ const child = spawn("node", args, {
92
+ cwd: join(ROOT, role),
93
+ env: { ...process.env, ...env },
94
+ detached: true,
95
+ stdio: ["ignore", out, out],
96
+ });
97
+
98
+ writeFileSync(pidFile(role), String(child.pid));
99
+ child.unref();
100
+
101
+ console.log(`${role} started (PID ${child.pid})`);
102
+ console.log(`Log: ${log}`);
103
+ }
104
+
105
+ // ── Stop ─────────────────────────────────────────────────────
106
+
107
+ function stopProcess(role) {
108
+ const status = getStatus(role);
109
+ if (!status.running) {
110
+ console.log(`${role} is not running`);
111
+ return;
112
+ }
113
+
114
+ try {
115
+ process.kill(-status.pid, "SIGTERM");
116
+ } catch {
117
+ try { process.kill(status.pid, "SIGTERM"); } catch {}
118
+ }
119
+
120
+ try { unlinkSync(pidFile(role)); } catch {}
121
+ console.log(`${role} stopped (was PID ${status.pid})`);
122
+ }
123
+
124
+ // ── Commands ─────────────────────────────────────────────────
125
+
126
+ function showStatus(role) {
127
+ const status = getStatus(role);
128
+ if (status.running) {
129
+ console.log(`${role}: running (PID ${status.pid})`);
130
+ console.log(`Log: ${logFile(role)}`);
131
+ } else {
132
+ console.log(`${role}: stopped`);
133
+ }
134
+ }
135
+
136
+ // ── CLI ──────────────────────────────────────────────────────
137
+
138
+ const cliArgs = process.argv.slice(2);
139
+ const role = cliArgs[0];
140
+ const action = cliArgs[1];
141
+
142
+ function usage() {
143
+ console.log(`
144
+ swarmy - Distributed container management
145
+
146
+ Usage:
147
+ swarmy manager start [--port PORT]
148
+ swarmy manager stop
149
+ swarmy manager restart [--port PORT]
150
+ swarmy manager status
151
+
152
+ swarmy worker start --url URL --token TOKEN [--hostname NAME]
153
+ swarmy worker stop
154
+ swarmy worker restart --url URL --token TOKEN [--hostname NAME]
155
+ swarmy worker status
156
+ `);
157
+ process.exit(1);
158
+ }
159
+
160
+ function parseFlag(flag) {
161
+ const idx = cliArgs.indexOf(flag);
162
+ if (idx === -1 || idx + 1 >= cliArgs.length) return undefined;
163
+ return cliArgs[idx + 1];
164
+ }
165
+
166
+ if (!role || !action) usage();
167
+
168
+ function startManager() {
169
+ const port = parseFlag("--port") ?? "5174";
170
+ const token = ensureToken();
171
+ startProcess("manager", ["dist/server/index.js"], { PORT: port, NODE_ENV: "production" });
172
+ console.log(`\nManager: http://localhost:${port}`);
173
+ console.log(`Token: ${token}`);
174
+ console.log(`\nTo connect a worker:\n npx swarmy worker start --url http://<this-ip>:${port} --token ${token}`);
175
+ }
176
+
177
+ function startWorker() {
178
+ const url = parseFlag("--url");
179
+ const token = parseFlag("--token");
180
+ const hostname = parseFlag("--hostname");
181
+ if (!url || !token) {
182
+ console.error("Error: --url and --token are required");
183
+ process.exit(1);
184
+ }
185
+ const env = { SWARM_URL: url, SWARM_TOKEN: token };
186
+ if (hostname) env.HOSTNAME = hostname;
187
+ startProcess("worker", ["dist/index.js"], env);
188
+ console.log(`\nConnecting to: ${url}`);
189
+ }
190
+
191
+ if (role === "manager") {
192
+ switch (action) {
193
+ case "start":
194
+ startManager();
195
+ break;
196
+ case "stop":
197
+ stopProcess("manager");
198
+ break;
199
+ case "restart":
200
+ stopProcess("manager");
201
+ setTimeout(startManager, 1000);
202
+ break;
203
+ case "status":
204
+ showStatus("manager");
205
+ if (getStatus("manager").running) {
206
+ console.log(`Token: ${ensureToken()}`);
207
+ }
208
+ break;
209
+ default:
210
+ usage();
211
+ }
212
+ } else if (role === "worker") {
213
+ switch (action) {
214
+ case "start":
215
+ startWorker();
216
+ break;
217
+ case "stop":
218
+ stopProcess("worker");
219
+ break;
220
+ case "restart":
221
+ stopProcess("worker");
222
+ setTimeout(startWorker, 1000);
223
+ break;
224
+ case "status":
225
+ showStatus("worker");
226
+ break;
227
+ default:
228
+ usage();
229
+ }
230
+ } else {
231
+ usage();
232
+ }
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "swarm-manager",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "tsx src/server/index.ts",
8
+ "build": "vite build && tsc -p tsconfig.node.json",
9
+ "start": "node dist/server/index.js"
10
+ },
11
+ "dependencies": {
12
+ "@novnc/novnc": "^1.6.0",
13
+ "@radix-ui/react-alert-dialog": "^1.1.15",
14
+ "@xterm/addon-fit": "^0.11.0",
15
+ "@xterm/addon-web-links": "^0.12.0",
16
+ "@xterm/xterm": "^6.0.0",
17
+ "better-sqlite3": "^11.7.0",
18
+ "class-variance-authority": "^0.7.1",
19
+ "clsx": "^2.1.1",
20
+ "express": "^4.21.0",
21
+ "lucide-react": "^0.577.0",
22
+ "tailwind-merge": "^3.5.0",
23
+ "uuid": "^10.0.0",
24
+ "ws": "^8.18.0"
25
+ },
26
+ "devDependencies": {
27
+ "@tailwindcss/vite": "^4.0.0",
28
+ "@types/better-sqlite3": "^7.6.12",
29
+ "@types/express": "^5.0.0",
30
+ "@types/node": "^22.10.0",
31
+ "@types/react": "^19.0.0",
32
+ "@types/react-dom": "^19.0.0",
33
+ "@types/uuid": "^10.0.0",
34
+ "@types/ws": "^8.5.13",
35
+ "@vitejs/plugin-react": "^4.3.4",
36
+ "react": "^19.0.0",
37
+ "react-dom": "^19.0.0",
38
+ "tailwindcss": "^4.0.0",
39
+ "tsx": "^4.19.0",
40
+ "typescript": "^5.7.0",
41
+ "vite": "^6.0.0"
42
+ }
43
+ }