@opensecurity/zonzon-cli 0.1.3 → 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 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 (v0.1.0)
66
+ zonzon core engine (v${CLI_VERSION})
62
67
  Usage: zonzon <command> [options]
63
68
 
64
69
  Commands:
@@ -74,22 +79,30 @@ 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
- audit.error(`Configuration already exists at ${configPath}`);
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 = {
86
97
  port: 53,
98
+ httpPort: 80,
99
+ httpsPort: 443,
87
100
  fallbackDns: "1.1.1.1",
88
101
  maxTcpConnections: 100,
89
102
  tcpIdleTimeoutMs: 30000,
90
103
  controlPlane: {
91
104
  enabled: true,
92
- port: 8080
105
+ socketPath: DEFAULT_SOCK_PATH
93
106
  },
94
107
  firewall: {
95
108
  defaultPolicy: "deny",
@@ -98,35 +111,101 @@ async function handleInit(configPath) {
98
111
  hosts: {}
99
112
  };
100
113
  saveConfig(configPath, defaultConf);
101
- audit.system(`Initialized secure default configuration at ${configPath}`);
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
+ }
102
122
  process.exit(0);
103
123
  }
104
- async function handleConfig(configPath, args) {
124
+ async function handleConfig(configPath, args, isJson) {
105
125
  const subCmd = args[0];
106
126
  if (subCmd === "view") {
107
127
  if (!fs.existsSync(configPath)) {
108
- audit.error(`No configuration found at ${configPath}. Run 'zonzon init' first.`);
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
+ }
109
134
  process.exit(1);
110
135
  }
111
136
  const fileContents = fs.readFileSync(configPath, "utf8");
112
- console.log(fileContents);
137
+ if (isJson) {
138
+ console.log(JSON.stringify(JSON.parse(fileContents)));
139
+ }
140
+ else {
141
+ console.log(fileContents);
142
+ }
113
143
  process.exit(0);
114
144
  }
115
145
  if (subCmd === "set") {
116
146
  const key = args[1];
117
147
  const value = args[2];
118
148
  if (!key || value === undefined) {
119
- audit.error("Usage: zonzon config set <key> <value>");
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
+ }
120
155
  process.exit(1);
121
156
  }
122
157
  const currentConfig = loadConfig(configPath);
123
158
  setDeepValue(currentConfig, key, value);
124
159
  saveConfig(configPath, currentConfig);
125
- audit.system(`Updated configuration: ${key} = ${value}`);
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
+ }
126
166
  process.exit(0);
127
167
  }
128
168
  printUsage();
129
169
  }
170
+ class ZonzonDaemon {
171
+ dnsHandler = null;
172
+ httpHandler = null;
173
+ sniProxy = null;
174
+ async start(config) {
175
+ try {
176
+ const dnsServer = new DevDnsServer(config);
177
+ this.dnsHandler = new DnsHandler(dnsServer, config);
178
+ await this.dnsHandler.start();
179
+ audit.system(`DNS Listener actively enforcing Zero-Trust boundaries on port ${config.port}`);
180
+ this.httpHandler = new HttpHandler(dnsServer, config, config.httpPort ?? 80);
181
+ await this.httpHandler.start();
182
+ audit.system(`HTTP L7 Sandbox Router active on port ${config.httpPort ?? 80}`);
183
+ this.sniProxy = new SniProxyService(config, config.httpsPort ?? 443);
184
+ await this.sniProxy.start();
185
+ audit.system(`SNI Proxy active on port ${config.httpsPort ?? 443}`);
186
+ }
187
+ catch (err) {
188
+ audit.error(`Fatal bind error during initialization: ${err.message}`);
189
+ await this.stop();
190
+ process.exit(1);
191
+ }
192
+ }
193
+ async stop() {
194
+ if (this.dnsHandler) {
195
+ await this.dnsHandler.stop();
196
+ this.dnsHandler = null;
197
+ }
198
+ if (this.httpHandler) {
199
+ await this.httpHandler.stop();
200
+ this.httpHandler = null;
201
+ }
202
+ if (this.sniProxy) {
203
+ await this.sniProxy.stop();
204
+ this.sniProxy = null;
205
+ }
206
+ audit.system("Subsystems halted. Sockets closed.");
207
+ }
208
+ }
130
209
  async function startEngine(configPath, portOverride, cpPortOverride) {
131
210
  const rawConfig = loadConfig(configPath);
132
211
  if (portOverride) {
@@ -145,10 +224,8 @@ async function startEngine(configPath, portOverride, cpPortOverride) {
145
224
  audit.error(`Configuration Schema Violation: ${err.message}`);
146
225
  process.exit(1);
147
226
  }
148
- const dnsServer = new DevDnsServer(config);
149
- const dnsHandler = new DnsHandler(dnsServer, config);
150
- const httpHandler = new HttpHandler(dnsServer, config, 80);
151
- const sniProxy = new SniProxyService(config, 443);
227
+ const daemon = new ZonzonDaemon();
228
+ await daemon.start(config);
152
229
  const isCpEnabled = config.controlPlane?.enabled !== false;
153
230
  let controlPlane = null;
154
231
  let isEphemeralKey = false;
@@ -161,51 +238,50 @@ async function startEngine(configPath, portOverride, cpPortOverride) {
161
238
  }
162
239
  const blindIndexSalt = randomBytes(16).toString("hex");
163
240
  const cpPort = config.controlPlane?.port || 8080;
241
+ const socketPath = config.controlPlane?.socketPath || DEFAULT_SOCK_PATH;
164
242
  controlPlane = new ControlPlane({
165
243
  port: cpPort,
244
+ socketPath: socketPath,
166
245
  apiKey: activeApiKey,
167
246
  blindIndexSalt: blindIndexSalt,
168
247
  initialConfig: config,
248
+ configFilePath: configPath
169
249
  });
170
- controlPlane.subscribe((newConfig) => {
250
+ controlPlane.subscribe(async (newConfig) => {
171
251
  audit.system("Applying dynamic configuration update from Control Plane...");
252
+ await daemon.stop();
253
+ await daemon.start(newConfig);
172
254
  });
255
+ await controlPlane.start();
256
+ if (isEphemeralKey) {
257
+ audit.system(`[SECURITY] Generated Ephemeral API Key for this session: ${activeApiKey}`);
258
+ audit.system(`[SECURITY] Do not lose this key. It will not be shown again.`);
259
+ }
260
+ else {
261
+ audit.system(`[SECURITY] Control Plane using static API Key from configuration.`);
262
+ }
173
263
  }
264
+ audit.system("Initialization complete. Awaiting connections...");
265
+ let shuttingDown = false;
174
266
  const shutdown = async () => {
175
- audit.system("Initiating graceful shutdown sequence...");
176
- await dnsHandler.stop();
177
- await httpHandler.stop();
178
- await sniProxy.stop();
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);
275
+ await daemon.stop();
179
276
  if (controlPlane) {
180
277
  await controlPlane.stop();
181
278
  }
279
+ clearTimeout(forceExit);
280
+ audit.system("All boundaries offline. Process terminating cleanly.");
182
281
  process.exit(0);
183
282
  };
184
283
  process.on("SIGINT", shutdown);
185
284
  process.on("SIGTERM", shutdown);
186
- try {
187
- await dnsHandler.start();
188
- audit.system(`DNS Listener actively enforcing Zero-Trust boundaries on port ${config.port}`);
189
- await httpHandler.start();
190
- audit.system(`HTTP L7 Sandbox Router active on port 80`);
191
- await sniProxy.start();
192
- audit.system(`SNI Proxy active on port 443`);
193
- if (controlPlane) {
194
- await controlPlane.start();
195
- if (isEphemeralKey) {
196
- audit.system(`[SECURITY] Generated Ephemeral API Key for this session: ${activeApiKey}`);
197
- audit.system(`[SECURITY] Do not lose this key. It will not be shown again.`);
198
- }
199
- else {
200
- audit.system(`[SECURITY] Control Plane using static API Key from configuration.`);
201
- }
202
- }
203
- audit.system("Initialization complete. Awaiting connections...");
204
- }
205
- catch (err) {
206
- audit.error(`Fatal bind error during initialization: ${err.message}`);
207
- await shutdown();
208
- }
209
285
  }
210
286
  async function main() {
211
287
  const { values, positionals } = parseArgs({
@@ -221,6 +297,10 @@ async function main() {
221
297
  "cp-port": {
222
298
  type: "string",
223
299
  },
300
+ json: {
301
+ type: "boolean",
302
+ default: false,
303
+ }
224
304
  },
225
305
  strict: false,
226
306
  allowPositionals: true
@@ -229,12 +309,13 @@ async function main() {
229
309
  const configPath = values.config
230
310
  ? path.resolve(process.cwd(), values.config)
231
311
  : DEFAULT_CONFIG_PATH;
312
+ const isJson = values.json;
232
313
  switch (command) {
233
314
  case "init":
234
- await handleInit(configPath);
315
+ await handleInit(configPath, isJson);
235
316
  break;
236
317
  case "config":
237
- await handleConfig(configPath, positionals.slice(1));
318
+ await handleConfig(configPath, positionals.slice(1), isJson);
238
319
  break;
239
320
  case "start":
240
321
  await startEngine(configPath, values.port, values["cp-port"]);
@@ -0,0 +1 @@
1
+ export {};
@@ -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",
3
+ "version": "0.1.5",
4
4
  "description": "cli interface for zonzon",
5
5
  "type": "module",
6
6
  "author": "Lucian BLETAN <neuraluc@gmail.com>",
@@ -28,10 +28,10 @@
28
28
  "scripts": {
29
29
  "build": "tsc -b",
30
30
  "start": "node dist/cli.js",
31
- "dev": "tsx watch src/cli.ts"
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.3",
35
- "@opensecurity/zonzon-control-plane": "^0.1.3"
34
+ "@opensecurity/zonzon-core": "^0.1.5",
35
+ "@opensecurity/zonzon-control-plane": "^0.1.5"
36
36
  }
37
- }
37
+ }