@aliens0006/vmagent 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.
Files changed (4) hide show
  1. package/README.md +31 -0
  2. package/agent.mjs +111 -0
  3. package/cli.mjs +32 -0
  4. package/package.json +22 -0
package/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # vmagent
2
+
3
+ Lightweight authenticated HTTP command agent for a private server. It uses only Node.js built-ins.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install --global @aliens0006/vmagent
9
+ mkdir -p /etc/vmagent
10
+ vmagent init --config /etc/vmagent/config.json
11
+ ```
12
+
13
+ The `init` command generates a unique 256-bit API key and writes the config with mode `0600`. Do not commit or publish that config.
14
+
15
+ ## Run
16
+
17
+ ```bash
18
+ vmagent start --config /etc/vmagent/config.json
19
+ ```
20
+
21
+ Keep the listener on `127.0.0.1` when exposing it through Cloudflare Tunnel. The only authenticated endpoint is `POST /exec`.
22
+
23
+ ```bash
24
+ curl https://vmagent.example.com/health
25
+ curl -X POST https://vmagent.example.com/exec \
26
+ -H "Authorization: Bearer $VMAGENT_API_KEY" \
27
+ -H 'content-type: application/json' \
28
+ -d '{"command":"uname -a"}'
29
+ ```
30
+
31
+ This agent executes arbitrary shell commands as its service user. Use a dedicated non-root account whenever possible and protect the tunnel with Cloudflare Access in addition to the API key.
package/agent.mjs ADDED
@@ -0,0 +1,111 @@
1
+ import http from 'node:http';
2
+ import { spawn } from 'node:child_process';
3
+ import { appendFile } from 'node:fs/promises';
4
+ import { readFileSync } from 'node:fs';
5
+ import { dirname, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const root = dirname(fileURLToPath(import.meta.url));
9
+
10
+ export function loadConfig(path = resolve(root, 'config.json')) {
11
+ return JSON.parse(readFileSync(path, 'utf8'));
12
+ }
13
+
14
+ function json(res, status, body) {
15
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
16
+ res.end(JSON.stringify(body));
17
+ }
18
+
19
+ function log(config, message) {
20
+ if (!config.logFile) return;
21
+ appendFile(config.logFile, `[${new Date().toISOString()}] ${message}\n`).catch(() => {});
22
+ }
23
+
24
+ function authorized(req, config) {
25
+ const value = req.headers.authorization;
26
+ return typeof value === 'string' && value.startsWith('Bearer ') && value.slice(7) === config.apiKey;
27
+ }
28
+
29
+ function readBody(req, maxBytes) {
30
+ return new Promise((resolveBody, reject) => {
31
+ let body = '';
32
+ req.setEncoding('utf8');
33
+ req.on('data', chunk => {
34
+ body += chunk;
35
+ if (Buffer.byteLength(body) > maxBytes) {
36
+ reject(Object.assign(new Error('request body too large'), { code: 'BODY_TOO_LARGE' }));
37
+ req.destroy();
38
+ }
39
+ });
40
+ req.on('end', () => resolveBody(body));
41
+ req.on('error', reject);
42
+ });
43
+ }
44
+
45
+ function runCommand(command, config) {
46
+ return new Promise(resolveResult => {
47
+ const started = Date.now();
48
+ const child = spawn('/bin/sh', ['-c', command], {
49
+ env: { ...process.env, PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' },
50
+ detached: true,
51
+ });
52
+ let stdout = '';
53
+ let stderr = '';
54
+ let timedOut = false;
55
+ const append = (target, chunk) => {
56
+ const remaining = config.maxOutput - Buffer.byteLength(target);
57
+ return remaining > 0 ? target + chunk.toString('utf8').slice(0, remaining) : target;
58
+ };
59
+ child.stdout.on('data', chunk => { stdout = append(stdout, chunk); });
60
+ child.stderr.on('data', chunk => { stderr = append(stderr, chunk); });
61
+ const timer = setTimeout(() => {
62
+ timedOut = true;
63
+ try { process.kill(-child.pid, 'SIGTERM'); } catch {}
64
+ }, config.timeout);
65
+ child.on('error', error => {
66
+ clearTimeout(timer);
67
+ resolveResult({ exitCode: -1, stdout, stderr: error.message, duration: Date.now() - started, error: 'spawn_error' });
68
+ });
69
+ child.on('close', code => {
70
+ clearTimeout(timer);
71
+ resolveResult({ exitCode: code ?? -1, stdout, stderr, duration: Date.now() - started, error: timedOut ? 'timeout' : null });
72
+ });
73
+ });
74
+ }
75
+
76
+ export function createServer(config) {
77
+ let active = 0;
78
+ return http.createServer(async (req, res) => {
79
+ const path = new URL(req.url, `http://${req.headers.host || 'localhost'}`).pathname;
80
+ if (req.method === 'GET' && path === '/health') {
81
+ return json(res, 200, { status: 'ok', uptime: process.uptime() });
82
+ }
83
+ if (req.method !== 'POST' || path !== '/exec') return json(res, 404, { error: 'Not found' });
84
+ if (!authorized(req, config)) return json(res, 401, { error: 'Unauthorized' });
85
+ if (active >= config.maxConcurrent) return json(res, 429, { error: 'Too many concurrent commands' });
86
+ active += 1;
87
+ try {
88
+ let body;
89
+ try { body = JSON.parse(await readBody(req, config.maxBody)); } catch (error) {
90
+ return json(res, error.code === 'BODY_TOO_LARGE' ? 413 : 400, { error: error.code === 'BODY_TOO_LARGE' ? error.message : 'Invalid JSON' });
91
+ }
92
+ if (typeof body.command !== 'string' || !body.command.trim()) return json(res, 400, { error: 'command must be a non-empty string' });
93
+ log(config, `exec command_length=${body.command.length}`);
94
+ return json(res, 200, await runCommand(body.command, config));
95
+ } finally {
96
+ active -= 1;
97
+ }
98
+ });
99
+ }
100
+
101
+ export async function start(configPath) {
102
+ const config = loadConfig(configPath);
103
+ if (!/^[a-f0-9]{64}$/.test(config.apiKey)) throw new Error('config.apiKey must be a 64-character hex key');
104
+ const server = createServer(config);
105
+ await new Promise((resolveListen, reject) => {
106
+ server.once('error', reject);
107
+ server.listen(config.port, config.host, resolveListen);
108
+ });
109
+ log(config, `started host=${config.host} port=${config.port}`);
110
+ return server;
111
+ }
package/cli.mjs ADDED
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ import { randomBytes } from 'node:crypto';
3
+ import { chmod, mkdir, writeFile } from 'node:fs/promises';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { start } from './agent.mjs';
7
+
8
+ const root = dirname(fileURLToPath(import.meta.url));
9
+ const args = process.argv.slice(2);
10
+ const command = args[0] || 'start';
11
+ const configIndex = args.indexOf('--config');
12
+ const configPath = resolve(configIndex >= 0 ? args[configIndex + 1] : './config.json');
13
+
14
+ if (command === 'init') {
15
+ await mkdir(dirname(configPath), { recursive: true });
16
+ const apiKey = randomBytes(32).toString('hex');
17
+ const config = { host: '127.0.0.1', port: 3100, apiKey, timeout: 30000, maxOutput: 1048576, maxBody: 1048576, maxConcurrent: 2 };
18
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
19
+ await chmod(configPath, 0o600);
20
+ console.log(`Created ${configPath}`);
21
+ console.log(`API key: ${apiKey}`);
22
+ process.exit(0);
23
+ }
24
+
25
+ if (command !== 'start') {
26
+ console.error(`Usage: ${process.argv[1]} [init|start] [--config path]`);
27
+ process.exit(2);
28
+ }
29
+
30
+ const server = await start(configPath);
31
+ console.log(`vmagent listening on ${server.address().address}:${server.address().port}`);
32
+ process.on('SIGTERM', () => server.close(() => process.exit(0)));
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@aliens0006/vmagent",
3
+ "version": "0.1.0",
4
+ "description": "Lightweight authenticated HTTP command agent for private server automation",
5
+ "type": "module",
6
+ "bin": {
7
+ "vmagent": "cli.mjs"
8
+ },
9
+ "files": [
10
+ "agent.mjs",
11
+ "cli.mjs",
12
+ "package.json",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "test": "node --test test/agent.test.mjs"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "license": "MIT"
22
+ }