@opensecurity/zonzon-cli 0.1.4 → 0.1.5
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/dist/cli.js +69 -15
- package/dist/cli.test.d.ts +1 -0
- package/dist/cli.test.js +105 -0
- package/package.json +4 -4
package/dist/cli.js
CHANGED
|
@@ -4,10 +4,15 @@ import * as path from "path";
|
|
|
4
4
|
import * as os from "os";
|
|
5
5
|
import { parseArgs } from "util";
|
|
6
6
|
import { randomBytes } from "crypto";
|
|
7
|
+
import { createRequire } from "module";
|
|
7
8
|
import { DevDnsServer, DnsHandler, HttpHandler, SniProxyService, validateServerConfig, audit } from "@opensecurity/zonzon-core";
|
|
8
9
|
import { ControlPlane } from "@opensecurity/zonzon-control-plane";
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const pkg = require("../package.json");
|
|
12
|
+
const CLI_VERSION = pkg.version;
|
|
9
13
|
const CONFIG_DIR = path.join(os.homedir(), ".zonzon");
|
|
10
14
|
const DEFAULT_CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
15
|
+
const DEFAULT_SOCK_PATH = path.join(os.tmpdir(), "zonzon-cp.sock");
|
|
11
16
|
function ensureConfigDir() {
|
|
12
17
|
if (!fs.existsSync(CONFIG_DIR)) {
|
|
13
18
|
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
@@ -58,7 +63,7 @@ function setDeepValue(obj, pathStr, value) {
|
|
|
58
63
|
}
|
|
59
64
|
function printUsage() {
|
|
60
65
|
console.log(`
|
|
61
|
-
zonzon core engine (
|
|
66
|
+
zonzon core engine (v${CLI_VERSION})
|
|
62
67
|
Usage: zonzon <command> [options]
|
|
63
68
|
|
|
64
69
|
Commands:
|
|
@@ -74,12 +79,18 @@ Config Commands:
|
|
|
74
79
|
|
|
75
80
|
Global Options:
|
|
76
81
|
--config, -c Override path to configuration file (default: ~/.zonzon/config.json)
|
|
82
|
+
--json Output CLI command results in pure JSON format
|
|
77
83
|
`);
|
|
78
84
|
process.exit(0);
|
|
79
85
|
}
|
|
80
|
-
async function handleInit(configPath) {
|
|
86
|
+
async function handleInit(configPath, isJson) {
|
|
81
87
|
if (fs.existsSync(configPath)) {
|
|
82
|
-
|
|
88
|
+
if (isJson) {
|
|
89
|
+
console.log(JSON.stringify({ success: false, error: "Configuration already exists", path: configPath }));
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
audit.error(`Configuration already exists at ${configPath}`);
|
|
93
|
+
}
|
|
83
94
|
process.exit(1);
|
|
84
95
|
}
|
|
85
96
|
const defaultConf = {
|
|
@@ -91,7 +102,7 @@ async function handleInit(configPath) {
|
|
|
91
102
|
tcpIdleTimeoutMs: 30000,
|
|
92
103
|
controlPlane: {
|
|
93
104
|
enabled: true,
|
|
94
|
-
|
|
105
|
+
socketPath: DEFAULT_SOCK_PATH
|
|
95
106
|
},
|
|
96
107
|
firewall: {
|
|
97
108
|
defaultPolicy: "deny",
|
|
@@ -100,33 +111,58 @@ async function handleInit(configPath) {
|
|
|
100
111
|
hosts: {}
|
|
101
112
|
};
|
|
102
113
|
saveConfig(configPath, defaultConf);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
114
|
+
if (isJson) {
|
|
115
|
+
console.log(JSON.stringify({ success: true, action: "init", path: configPath, config: defaultConf }));
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
audit.system(`Initialized secure default configuration at ${configPath}`);
|
|
119
|
+
audit.system(`Security Notice: Default HTTP/HTTPS ports mapped to 80/443.`);
|
|
120
|
+
audit.system(`If executing within a non-root sandbox, mutate config.json to unprivileged ports (e.g. 8080/8443) to prevent EACCES binding faults.`);
|
|
121
|
+
}
|
|
106
122
|
process.exit(0);
|
|
107
123
|
}
|
|
108
|
-
async function handleConfig(configPath, args) {
|
|
124
|
+
async function handleConfig(configPath, args, isJson) {
|
|
109
125
|
const subCmd = args[0];
|
|
110
126
|
if (subCmd === "view") {
|
|
111
127
|
if (!fs.existsSync(configPath)) {
|
|
112
|
-
|
|
128
|
+
if (isJson) {
|
|
129
|
+
console.log(JSON.stringify({ success: false, error: "No configuration found. Run init first." }));
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
audit.error(`No configuration found at ${configPath}. Run 'zonzon init' first.`);
|
|
133
|
+
}
|
|
113
134
|
process.exit(1);
|
|
114
135
|
}
|
|
115
136
|
const fileContents = fs.readFileSync(configPath, "utf8");
|
|
116
|
-
|
|
137
|
+
if (isJson) {
|
|
138
|
+
console.log(JSON.stringify(JSON.parse(fileContents)));
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
console.log(fileContents);
|
|
142
|
+
}
|
|
117
143
|
process.exit(0);
|
|
118
144
|
}
|
|
119
145
|
if (subCmd === "set") {
|
|
120
146
|
const key = args[1];
|
|
121
147
|
const value = args[2];
|
|
122
148
|
if (!key || value === undefined) {
|
|
123
|
-
|
|
149
|
+
if (isJson) {
|
|
150
|
+
console.log(JSON.stringify({ success: false, error: "Missing key or value" }));
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
audit.error("Usage: zonzon config set <key> <value>");
|
|
154
|
+
}
|
|
124
155
|
process.exit(1);
|
|
125
156
|
}
|
|
126
157
|
const currentConfig = loadConfig(configPath);
|
|
127
158
|
setDeepValue(currentConfig, key, value);
|
|
128
159
|
saveConfig(configPath, currentConfig);
|
|
129
|
-
|
|
160
|
+
if (isJson) {
|
|
161
|
+
console.log(JSON.stringify({ success: true, action: "set", key, value }));
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
audit.system(`Updated configuration: ${key} = ${value}`);
|
|
165
|
+
}
|
|
130
166
|
process.exit(0);
|
|
131
167
|
}
|
|
132
168
|
printUsage();
|
|
@@ -202,11 +238,14 @@ async function startEngine(configPath, portOverride, cpPortOverride) {
|
|
|
202
238
|
}
|
|
203
239
|
const blindIndexSalt = randomBytes(16).toString("hex");
|
|
204
240
|
const cpPort = config.controlPlane?.port || 8080;
|
|
241
|
+
const socketPath = config.controlPlane?.socketPath || DEFAULT_SOCK_PATH;
|
|
205
242
|
controlPlane = new ControlPlane({
|
|
206
243
|
port: cpPort,
|
|
244
|
+
socketPath: socketPath,
|
|
207
245
|
apiKey: activeApiKey,
|
|
208
246
|
blindIndexSalt: blindIndexSalt,
|
|
209
247
|
initialConfig: config,
|
|
248
|
+
configFilePath: configPath
|
|
210
249
|
});
|
|
211
250
|
controlPlane.subscribe(async (newConfig) => {
|
|
212
251
|
audit.system("Applying dynamic configuration update from Control Plane...");
|
|
@@ -223,12 +262,22 @@ async function startEngine(configPath, portOverride, cpPortOverride) {
|
|
|
223
262
|
}
|
|
224
263
|
}
|
|
225
264
|
audit.system("Initialization complete. Awaiting connections...");
|
|
265
|
+
let shuttingDown = false;
|
|
226
266
|
const shutdown = async () => {
|
|
227
|
-
|
|
267
|
+
if (shuttingDown)
|
|
268
|
+
return;
|
|
269
|
+
shuttingDown = true;
|
|
270
|
+
audit.system("SIGINT/SIGTERM received. Initiating graceful connection draining sequence (10s bounds)...");
|
|
271
|
+
const forceExit = setTimeout(() => {
|
|
272
|
+
audit.error("Graceful drain timeout exceeded. Forcing engine termination.");
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}, 10000);
|
|
228
275
|
await daemon.stop();
|
|
229
276
|
if (controlPlane) {
|
|
230
277
|
await controlPlane.stop();
|
|
231
278
|
}
|
|
279
|
+
clearTimeout(forceExit);
|
|
280
|
+
audit.system("All boundaries offline. Process terminating cleanly.");
|
|
232
281
|
process.exit(0);
|
|
233
282
|
};
|
|
234
283
|
process.on("SIGINT", shutdown);
|
|
@@ -248,6 +297,10 @@ async function main() {
|
|
|
248
297
|
"cp-port": {
|
|
249
298
|
type: "string",
|
|
250
299
|
},
|
|
300
|
+
json: {
|
|
301
|
+
type: "boolean",
|
|
302
|
+
default: false,
|
|
303
|
+
}
|
|
251
304
|
},
|
|
252
305
|
strict: false,
|
|
253
306
|
allowPositionals: true
|
|
@@ -256,12 +309,13 @@ async function main() {
|
|
|
256
309
|
const configPath = values.config
|
|
257
310
|
? path.resolve(process.cwd(), values.config)
|
|
258
311
|
: DEFAULT_CONFIG_PATH;
|
|
312
|
+
const isJson = values.json;
|
|
259
313
|
switch (command) {
|
|
260
314
|
case "init":
|
|
261
|
-
await handleInit(configPath);
|
|
315
|
+
await handleInit(configPath, isJson);
|
|
262
316
|
break;
|
|
263
317
|
case "config":
|
|
264
|
-
await handleConfig(configPath, positionals.slice(1));
|
|
318
|
+
await handleConfig(configPath, positionals.slice(1), isJson);
|
|
265
319
|
break;
|
|
266
320
|
case "start":
|
|
267
321
|
await startEngine(configPath, values.port, values["cp-port"]);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.test.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { exec } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { promises as fs } from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
const PROJECT_ROOT = process.cwd();
|
|
10
|
+
const CLI_PATH = path.resolve(PROJECT_ROOT, "packages/cli/src/cli.ts");
|
|
11
|
+
async function runCli(args) {
|
|
12
|
+
try {
|
|
13
|
+
const { stdout, stderr } = await execAsync(`npx tsx ${CLI_PATH} ${args.join(" ")}`);
|
|
14
|
+
return { stdout, stderr, code: 0 };
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
return { stdout: error.stdout || "", stderr: error.stderr || "", code: error.code || 1 };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
describe("CLI Integration Tests", () => {
|
|
21
|
+
let tempDir;
|
|
22
|
+
let testConfigPath;
|
|
23
|
+
before(async () => {
|
|
24
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zonzon-cli-test-"));
|
|
25
|
+
testConfigPath = path.join(tempDir, "config.json");
|
|
26
|
+
});
|
|
27
|
+
after(async () => {
|
|
28
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
it("fails to run without a command and prints usage", async () => {
|
|
31
|
+
const { stdout, code } = await runCli([]);
|
|
32
|
+
assert.strictEqual(code, 0);
|
|
33
|
+
assert.ok(stdout.includes("Usage: zonzon <command>"));
|
|
34
|
+
});
|
|
35
|
+
it("initializes a new configuration file", async () => {
|
|
36
|
+
const { stdout, code } = await runCli(["init", "--config", testConfigPath, "--json"]);
|
|
37
|
+
assert.strictEqual(code, 0);
|
|
38
|
+
const output = JSON.parse(stdout);
|
|
39
|
+
assert.strictEqual(output.success, true);
|
|
40
|
+
assert.strictEqual(output.action, "init");
|
|
41
|
+
const fileContent = await fs.readFile(testConfigPath, "utf8");
|
|
42
|
+
const config = JSON.parse(fileContent);
|
|
43
|
+
assert.strictEqual(config.port, 53);
|
|
44
|
+
assert.strictEqual(config.controlPlane.enabled, true);
|
|
45
|
+
});
|
|
46
|
+
it("refuses to overwrite an existing configuration", async () => {
|
|
47
|
+
const { stdout, code } = await runCli(["init", "--config", testConfigPath, "--json"]);
|
|
48
|
+
assert.strictEqual(code, 1);
|
|
49
|
+
const output = JSON.parse(stdout);
|
|
50
|
+
assert.strictEqual(output.success, false);
|
|
51
|
+
assert.ok(output.error.includes("already exists"));
|
|
52
|
+
});
|
|
53
|
+
it("views the current configuration", async () => {
|
|
54
|
+
const { stdout, code } = await runCli(["config", "view", "--config", testConfigPath, "--json"]);
|
|
55
|
+
assert.strictEqual(code, 0);
|
|
56
|
+
const config = JSON.parse(stdout);
|
|
57
|
+
assert.strictEqual(config.port, 53);
|
|
58
|
+
assert.strictEqual(config.httpPort, 80);
|
|
59
|
+
});
|
|
60
|
+
it("mutates a top level configuration value using numeric casting", async () => {
|
|
61
|
+
const { stdout, code } = await runCli(["config", "set", "port", "5353", "--config", testConfigPath, "--json"]);
|
|
62
|
+
assert.strictEqual(code, 0);
|
|
63
|
+
const output = JSON.parse(stdout);
|
|
64
|
+
assert.strictEqual(output.success, true);
|
|
65
|
+
const { stdout: viewOut } = await runCli(["config", "view", "--config", testConfigPath, "--json"]);
|
|
66
|
+
const config = JSON.parse(viewOut);
|
|
67
|
+
assert.strictEqual(config.port, 5353);
|
|
68
|
+
});
|
|
69
|
+
it("mutates a nested configuration value using boolean casting", async () => {
|
|
70
|
+
const { code } = await runCli(["config", "set", "controlPlane.enabled", "false", "--config", testConfigPath, "--json"]);
|
|
71
|
+
assert.strictEqual(code, 0);
|
|
72
|
+
const fileContent = await fs.readFile(testConfigPath, "utf8");
|
|
73
|
+
const config = JSON.parse(fileContent);
|
|
74
|
+
assert.strictEqual(config.controlPlane.enabled, false);
|
|
75
|
+
});
|
|
76
|
+
it("mutates a nested configuration value with standard strings", async () => {
|
|
77
|
+
const { code } = await runCli(["config", "set", "fallbackDns", "8.8.8.8", "--config", testConfigPath, "--json"]);
|
|
78
|
+
assert.strictEqual(code, 0);
|
|
79
|
+
const fileContent = await fs.readFile(testConfigPath, "utf8");
|
|
80
|
+
const config = JSON.parse(fileContent);
|
|
81
|
+
assert.strictEqual(config.fallbackDns, "8.8.8.8");
|
|
82
|
+
});
|
|
83
|
+
it("creates nested objects natively if they do not exist", async () => {
|
|
84
|
+
const { code } = await runCli(["config", "set", "firewall.allowlist_ips.0", "10.0.0.1", "--config", testConfigPath, "--json"]);
|
|
85
|
+
assert.strictEqual(code, 0);
|
|
86
|
+
const fileContent = await fs.readFile(testConfigPath, "utf8");
|
|
87
|
+
const config = JSON.parse(fileContent);
|
|
88
|
+
assert.strictEqual(config.firewall.allowlist_ips[0], "10.0.0.1");
|
|
89
|
+
});
|
|
90
|
+
it("fails to view configuration if file is missing", async () => {
|
|
91
|
+
const missingPath = path.join(tempDir, "missing.json");
|
|
92
|
+
const { stdout, code } = await runCli(["config", "view", "--config", missingPath, "--json"]);
|
|
93
|
+
assert.strictEqual(code, 1);
|
|
94
|
+
const output = JSON.parse(stdout);
|
|
95
|
+
assert.strictEqual(output.success, false);
|
|
96
|
+
assert.ok(output.error.includes("No configuration found"));
|
|
97
|
+
});
|
|
98
|
+
it("fails to set configuration if parameters are missing", async () => {
|
|
99
|
+
const { stdout, code } = await runCli(["config", "set", "port", "--config", testConfigPath, "--json"]);
|
|
100
|
+
assert.strictEqual(code, 1);
|
|
101
|
+
const output = JSON.parse(stdout);
|
|
102
|
+
assert.strictEqual(output.success, false);
|
|
103
|
+
assert.ok(output.error.includes("Missing key or value"));
|
|
104
|
+
});
|
|
105
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opensecurity/zonzon-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "cli interface for zonzon",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Lucian BLETAN <neuraluc@gmail.com>",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"dev:watch": "NODE_OPTIONS=--disable-warning=DEP0205 tsx watch src/cli.ts start --config ../../config/hosts.json"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@opensecurity/zonzon-core": "^0.1.
|
|
35
|
-
"@opensecurity/zonzon-control-plane": "^0.1.
|
|
34
|
+
"@opensecurity/zonzon-core": "^0.1.5",
|
|
35
|
+
"@opensecurity/zonzon-control-plane": "^0.1.5"
|
|
36
36
|
}
|
|
37
|
-
}
|
|
37
|
+
}
|