@opensecurity/zonzon-control-plane 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/domain/config/config.handler.d.ts +2 -0
- package/dist/domain/config/config.handler.js +56 -28
- package/dist/domain/config/config.schema.d.ts +4 -17
- package/dist/domain/config/config.schema.js +1 -5
- package/dist/domain/config/config.service.d.ts +4 -4
- package/dist/domain/config/config.service.js +13 -3
- package/dist/domain/config/config.test.js +37 -10
- package/dist/domain/config/context.d.ts +4 -0
- package/dist/domain/config/context.js +9 -0
- package/dist/domain/config/context.test.d.ts +1 -0
- package/dist/domain/config/context.test.js +34 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +18 -5
- package/package.json +3 -3
|
@@ -4,6 +4,8 @@ export declare class ConfigHandler {
|
|
|
4
4
|
private service;
|
|
5
5
|
private blindIndexSalt;
|
|
6
6
|
private expectedApiKeyHash;
|
|
7
|
+
private seenNonces;
|
|
8
|
+
private currentPoWWindow;
|
|
7
9
|
constructor(service: ConfigService, rawApiKey: string, blindIndexSalt: string);
|
|
8
10
|
private readBodyStrict;
|
|
9
11
|
private verifyProofOfWork;
|
|
@@ -1,40 +1,54 @@
|
|
|
1
|
-
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual, hkdfSync } from "crypto";
|
|
2
2
|
import { audit } from "@opensecurity/zonzon-core";
|
|
3
3
|
import { ApiAuthHeaderSchema } from "./config.schema.js";
|
|
4
|
+
import { contextStorage } from "./context.js";
|
|
4
5
|
export class ConfigHandler {
|
|
5
6
|
service;
|
|
6
7
|
blindIndexSalt;
|
|
7
8
|
expectedApiKeyHash;
|
|
9
|
+
seenNonces = new Set();
|
|
10
|
+
currentPoWWindow = 0;
|
|
8
11
|
constructor(service, rawApiKey, blindIndexSalt) {
|
|
9
12
|
this.service = service;
|
|
10
13
|
this.blindIndexSalt = blindIndexSalt;
|
|
11
|
-
|
|
14
|
+
const apiKeySecret = hkdfSync("sha256", this.blindIndexSalt, Buffer.alloc(0), "api_key_derivation", 32);
|
|
15
|
+
this.expectedApiKeyHash = createHmac("sha256", Buffer.from(apiKeySecret)).update(rawApiKey).digest("hex");
|
|
12
16
|
}
|
|
13
17
|
readBodyStrict(req) {
|
|
14
18
|
return new Promise((resolve, reject) => {
|
|
15
|
-
|
|
19
|
+
const chunks = [];
|
|
16
20
|
let length = 0;
|
|
17
21
|
req.on("data", (chunk) => {
|
|
18
22
|
length += chunk.length;
|
|
19
23
|
if (length > 1048576) {
|
|
24
|
+
req.destroy();
|
|
20
25
|
reject(new Error("Payload size limit exceeded"));
|
|
21
26
|
return;
|
|
22
27
|
}
|
|
23
|
-
|
|
28
|
+
chunks.push(chunk);
|
|
24
29
|
});
|
|
25
|
-
req.on("end", () => resolve(
|
|
30
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
26
31
|
req.on("error", reject);
|
|
27
32
|
});
|
|
28
33
|
}
|
|
29
34
|
verifyProofOfWork(nonce) {
|
|
30
|
-
const
|
|
35
|
+
const timeWindow = Math.floor(Date.now() / 300000);
|
|
36
|
+
if (this.currentPoWWindow !== timeWindow) {
|
|
37
|
+
this.currentPoWWindow = timeWindow;
|
|
38
|
+
this.seenNonces.clear();
|
|
39
|
+
}
|
|
40
|
+
if (this.seenNonces.has(nonce)) {
|
|
41
|
+
throw new Error("Proof of Work challenge nonce already used");
|
|
42
|
+
}
|
|
43
|
+
const challenge = `${this.blindIndexSalt}:${timeWindow}`;
|
|
44
|
+
const hash = createHash("sha256").update(challenge + nonce).digest("hex");
|
|
31
45
|
if (!hash.startsWith("0000")) {
|
|
32
46
|
throw new Error("Invalid Proof of Work Challenge");
|
|
33
47
|
}
|
|
48
|
+
this.seenNonces.add(nonce);
|
|
34
49
|
}
|
|
35
50
|
extractContext(req, isMutation) {
|
|
36
51
|
const rawHeaders = {
|
|
37
|
-
authorization: req.headers.authorization,
|
|
38
52
|
"x-api-key": req.headers["x-api-key"],
|
|
39
53
|
"x-device-id": req.headers["x-device-id"],
|
|
40
54
|
"x-pow-nonce": req.headers["x-pow-nonce"],
|
|
@@ -49,7 +63,8 @@ export class ConfigHandler {
|
|
|
49
63
|
if (!providedKey) {
|
|
50
64
|
throw new Error("Missing API Key");
|
|
51
65
|
}
|
|
52
|
-
const
|
|
66
|
+
const apiKeySecret = hkdfSync("sha256", this.blindIndexSalt, Buffer.alloc(0), "api_key_derivation", 32);
|
|
67
|
+
const providedHash = createHmac("sha256", Buffer.from(apiKeySecret)).update(providedKey).digest("hex");
|
|
53
68
|
const expectedBuffer = Buffer.from(this.expectedApiKeyHash, "utf8");
|
|
54
69
|
const providedBuffer = Buffer.from(providedHash, "utf8");
|
|
55
70
|
if (expectedBuffer.length !== providedBuffer.length || !timingSafeEqual(expectedBuffer, providedBuffer)) {
|
|
@@ -61,7 +76,8 @@ export class ConfigHandler {
|
|
|
61
76
|
}
|
|
62
77
|
this.verifyProofOfWork(validatedHeaders["x-pow-nonce"]);
|
|
63
78
|
}
|
|
64
|
-
const
|
|
79
|
+
const deviceSecret = hkdfSync("sha256", this.blindIndexSalt, Buffer.alloc(0), "device_id_derivation", 32);
|
|
80
|
+
const deviceHash = createHmac("sha256", Buffer.from(deviceSecret)).update(validatedHeaders["x-device-id"]).digest("hex");
|
|
65
81
|
return {
|
|
66
82
|
tenantId: "system-tenant-001",
|
|
67
83
|
deviceHash: deviceHash,
|
|
@@ -71,6 +87,16 @@ export class ConfigHandler {
|
|
|
71
87
|
const clientIp = req.socket.remoteAddress || "unknown";
|
|
72
88
|
const method = req.method || "GET";
|
|
73
89
|
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
90
|
+
if (url.pathname === "/metrics") {
|
|
91
|
+
if (method !== "GET") {
|
|
92
|
+
res.writeHead(405);
|
|
93
|
+
res.end();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4" });
|
|
97
|
+
res.end(audit.getMetricsPrometheus());
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
74
100
|
if (url.pathname !== "/api/v1/config") {
|
|
75
101
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
76
102
|
res.end(JSON.stringify({ error: "Endpoint Not Found" }));
|
|
@@ -79,24 +105,26 @@ export class ConfigHandler {
|
|
|
79
105
|
try {
|
|
80
106
|
const isMutation = method === "PUT" || method === "POST";
|
|
81
107
|
const context = this.extractContext(req, isMutation);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
await contextStorage.run(context, async () => {
|
|
109
|
+
if (method === "GET") {
|
|
110
|
+
const config = await this.service.getConfig();
|
|
111
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
112
|
+
res.end(JSON.stringify(config));
|
|
113
|
+
audit.http(clientIp, "GET", "control-plane", url.pathname, 200, "Config retrieved");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (method === "PUT") {
|
|
117
|
+
const bodyStr = await this.readBodyStrict(req);
|
|
118
|
+
const rawConfig = JSON.parse(bodyStr);
|
|
119
|
+
await this.service.updateConfig(rawConfig);
|
|
120
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
121
|
+
res.end(JSON.stringify({ success: true, timestamp: Date.now() }));
|
|
122
|
+
audit.http(clientIp, "PUT", "control-plane", url.pathname, 200, "Config updated");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
126
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
127
|
+
});
|
|
100
128
|
}
|
|
101
129
|
catch (error) {
|
|
102
130
|
const message = error.message || "Internal Server Error";
|
|
@@ -107,7 +135,7 @@ export class ConfigHandler {
|
|
|
107
135
|
else if (message.includes("Authentication Validation Failed") || message.includes("Unauthorized") || message.includes("API Key") || message.includes("x-pow-nonce")) {
|
|
108
136
|
statusCode = 401;
|
|
109
137
|
}
|
|
110
|
-
else if (message.includes("Invalid Proof of Work")) {
|
|
138
|
+
else if (message.includes("Invalid Proof of Work") || message.includes("already used")) {
|
|
111
139
|
statusCode = 403;
|
|
112
140
|
}
|
|
113
141
|
audit.error(`Control Plane API Fault: HTTP ${statusCode} | ${message} | Client: ${clientIp}`);
|
|
@@ -1,27 +1,14 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
export declare const ApiAuthHeaderSchema: z.
|
|
3
|
-
|
|
4
|
-
"x-api-key": z.ZodOptional<z.ZodString>;
|
|
2
|
+
export declare const ApiAuthHeaderSchema: z.ZodObject<{
|
|
3
|
+
"x-api-key": z.ZodString;
|
|
5
4
|
"x-device-id": z.ZodString;
|
|
6
5
|
"x-pow-nonce": z.ZodOptional<z.ZodString>;
|
|
7
6
|
}, "strip", z.ZodTypeAny, {
|
|
7
|
+
"x-api-key": string;
|
|
8
8
|
"x-device-id": string;
|
|
9
|
-
authorization?: string | undefined;
|
|
10
|
-
"x-api-key"?: string | undefined;
|
|
11
9
|
"x-pow-nonce"?: string | undefined;
|
|
12
10
|
}, {
|
|
11
|
+
"x-api-key": string;
|
|
13
12
|
"x-device-id": string;
|
|
14
|
-
authorization?: string | undefined;
|
|
15
|
-
"x-api-key"?: string | undefined;
|
|
16
|
-
"x-pow-nonce"?: string | undefined;
|
|
17
|
-
}>, {
|
|
18
|
-
"x-device-id": string;
|
|
19
|
-
authorization?: string | undefined;
|
|
20
|
-
"x-api-key"?: string | undefined;
|
|
21
|
-
"x-pow-nonce"?: string | undefined;
|
|
22
|
-
}, {
|
|
23
|
-
"x-device-id": string;
|
|
24
|
-
authorization?: string | undefined;
|
|
25
|
-
"x-api-key"?: string | undefined;
|
|
26
13
|
"x-pow-nonce"?: string | undefined;
|
|
27
14
|
}>;
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export const ApiAuthHeaderSchema = z.object({
|
|
3
|
-
|
|
4
|
-
"x-api-key": z.string().min(32).max(128).optional(),
|
|
3
|
+
"x-api-key": z.string().min(32).max(128),
|
|
5
4
|
"x-device-id": z.string().min(16).max(64),
|
|
6
5
|
"x-pow-nonce": z.string().min(1).max(64).optional()
|
|
7
|
-
}).refine(data => data.authorization || data["x-api-key"], {
|
|
8
|
-
message: "Missing authentication credentials",
|
|
9
|
-
path: ["x-api-key"]
|
|
10
6
|
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { ServerConfig } from "@opensecurity/zonzon-core";
|
|
2
|
-
import { ConfigContext } from "./config.types.js";
|
|
3
2
|
export declare class ConfigService {
|
|
4
3
|
private config;
|
|
4
|
+
private configFilePath;
|
|
5
5
|
private lock;
|
|
6
6
|
private subscribers;
|
|
7
|
-
constructor(initialConfig: ServerConfig);
|
|
7
|
+
constructor(initialConfig: ServerConfig, configFilePath: string);
|
|
8
8
|
subscribe(callback: (config: ServerConfig) => void): void;
|
|
9
|
-
getConfig(
|
|
10
|
-
updateConfig(
|
|
9
|
+
getConfig(): Promise<ServerConfig>;
|
|
10
|
+
updateConfig(rawConfig: unknown): Promise<void>;
|
|
11
11
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
1
3
|
import { validateServerConfig, audit } from "@opensecurity/zonzon-core";
|
|
4
|
+
import { getContext } from "./context.js";
|
|
2
5
|
class PessimisticLock {
|
|
3
6
|
locked = false;
|
|
4
7
|
queue = [];
|
|
@@ -22,27 +25,34 @@ class PessimisticLock {
|
|
|
22
25
|
}
|
|
23
26
|
export class ConfigService {
|
|
24
27
|
config;
|
|
28
|
+
configFilePath;
|
|
25
29
|
lock = new PessimisticLock();
|
|
26
30
|
subscribers = [];
|
|
27
|
-
constructor(initialConfig) {
|
|
31
|
+
constructor(initialConfig, configFilePath) {
|
|
28
32
|
this.config = validateServerConfig(initialConfig);
|
|
33
|
+
this.configFilePath = path.resolve(configFilePath);
|
|
29
34
|
}
|
|
30
35
|
subscribe(callback) {
|
|
31
36
|
this.subscribers.push(callback);
|
|
32
37
|
}
|
|
33
|
-
async getConfig(
|
|
38
|
+
async getConfig() {
|
|
39
|
+
const ctx = getContext();
|
|
34
40
|
if (!ctx.tenantId) {
|
|
35
41
|
throw new Error("Security Exception: Missing Tenant Context");
|
|
36
42
|
}
|
|
37
43
|
return this.config;
|
|
38
44
|
}
|
|
39
|
-
async updateConfig(
|
|
45
|
+
async updateConfig(rawConfig) {
|
|
46
|
+
const ctx = getContext();
|
|
40
47
|
if (!ctx.tenantId) {
|
|
41
48
|
throw new Error("Security Exception: Missing Tenant Context");
|
|
42
49
|
}
|
|
43
50
|
await this.lock.acquire();
|
|
44
51
|
try {
|
|
45
52
|
const validatedConfig = validateServerConfig(rawConfig);
|
|
53
|
+
const tempPath = `${this.configFilePath}.tmp.${Date.now()}`;
|
|
54
|
+
await fs.writeFile(tempPath, JSON.stringify(validatedConfig, null, 2), { mode: 0o600, encoding: "utf8" });
|
|
55
|
+
await fs.rename(tempPath, this.configFilePath);
|
|
46
56
|
this.config = validatedConfig;
|
|
47
57
|
for (const callback of this.subscribers) {
|
|
48
58
|
try {
|
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
import { describe, it, before, after } from "node:test";
|
|
2
2
|
import assert from "node:assert";
|
|
3
3
|
import * as http from "http";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { promises as fs } from "node:fs";
|
|
4
7
|
import { createHash } from "crypto";
|
|
5
8
|
import { ConfigService } from "./config.service.js";
|
|
6
9
|
import { ConfigHandler } from "./config.handler.js";
|
|
7
10
|
const MOCK_SALT = "test-salt-1234567890";
|
|
8
11
|
const MOCK_API_KEY = "test-api-key-very-long-and-secure-32-chars";
|
|
9
12
|
const MOCK_DEVICE_ID = "test-device-id-1234";
|
|
13
|
+
let globalNonceCounter = 0;
|
|
10
14
|
function generateValidPoW(salt) {
|
|
11
|
-
|
|
15
|
+
const timeWindow = Math.floor(Date.now() / 300000);
|
|
16
|
+
const challenge = `${salt}:${timeWindow}`;
|
|
12
17
|
while (true) {
|
|
13
|
-
const hash = createHash("sha256").update(
|
|
18
|
+
const hash = createHash("sha256").update(challenge + globalNonceCounter.toString()).digest("hex");
|
|
14
19
|
if (hash.startsWith("0000")) {
|
|
15
|
-
|
|
20
|
+
const validNonce = globalNonceCounter.toString();
|
|
21
|
+
globalNonceCounter++;
|
|
22
|
+
return validNonce;
|
|
16
23
|
}
|
|
17
|
-
|
|
24
|
+
globalNonceCounter++;
|
|
18
25
|
}
|
|
19
26
|
}
|
|
20
27
|
describe("Control Plane API Tests", () => {
|
|
21
28
|
let server;
|
|
22
29
|
let port;
|
|
23
30
|
let validNonce;
|
|
31
|
+
let tempConfigPath;
|
|
24
32
|
const initialConfig = {
|
|
25
33
|
port: 53,
|
|
26
34
|
hosts: {
|
|
@@ -28,8 +36,10 @@ describe("Control Plane API Tests", () => {
|
|
|
28
36
|
}
|
|
29
37
|
};
|
|
30
38
|
before(async () => {
|
|
39
|
+
tempConfigPath = path.join(os.tmpdir(), `hosts-test-${Date.now()}.json`);
|
|
40
|
+
await fs.writeFile(tempConfigPath, JSON.stringify(initialConfig), { mode: 0o600 });
|
|
31
41
|
validNonce = generateValidPoW(MOCK_SALT);
|
|
32
|
-
const service = new ConfigService(initialConfig);
|
|
42
|
+
const service = new ConfigService(initialConfig, tempConfigPath);
|
|
33
43
|
const handler = new ConfigHandler(service, MOCK_API_KEY, MOCK_SALT);
|
|
34
44
|
server = http.createServer((req, res) => handler.handleRequest(req, res));
|
|
35
45
|
await new Promise((resolve) => {
|
|
@@ -39,8 +49,12 @@ describe("Control Plane API Tests", () => {
|
|
|
39
49
|
});
|
|
40
50
|
});
|
|
41
51
|
});
|
|
42
|
-
after(() => {
|
|
52
|
+
after(async () => {
|
|
43
53
|
server.close();
|
|
54
|
+
try {
|
|
55
|
+
await fs.unlink(tempConfigPath);
|
|
56
|
+
}
|
|
57
|
+
catch { }
|
|
44
58
|
});
|
|
45
59
|
function makeRequest(method, path, headers, body) {
|
|
46
60
|
return new Promise((resolve, reject) => {
|
|
@@ -63,7 +77,14 @@ describe("Control Plane API Tests", () => {
|
|
|
63
77
|
}
|
|
64
78
|
});
|
|
65
79
|
});
|
|
66
|
-
req.on("error",
|
|
80
|
+
req.on("error", (err) => {
|
|
81
|
+
if (err.code === 'ECONNRESET') {
|
|
82
|
+
resolve({ status: 413, data: { error: "Payload too large (Socket Destroyed)" } });
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
reject(err);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
67
88
|
if (body)
|
|
68
89
|
req.write(body);
|
|
69
90
|
req.end();
|
|
@@ -103,17 +124,18 @@ describe("Control Plane API Tests", () => {
|
|
|
103
124
|
assert.strictEqual(res.status, 403);
|
|
104
125
|
assert.ok(res.data.error.includes("Invalid Proof of Work"));
|
|
105
126
|
});
|
|
106
|
-
it("accepts PUT requests with valid configuration and PoW", async () => {
|
|
127
|
+
it("accepts PUT requests with valid configuration and PoW, and writes to disk", async () => {
|
|
107
128
|
const newConfig = {
|
|
108
129
|
port: 5353,
|
|
109
130
|
hosts: {
|
|
110
131
|
"updated.loop": { records: [{ type: "A", address: "8.8.8.8" }] }
|
|
111
132
|
}
|
|
112
133
|
};
|
|
134
|
+
const freshNonce = generateValidPoW(MOCK_SALT);
|
|
113
135
|
const res = await makeRequest("PUT", "/api/v1/config", {
|
|
114
136
|
"x-api-key": MOCK_API_KEY,
|
|
115
137
|
"x-device-id": MOCK_DEVICE_ID,
|
|
116
|
-
"x-pow-nonce":
|
|
138
|
+
"x-pow-nonce": freshNonce,
|
|
117
139
|
"Content-Type": "application/json"
|
|
118
140
|
}, JSON.stringify(newConfig));
|
|
119
141
|
assert.strictEqual(res.status, 200);
|
|
@@ -125,13 +147,18 @@ describe("Control Plane API Tests", () => {
|
|
|
125
147
|
assert.strictEqual(checkRes.status, 200);
|
|
126
148
|
assert.strictEqual(checkRes.data.port, 5353);
|
|
127
149
|
assert.ok("updated.loop" in checkRes.data.hosts);
|
|
150
|
+
const diskContent = await fs.readFile(tempConfigPath, "utf8");
|
|
151
|
+
const parsedDisk = JSON.parse(diskContent);
|
|
152
|
+
assert.strictEqual(parsedDisk.port, 5353);
|
|
153
|
+
assert.ok("updated.loop" in parsedDisk.hosts);
|
|
128
154
|
});
|
|
129
155
|
it("rejects payloads exceeding the memory boundary", async () => {
|
|
130
156
|
const hugePayload = JSON.stringify({ port: 53, hosts: {} }) + " ".repeat(1.5 * 1024 * 1024);
|
|
157
|
+
const boundaryNonce = generateValidPoW(MOCK_SALT);
|
|
131
158
|
const res = await makeRequest("PUT", "/api/v1/config", {
|
|
132
159
|
"x-api-key": MOCK_API_KEY,
|
|
133
160
|
"x-device-id": MOCK_DEVICE_ID,
|
|
134
|
-
"x-pow-nonce":
|
|
161
|
+
"x-pow-nonce": boundaryNonce,
|
|
135
162
|
"Content-Type": "application/json"
|
|
136
163
|
}, hugePayload);
|
|
137
164
|
assert.strictEqual(res.status, 413);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
export const contextStorage = new AsyncLocalStorage();
|
|
3
|
+
export function getContext() {
|
|
4
|
+
const store = contextStorage.getStore();
|
|
5
|
+
if (!store) {
|
|
6
|
+
throw new Error("Security Exception: Context missing. Ensure request is wrapped in middleware.");
|
|
7
|
+
}
|
|
8
|
+
return store;
|
|
9
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { getContext, contextStorage } from "./context.js";
|
|
4
|
+
describe("AsyncLocalStorage Context Boundary", () => {
|
|
5
|
+
it("throws error when accessing context outside of storage run", () => {
|
|
6
|
+
assert.throws(() => getContext(), /Security Exception: Context missing/);
|
|
7
|
+
});
|
|
8
|
+
it("successfully retrieves injected context payload", () => {
|
|
9
|
+
const mockContext = {
|
|
10
|
+
tenantId: "test-tenant-001",
|
|
11
|
+
deviceHash: "deadbeef"
|
|
12
|
+
};
|
|
13
|
+
contextStorage.run(mockContext, () => {
|
|
14
|
+
const ctx = getContext();
|
|
15
|
+
assert.strictEqual(ctx.tenantId, "test-tenant-001");
|
|
16
|
+
assert.strictEqual(ctx.deviceHash, "deadbeef");
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
it("maintains isolation across asynchronous boundaries", async () => {
|
|
20
|
+
const contextA = { tenantId: "tenant-A", deviceHash: "hash-A" };
|
|
21
|
+
const contextB = { tenantId: "tenant-B", deviceHash: "hash-B" };
|
|
22
|
+
const promiseA = contextStorage.run(contextA, async () => {
|
|
23
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
24
|
+
const ctx = getContext();
|
|
25
|
+
assert.strictEqual(ctx.tenantId, "tenant-A");
|
|
26
|
+
});
|
|
27
|
+
const promiseB = contextStorage.run(contextB, async () => {
|
|
28
|
+
await new Promise(resolve => setTimeout(resolve, 5));
|
|
29
|
+
const ctx = getContext();
|
|
30
|
+
assert.strictEqual(ctx.tenantId, "tenant-B");
|
|
31
|
+
});
|
|
32
|
+
await Promise.all([promiseA, promiseB]);
|
|
33
|
+
});
|
|
34
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { ServerConfig } from "@opensecurity/zonzon-core";
|
|
2
2
|
export interface ControlPlaneOptions {
|
|
3
|
-
port
|
|
3
|
+
port?: number;
|
|
4
|
+
socketPath?: string;
|
|
4
5
|
apiKey: string;
|
|
5
6
|
blindIndexSalt: string;
|
|
6
7
|
initialConfig: ServerConfig;
|
|
8
|
+
configFilePath: string;
|
|
7
9
|
}
|
|
8
10
|
export declare class ControlPlane {
|
|
9
11
|
private service;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as http from "http";
|
|
2
|
+
import * as fs from "node:fs";
|
|
2
3
|
import { audit } from "@opensecurity/zonzon-core";
|
|
3
4
|
import { ConfigService } from "./domain/config/config.service.js";
|
|
4
5
|
import { ConfigHandler } from "./domain/config/config.handler.js";
|
|
@@ -8,7 +9,7 @@ export class ControlPlane {
|
|
|
8
9
|
options;
|
|
9
10
|
constructor(options) {
|
|
10
11
|
this.options = options;
|
|
11
|
-
this.service = new ConfigService(options.initialConfig);
|
|
12
|
+
this.service = new ConfigService(options.initialConfig, options.configFilePath);
|
|
12
13
|
}
|
|
13
14
|
subscribe(callback) {
|
|
14
15
|
this.service.subscribe(callback);
|
|
@@ -26,10 +27,22 @@ export class ControlPlane {
|
|
|
26
27
|
});
|
|
27
28
|
});
|
|
28
29
|
this.server.on("error", reject);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
if (this.options.socketPath) {
|
|
31
|
+
if (fs.existsSync(this.options.socketPath)) {
|
|
32
|
+
fs.unlinkSync(this.options.socketPath);
|
|
33
|
+
}
|
|
34
|
+
this.server.listen(this.options.socketPath, () => {
|
|
35
|
+
fs.chmodSync(this.options.socketPath, 0o600);
|
|
36
|
+
audit.system(`Control Plane locked strictly to Unix Domain Socket at ${this.options.socketPath}`);
|
|
37
|
+
resolve();
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
this.server.listen(this.options.port || 8080, "127.0.0.1", () => {
|
|
42
|
+
audit.system(`Control Plane locked strictly to loopback interface on port ${this.options.port || 8080}`);
|
|
43
|
+
resolve();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
33
46
|
});
|
|
34
47
|
}
|
|
35
48
|
async stop() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opensecurity/zonzon-control-plane",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "dynamic configuration api for the core engine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Lucian BLETAN <neuraluc@gmail.com>",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"build": "tsc -b"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@opensecurity/zonzon-core": "^0.1.
|
|
31
|
+
"@opensecurity/zonzon-core": "^0.1.5",
|
|
32
32
|
"zod": "^3.24.2"
|
|
33
33
|
}
|
|
34
|
-
}
|
|
34
|
+
}
|