@opensecurity/zonzon-control-plane 0.1.2 → 0.1.3
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 +12 -0
- package/dist/domain/config/config.handler.js +118 -0
- package/dist/domain/config/config.schema.d.ts +27 -0
- package/dist/domain/config/config.schema.js +10 -0
- package/dist/domain/config/config.service.d.ts +11 -0
- package/dist/domain/config/config.service.js +60 -0
- package/dist/domain/config/config.test.d.ts +1 -0
- package/dist/domain/config/config.test.js +139 -0
- package/dist/domain/config/config.types.d.ts +14 -0
- package/dist/domain/config/config.types.js +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +43 -0
- package/package.json +5 -2
- package/src/domain/config/config.handler.ts +0 -141
- package/src/domain/config/config.schema.ts +0 -11
- package/src/domain/config/config.service.ts +0 -66
- package/src/domain/config/config.test.ts +0 -158
- package/src/domain/config/config.types.ts +0 -18
- package/src/index.ts +0 -58
- package/tsconfig.json +0 -12
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as http from "http";
|
|
2
|
+
import { ConfigService } from "./config.service.js";
|
|
3
|
+
export declare class ConfigHandler {
|
|
4
|
+
private service;
|
|
5
|
+
private blindIndexSalt;
|
|
6
|
+
private expectedApiKeyHash;
|
|
7
|
+
constructor(service: ConfigService, rawApiKey: string, blindIndexSalt: string);
|
|
8
|
+
private readBodyStrict;
|
|
9
|
+
private verifyProofOfWork;
|
|
10
|
+
private extractContext;
|
|
11
|
+
handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
|
2
|
+
import { audit } from "@opensecurity/zonzon-core";
|
|
3
|
+
import { ApiAuthHeaderSchema } from "./config.schema.js";
|
|
4
|
+
export class ConfigHandler {
|
|
5
|
+
service;
|
|
6
|
+
blindIndexSalt;
|
|
7
|
+
expectedApiKeyHash;
|
|
8
|
+
constructor(service, rawApiKey, blindIndexSalt) {
|
|
9
|
+
this.service = service;
|
|
10
|
+
this.blindIndexSalt = blindIndexSalt;
|
|
11
|
+
this.expectedApiKeyHash = createHmac("sha256", this.blindIndexSalt).update(rawApiKey).digest("hex");
|
|
12
|
+
}
|
|
13
|
+
readBodyStrict(req) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
let body = "";
|
|
16
|
+
let length = 0;
|
|
17
|
+
req.on("data", (chunk) => {
|
|
18
|
+
length += chunk.length;
|
|
19
|
+
if (length > 1048576) {
|
|
20
|
+
reject(new Error("Payload size limit exceeded"));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
body += chunk.toString("utf8");
|
|
24
|
+
});
|
|
25
|
+
req.on("end", () => resolve(body));
|
|
26
|
+
req.on("error", reject);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
verifyProofOfWork(nonce) {
|
|
30
|
+
const hash = createHash("sha256").update(this.blindIndexSalt + nonce).digest("hex");
|
|
31
|
+
if (!hash.startsWith("0000")) {
|
|
32
|
+
throw new Error("Invalid Proof of Work Challenge");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
extractContext(req, isMutation) {
|
|
36
|
+
const rawHeaders = {
|
|
37
|
+
authorization: req.headers.authorization,
|
|
38
|
+
"x-api-key": req.headers["x-api-key"],
|
|
39
|
+
"x-device-id": req.headers["x-device-id"],
|
|
40
|
+
"x-pow-nonce": req.headers["x-pow-nonce"],
|
|
41
|
+
};
|
|
42
|
+
const parsed = ApiAuthHeaderSchema.safeParse(rawHeaders);
|
|
43
|
+
if (!parsed.success) {
|
|
44
|
+
const issueString = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ');
|
|
45
|
+
throw new Error(`Authentication Validation Failed (${issueString})`);
|
|
46
|
+
}
|
|
47
|
+
const validatedHeaders = parsed.data;
|
|
48
|
+
const providedKey = validatedHeaders["x-api-key"] || "";
|
|
49
|
+
if (!providedKey) {
|
|
50
|
+
throw new Error("Missing API Key");
|
|
51
|
+
}
|
|
52
|
+
const providedHash = createHmac("sha256", this.blindIndexSalt).update(providedKey).digest("hex");
|
|
53
|
+
const expectedBuffer = Buffer.from(this.expectedApiKeyHash, "utf8");
|
|
54
|
+
const providedBuffer = Buffer.from(providedHash, "utf8");
|
|
55
|
+
if (expectedBuffer.length !== providedBuffer.length || !timingSafeEqual(expectedBuffer, providedBuffer)) {
|
|
56
|
+
throw new Error("Unauthorized Access");
|
|
57
|
+
}
|
|
58
|
+
if (isMutation) {
|
|
59
|
+
if (!validatedHeaders["x-pow-nonce"]) {
|
|
60
|
+
throw new Error("Mutation endpoint requires x-pow-nonce header");
|
|
61
|
+
}
|
|
62
|
+
this.verifyProofOfWork(validatedHeaders["x-pow-nonce"]);
|
|
63
|
+
}
|
|
64
|
+
const deviceHash = createHmac("sha256", this.blindIndexSalt).update(validatedHeaders["x-device-id"]).digest("hex");
|
|
65
|
+
return {
|
|
66
|
+
tenantId: "system-tenant-001",
|
|
67
|
+
deviceHash: deviceHash,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
async handleRequest(req, res) {
|
|
71
|
+
const clientIp = req.socket.remoteAddress || "unknown";
|
|
72
|
+
const method = req.method || "GET";
|
|
73
|
+
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
74
|
+
if (url.pathname !== "/api/v1/config") {
|
|
75
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
76
|
+
res.end(JSON.stringify({ error: "Endpoint Not Found" }));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const isMutation = method === "PUT" || method === "POST";
|
|
81
|
+
const context = this.extractContext(req, isMutation);
|
|
82
|
+
if (method === "GET") {
|
|
83
|
+
const config = await this.service.getConfig(context);
|
|
84
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
85
|
+
res.end(JSON.stringify(config));
|
|
86
|
+
audit.http(clientIp, "GET", "control-plane", url.pathname, 200, "Config retrieved");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (method === "PUT") {
|
|
90
|
+
const bodyStr = await this.readBodyStrict(req);
|
|
91
|
+
const rawConfig = JSON.parse(bodyStr);
|
|
92
|
+
await this.service.updateConfig(context, rawConfig);
|
|
93
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
94
|
+
res.end(JSON.stringify({ success: true, timestamp: Date.now() }));
|
|
95
|
+
audit.http(clientIp, "PUT", "control-plane", url.pathname, 200, "Config updated");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
99
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
const message = error.message || "Internal Server Error";
|
|
103
|
+
let statusCode = 400;
|
|
104
|
+
if (message.includes("Payload size limit")) {
|
|
105
|
+
statusCode = 413;
|
|
106
|
+
}
|
|
107
|
+
else if (message.includes("Authentication Validation Failed") || message.includes("Unauthorized") || message.includes("API Key") || message.includes("x-pow-nonce")) {
|
|
108
|
+
statusCode = 401;
|
|
109
|
+
}
|
|
110
|
+
else if (message.includes("Invalid Proof of Work")) {
|
|
111
|
+
statusCode = 403;
|
|
112
|
+
}
|
|
113
|
+
audit.error(`Control Plane API Fault: HTTP ${statusCode} | ${message} | Client: ${clientIp}`);
|
|
114
|
+
res.writeHead(statusCode, { "Content-Type": "application/json" });
|
|
115
|
+
res.end(JSON.stringify({ error: message }));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const ApiAuthHeaderSchema: z.ZodEffects<z.ZodObject<{
|
|
3
|
+
authorization: z.ZodOptional<z.ZodString>;
|
|
4
|
+
"x-api-key": z.ZodOptional<z.ZodString>;
|
|
5
|
+
"x-device-id": z.ZodString;
|
|
6
|
+
"x-pow-nonce": z.ZodOptional<z.ZodString>;
|
|
7
|
+
}, "strip", z.ZodTypeAny, {
|
|
8
|
+
"x-device-id": string;
|
|
9
|
+
authorization?: string | undefined;
|
|
10
|
+
"x-api-key"?: string | undefined;
|
|
11
|
+
"x-pow-nonce"?: string | undefined;
|
|
12
|
+
}, {
|
|
13
|
+
"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
|
+
"x-pow-nonce"?: string | undefined;
|
|
27
|
+
}>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const ApiAuthHeaderSchema = z.object({
|
|
3
|
+
authorization: z.string().regex(/^Bearer [a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+$/).optional(),
|
|
4
|
+
"x-api-key": z.string().min(32).max(128).optional(),
|
|
5
|
+
"x-device-id": z.string().min(16).max(64),
|
|
6
|
+
"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
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ServerConfig } from "@opensecurity/zonzon-core";
|
|
2
|
+
import { ConfigContext } from "./config.types.js";
|
|
3
|
+
export declare class ConfigService {
|
|
4
|
+
private config;
|
|
5
|
+
private lock;
|
|
6
|
+
private subscribers;
|
|
7
|
+
constructor(initialConfig: ServerConfig);
|
|
8
|
+
subscribe(callback: (config: ServerConfig) => void): void;
|
|
9
|
+
getConfig(ctx: ConfigContext): Promise<ServerConfig>;
|
|
10
|
+
updateConfig(ctx: ConfigContext, rawConfig: unknown): Promise<void>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { validateServerConfig, audit } from "@opensecurity/zonzon-core";
|
|
2
|
+
class PessimisticLock {
|
|
3
|
+
locked = false;
|
|
4
|
+
queue = [];
|
|
5
|
+
acquire() {
|
|
6
|
+
if (!this.locked) {
|
|
7
|
+
this.locked = true;
|
|
8
|
+
return Promise.resolve();
|
|
9
|
+
}
|
|
10
|
+
return new Promise(resolve => this.queue.push(resolve));
|
|
11
|
+
}
|
|
12
|
+
release() {
|
|
13
|
+
if (this.queue.length > 0) {
|
|
14
|
+
const next = this.queue.shift();
|
|
15
|
+
if (next)
|
|
16
|
+
next();
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
this.locked = false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class ConfigService {
|
|
24
|
+
config;
|
|
25
|
+
lock = new PessimisticLock();
|
|
26
|
+
subscribers = [];
|
|
27
|
+
constructor(initialConfig) {
|
|
28
|
+
this.config = validateServerConfig(initialConfig);
|
|
29
|
+
}
|
|
30
|
+
subscribe(callback) {
|
|
31
|
+
this.subscribers.push(callback);
|
|
32
|
+
}
|
|
33
|
+
async getConfig(ctx) {
|
|
34
|
+
if (!ctx.tenantId) {
|
|
35
|
+
throw new Error("Security Exception: Missing Tenant Context");
|
|
36
|
+
}
|
|
37
|
+
return this.config;
|
|
38
|
+
}
|
|
39
|
+
async updateConfig(ctx, rawConfig) {
|
|
40
|
+
if (!ctx.tenantId) {
|
|
41
|
+
throw new Error("Security Exception: Missing Tenant Context");
|
|
42
|
+
}
|
|
43
|
+
await this.lock.acquire();
|
|
44
|
+
try {
|
|
45
|
+
const validatedConfig = validateServerConfig(rawConfig);
|
|
46
|
+
this.config = validatedConfig;
|
|
47
|
+
for (const callback of this.subscribers) {
|
|
48
|
+
try {
|
|
49
|
+
callback(this.config);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
audit.error(`Subscriber failed to process configuration update: ${err}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
this.lock.release();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import * as http from "http";
|
|
4
|
+
import { createHash } from "crypto";
|
|
5
|
+
import { ConfigService } from "./config.service.js";
|
|
6
|
+
import { ConfigHandler } from "./config.handler.js";
|
|
7
|
+
const MOCK_SALT = "test-salt-1234567890";
|
|
8
|
+
const MOCK_API_KEY = "test-api-key-very-long-and-secure-32-chars";
|
|
9
|
+
const MOCK_DEVICE_ID = "test-device-id-1234";
|
|
10
|
+
function generateValidPoW(salt) {
|
|
11
|
+
let nonce = 0;
|
|
12
|
+
while (true) {
|
|
13
|
+
const hash = createHash("sha256").update(salt + nonce.toString()).digest("hex");
|
|
14
|
+
if (hash.startsWith("0000")) {
|
|
15
|
+
return nonce.toString();
|
|
16
|
+
}
|
|
17
|
+
nonce++;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
describe("Control Plane API Tests", () => {
|
|
21
|
+
let server;
|
|
22
|
+
let port;
|
|
23
|
+
let validNonce;
|
|
24
|
+
const initialConfig = {
|
|
25
|
+
port: 53,
|
|
26
|
+
hosts: {
|
|
27
|
+
"initial.loop": { records: [{ type: "A", address: "1.1.1.1" }] }
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
before(async () => {
|
|
31
|
+
validNonce = generateValidPoW(MOCK_SALT);
|
|
32
|
+
const service = new ConfigService(initialConfig);
|
|
33
|
+
const handler = new ConfigHandler(service, MOCK_API_KEY, MOCK_SALT);
|
|
34
|
+
server = http.createServer((req, res) => handler.handleRequest(req, res));
|
|
35
|
+
await new Promise((resolve) => {
|
|
36
|
+
server.listen(0, "127.0.0.1", () => {
|
|
37
|
+
port = server.address().port;
|
|
38
|
+
resolve();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
after(() => {
|
|
43
|
+
server.close();
|
|
44
|
+
});
|
|
45
|
+
function makeRequest(method, path, headers, body) {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const options = {
|
|
48
|
+
hostname: "127.0.0.1",
|
|
49
|
+
port: port,
|
|
50
|
+
path: path,
|
|
51
|
+
method: method,
|
|
52
|
+
headers: headers
|
|
53
|
+
};
|
|
54
|
+
const req = http.request(options, (res) => {
|
|
55
|
+
let responseBody = "";
|
|
56
|
+
res.on("data", chunk => responseBody += chunk);
|
|
57
|
+
res.on("end", () => {
|
|
58
|
+
try {
|
|
59
|
+
resolve({ status: res.statusCode || 500, data: JSON.parse(responseBody) });
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
resolve({ status: res.statusCode || 500, data: responseBody });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
req.on("error", reject);
|
|
67
|
+
if (body)
|
|
68
|
+
req.write(body);
|
|
69
|
+
req.end();
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
it("rejects GET requests without authentication", async () => {
|
|
73
|
+
const res = await makeRequest("GET", "/api/v1/config", {});
|
|
74
|
+
assert.strictEqual(res.status, 401);
|
|
75
|
+
});
|
|
76
|
+
it("accepts GET requests with valid API key and device ID", async () => {
|
|
77
|
+
const res = await makeRequest("GET", "/api/v1/config", {
|
|
78
|
+
"x-api-key": MOCK_API_KEY,
|
|
79
|
+
"x-device-id": MOCK_DEVICE_ID
|
|
80
|
+
});
|
|
81
|
+
assert.strictEqual(res.status, 200);
|
|
82
|
+
assert.ok("hosts" in res.data);
|
|
83
|
+
assert.ok("initial.loop" in res.data.hosts);
|
|
84
|
+
});
|
|
85
|
+
it("rejects PUT requests without Proof of Work challenge", async () => {
|
|
86
|
+
const newConfig = { ...initialConfig, port: 5353 };
|
|
87
|
+
const res = await makeRequest("PUT", "/api/v1/config", {
|
|
88
|
+
"x-api-key": MOCK_API_KEY,
|
|
89
|
+
"x-device-id": MOCK_DEVICE_ID,
|
|
90
|
+
"Content-Type": "application/json"
|
|
91
|
+
}, JSON.stringify(newConfig));
|
|
92
|
+
assert.strictEqual(res.status, 401);
|
|
93
|
+
assert.ok(res.data.error.includes("x-pow-nonce"));
|
|
94
|
+
});
|
|
95
|
+
it("rejects PUT requests with invalid Proof of Work challenge", async () => {
|
|
96
|
+
const newConfig = { ...initialConfig, port: 5353 };
|
|
97
|
+
const res = await makeRequest("PUT", "/api/v1/config", {
|
|
98
|
+
"x-api-key": MOCK_API_KEY,
|
|
99
|
+
"x-device-id": MOCK_DEVICE_ID,
|
|
100
|
+
"x-pow-nonce": "invalid-nonce-value",
|
|
101
|
+
"Content-Type": "application/json"
|
|
102
|
+
}, JSON.stringify(newConfig));
|
|
103
|
+
assert.strictEqual(res.status, 403);
|
|
104
|
+
assert.ok(res.data.error.includes("Invalid Proof of Work"));
|
|
105
|
+
});
|
|
106
|
+
it("accepts PUT requests with valid configuration and PoW", async () => {
|
|
107
|
+
const newConfig = {
|
|
108
|
+
port: 5353,
|
|
109
|
+
hosts: {
|
|
110
|
+
"updated.loop": { records: [{ type: "A", address: "8.8.8.8" }] }
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const res = await makeRequest("PUT", "/api/v1/config", {
|
|
114
|
+
"x-api-key": MOCK_API_KEY,
|
|
115
|
+
"x-device-id": MOCK_DEVICE_ID,
|
|
116
|
+
"x-pow-nonce": validNonce,
|
|
117
|
+
"Content-Type": "application/json"
|
|
118
|
+
}, JSON.stringify(newConfig));
|
|
119
|
+
assert.strictEqual(res.status, 200);
|
|
120
|
+
assert.strictEqual(res.data.success, true);
|
|
121
|
+
const checkRes = await makeRequest("GET", "/api/v1/config", {
|
|
122
|
+
"x-api-key": MOCK_API_KEY,
|
|
123
|
+
"x-device-id": MOCK_DEVICE_ID
|
|
124
|
+
});
|
|
125
|
+
assert.strictEqual(checkRes.status, 200);
|
|
126
|
+
assert.strictEqual(checkRes.data.port, 5353);
|
|
127
|
+
assert.ok("updated.loop" in checkRes.data.hosts);
|
|
128
|
+
});
|
|
129
|
+
it("rejects payloads exceeding the memory boundary", async () => {
|
|
130
|
+
const hugePayload = JSON.stringify({ port: 53, hosts: {} }) + " ".repeat(1.5 * 1024 * 1024);
|
|
131
|
+
const res = await makeRequest("PUT", "/api/v1/config", {
|
|
132
|
+
"x-api-key": MOCK_API_KEY,
|
|
133
|
+
"x-device-id": MOCK_DEVICE_ID,
|
|
134
|
+
"x-pow-nonce": validNonce,
|
|
135
|
+
"Content-Type": "application/json"
|
|
136
|
+
}, hugePayload);
|
|
137
|
+
assert.strictEqual(res.status, 413);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface ConfigContext {
|
|
2
|
+
tenantId: string;
|
|
3
|
+
deviceHash: string;
|
|
4
|
+
}
|
|
5
|
+
export interface ConfigUpdateResponse {
|
|
6
|
+
success: boolean;
|
|
7
|
+
timestamp: number;
|
|
8
|
+
}
|
|
9
|
+
export interface ApiAuthHeaders {
|
|
10
|
+
authorization?: string;
|
|
11
|
+
"x-api-key"?: string;
|
|
12
|
+
"x-device-id": string;
|
|
13
|
+
"x-pow-nonce"?: string;
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ServerConfig } from "@opensecurity/zonzon-core";
|
|
2
|
+
export interface ControlPlaneOptions {
|
|
3
|
+
port: number;
|
|
4
|
+
apiKey: string;
|
|
5
|
+
blindIndexSalt: string;
|
|
6
|
+
initialConfig: ServerConfig;
|
|
7
|
+
}
|
|
8
|
+
export declare class ControlPlane {
|
|
9
|
+
private service;
|
|
10
|
+
private server;
|
|
11
|
+
private options;
|
|
12
|
+
constructor(options: ControlPlaneOptions);
|
|
13
|
+
subscribe(callback: (config: ServerConfig) => void): void;
|
|
14
|
+
start(): Promise<void>;
|
|
15
|
+
stop(): Promise<void>;
|
|
16
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as http from "http";
|
|
2
|
+
import { audit } from "@opensecurity/zonzon-core";
|
|
3
|
+
import { ConfigService } from "./domain/config/config.service.js";
|
|
4
|
+
import { ConfigHandler } from "./domain/config/config.handler.js";
|
|
5
|
+
export class ControlPlane {
|
|
6
|
+
service;
|
|
7
|
+
server = null;
|
|
8
|
+
options;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
this.service = new ConfigService(options.initialConfig);
|
|
12
|
+
}
|
|
13
|
+
subscribe(callback) {
|
|
14
|
+
this.service.subscribe(callback);
|
|
15
|
+
}
|
|
16
|
+
async start() {
|
|
17
|
+
const handler = new ConfigHandler(this.service, this.options.apiKey, this.options.blindIndexSalt);
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
this.server = http.createServer((req, res) => {
|
|
20
|
+
handler.handleRequest(req, res).catch(err => {
|
|
21
|
+
audit.error(`Control Plane Native Fault: ${err.message}`);
|
|
22
|
+
if (!res.headersSent) {
|
|
23
|
+
res.writeHead(500);
|
|
24
|
+
res.end(JSON.stringify({ error: "Internal Server Fault" }));
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
this.server.on("error", reject);
|
|
29
|
+
this.server.listen(this.options.port, "127.0.0.1", () => {
|
|
30
|
+
audit.system(`Control Plane locked strictly to loopback interface on port ${this.options.port}`);
|
|
31
|
+
resolve();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
async stop() {
|
|
36
|
+
if (this.server) {
|
|
37
|
+
await new Promise((resolve) => {
|
|
38
|
+
this.server.close(() => resolve());
|
|
39
|
+
});
|
|
40
|
+
this.server = null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
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.3",
|
|
4
4
|
"description": "dynamic configuration api for the core engine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Lucian BLETAN <neuraluc@gmail.com>",
|
|
@@ -21,11 +21,14 @@
|
|
|
21
21
|
"publishConfig": {
|
|
22
22
|
"access": "public"
|
|
23
23
|
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
24
27
|
"scripts": {
|
|
25
28
|
"build": "tsc -b"
|
|
26
29
|
},
|
|
27
30
|
"dependencies": {
|
|
28
|
-
"@opensecurity/zonzon-core": "
|
|
31
|
+
"@opensecurity/zonzon-core": "^0.1.3",
|
|
29
32
|
"zod": "^3.24.2"
|
|
30
33
|
}
|
|
31
34
|
}
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import * as http from "http";
|
|
2
|
-
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
|
3
|
-
import { audit } from "@opensecurity/zonzon-core";
|
|
4
|
-
import { ConfigService } from "./config.service.js";
|
|
5
|
-
import { ApiAuthHeaderSchema } from "./config.schema.js";
|
|
6
|
-
import { ConfigContext } from "./config.types.js";
|
|
7
|
-
|
|
8
|
-
export class ConfigHandler {
|
|
9
|
-
private service: ConfigService;
|
|
10
|
-
private blindIndexSalt: string;
|
|
11
|
-
private expectedApiKeyHash: string;
|
|
12
|
-
|
|
13
|
-
constructor(service: ConfigService, rawApiKey: string, blindIndexSalt: string) {
|
|
14
|
-
this.service = service;
|
|
15
|
-
this.blindIndexSalt = blindIndexSalt;
|
|
16
|
-
this.expectedApiKeyHash = createHmac("sha256", this.blindIndexSalt).update(rawApiKey).digest("hex");
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
private readBodyStrict(req: http.IncomingMessage): Promise<string> {
|
|
20
|
-
return new Promise((resolve, reject) => {
|
|
21
|
-
let body = "";
|
|
22
|
-
let length = 0;
|
|
23
|
-
req.on("data", (chunk: Buffer) => {
|
|
24
|
-
length += chunk.length;
|
|
25
|
-
if (length > 1048576) {
|
|
26
|
-
reject(new Error("Payload size limit exceeded"));
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
body += chunk.toString("utf8");
|
|
30
|
-
});
|
|
31
|
-
req.on("end", () => resolve(body));
|
|
32
|
-
req.on("error", reject);
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
private verifyProofOfWork(nonce: string): void {
|
|
37
|
-
const hash = createHash("sha256").update(this.blindIndexSalt + nonce).digest("hex");
|
|
38
|
-
if (!hash.startsWith("0000")) {
|
|
39
|
-
throw new Error("Invalid Proof of Work Challenge");
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
private extractContext(req: http.IncomingMessage, isMutation: boolean): ConfigContext {
|
|
44
|
-
const rawHeaders = {
|
|
45
|
-
authorization: req.headers.authorization,
|
|
46
|
-
"x-api-key": req.headers["x-api-key"],
|
|
47
|
-
"x-device-id": req.headers["x-device-id"],
|
|
48
|
-
"x-pow-nonce": req.headers["x-pow-nonce"],
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
const parsed = ApiAuthHeaderSchema.safeParse(rawHeaders);
|
|
52
|
-
if (!parsed.success) {
|
|
53
|
-
const issueString = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ');
|
|
54
|
-
throw new Error(`Authentication Validation Failed (${issueString})`);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const validatedHeaders = parsed.data;
|
|
58
|
-
|
|
59
|
-
const providedKey = validatedHeaders["x-api-key"] || "";
|
|
60
|
-
if (!providedKey) {
|
|
61
|
-
throw new Error("Missing API Key");
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const providedHash = createHmac("sha256", this.blindIndexSalt).update(providedKey).digest("hex");
|
|
65
|
-
const expectedBuffer = Buffer.from(this.expectedApiKeyHash, "utf8");
|
|
66
|
-
const providedBuffer = Buffer.from(providedHash, "utf8");
|
|
67
|
-
|
|
68
|
-
if (expectedBuffer.length !== providedBuffer.length || !timingSafeEqual(expectedBuffer, providedBuffer)) {
|
|
69
|
-
throw new Error("Unauthorized Access");
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if (isMutation) {
|
|
73
|
-
if (!validatedHeaders["x-pow-nonce"]) {
|
|
74
|
-
throw new Error("Mutation endpoint requires x-pow-nonce header");
|
|
75
|
-
}
|
|
76
|
-
this.verifyProofOfWork(validatedHeaders["x-pow-nonce"]);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const deviceHash = createHmac("sha256", this.blindIndexSalt).update(validatedHeaders["x-device-id"]).digest("hex");
|
|
80
|
-
|
|
81
|
-
return {
|
|
82
|
-
tenantId: "system-tenant-001",
|
|
83
|
-
deviceHash: deviceHash,
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
public async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
88
|
-
const clientIp = req.socket.remoteAddress || "unknown";
|
|
89
|
-
const method = req.method || "GET";
|
|
90
|
-
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
91
|
-
|
|
92
|
-
if (url.pathname !== "/api/v1/config") {
|
|
93
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
94
|
-
res.end(JSON.stringify({ error: "Endpoint Not Found" }));
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
try {
|
|
99
|
-
const isMutation = method === "PUT" || method === "POST";
|
|
100
|
-
const context = this.extractContext(req, isMutation);
|
|
101
|
-
|
|
102
|
-
if (method === "GET") {
|
|
103
|
-
const config = await this.service.getConfig(context);
|
|
104
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
105
|
-
res.end(JSON.stringify(config));
|
|
106
|
-
audit.http(clientIp, "GET", "control-plane", url.pathname, 200, "Config retrieved");
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (method === "PUT") {
|
|
111
|
-
const bodyStr = await this.readBodyStrict(req);
|
|
112
|
-
const rawConfig = JSON.parse(bodyStr);
|
|
113
|
-
await this.service.updateConfig(context, rawConfig);
|
|
114
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
115
|
-
res.end(JSON.stringify({ success: true, timestamp: Date.now() }));
|
|
116
|
-
audit.http(clientIp, "PUT", "control-plane", url.pathname, 200, "Config updated");
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
res.writeHead(405, { "Content-Type": "application/json" });
|
|
121
|
-
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
122
|
-
|
|
123
|
-
} catch (error: any) {
|
|
124
|
-
const message = error.message || "Internal Server Error";
|
|
125
|
-
|
|
126
|
-
let statusCode = 400;
|
|
127
|
-
if (message.includes("Payload size limit")) {
|
|
128
|
-
statusCode = 413;
|
|
129
|
-
} else if (message.includes("Authentication Validation Failed") || message.includes("Unauthorized") || message.includes("API Key") || message.includes("x-pow-nonce")) {
|
|
130
|
-
statusCode = 401;
|
|
131
|
-
} else if (message.includes("Invalid Proof of Work")) {
|
|
132
|
-
statusCode = 403;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
audit.error(`Control Plane API Fault: HTTP ${statusCode} | ${message} | Client: ${clientIp}`);
|
|
136
|
-
|
|
137
|
-
res.writeHead(statusCode, { "Content-Type": "application/json" });
|
|
138
|
-
res.end(JSON.stringify({ error: message }));
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
|
|
3
|
-
export const ApiAuthHeaderSchema = z.object({
|
|
4
|
-
authorization: z.string().regex(/^Bearer [a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+$/).optional(),
|
|
5
|
-
"x-api-key": z.string().min(32).max(128).optional(),
|
|
6
|
-
"x-device-id": z.string().min(16).max(64),
|
|
7
|
-
"x-pow-nonce": z.string().min(1).max(64).optional()
|
|
8
|
-
}).refine(data => data.authorization || data["x-api-key"], {
|
|
9
|
-
message: "Missing authentication credentials",
|
|
10
|
-
path: ["x-api-key"]
|
|
11
|
-
});
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { ServerConfig, validateServerConfig, audit } from "@opensecurity/zonzon-core";
|
|
2
|
-
import { ConfigContext } from "./config.types.js";
|
|
3
|
-
|
|
4
|
-
class PessimisticLock {
|
|
5
|
-
private locked = false;
|
|
6
|
-
private queue: Array<() => void> = [];
|
|
7
|
-
|
|
8
|
-
public acquire(): Promise<void> {
|
|
9
|
-
if (!this.locked) {
|
|
10
|
-
this.locked = true;
|
|
11
|
-
return Promise.resolve();
|
|
12
|
-
}
|
|
13
|
-
return new Promise(resolve => this.queue.push(resolve));
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
public release(): void {
|
|
17
|
-
if (this.queue.length > 0) {
|
|
18
|
-
const next = this.queue.shift();
|
|
19
|
-
if (next) next();
|
|
20
|
-
} else {
|
|
21
|
-
this.locked = false;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export class ConfigService {
|
|
27
|
-
private config: ServerConfig;
|
|
28
|
-
private lock = new PessimisticLock();
|
|
29
|
-
private subscribers: Array<(config: ServerConfig) => void> = [];
|
|
30
|
-
|
|
31
|
-
constructor(initialConfig: ServerConfig) {
|
|
32
|
-
this.config = validateServerConfig(initialConfig);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
public subscribe(callback: (config: ServerConfig) => void): void {
|
|
36
|
-
this.subscribers.push(callback);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
public async getConfig(ctx: ConfigContext): Promise<ServerConfig> {
|
|
40
|
-
if (!ctx.tenantId) {
|
|
41
|
-
throw new Error("Security Exception: Missing Tenant Context");
|
|
42
|
-
}
|
|
43
|
-
return this.config;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
public async updateConfig(ctx: ConfigContext, rawConfig: unknown): Promise<void> {
|
|
47
|
-
if (!ctx.tenantId) {
|
|
48
|
-
throw new Error("Security Exception: Missing Tenant Context");
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
await this.lock.acquire();
|
|
52
|
-
try {
|
|
53
|
-
const validatedConfig = validateServerConfig(rawConfig);
|
|
54
|
-
this.config = validatedConfig;
|
|
55
|
-
for (const callback of this.subscribers) {
|
|
56
|
-
try {
|
|
57
|
-
callback(this.config);
|
|
58
|
-
} catch (err) {
|
|
59
|
-
audit.error(`Subscriber failed to process configuration update: ${err}`);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
} finally {
|
|
63
|
-
this.lock.release();
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
import { describe, it, before, after } from "node:test";
|
|
2
|
-
import assert from "node:assert";
|
|
3
|
-
import * as http from "http";
|
|
4
|
-
import * as net from "net";
|
|
5
|
-
import { createHash } from "crypto";
|
|
6
|
-
import { ConfigService } from "./config.service.js";
|
|
7
|
-
import { ConfigHandler } from "./config.handler.js";
|
|
8
|
-
import { ServerConfig } from "@opensecurity/zonzon-core";
|
|
9
|
-
|
|
10
|
-
const MOCK_SALT = "test-salt-1234567890";
|
|
11
|
-
const MOCK_API_KEY = "test-api-key-very-long-and-secure-32-chars";
|
|
12
|
-
const MOCK_DEVICE_ID = "test-device-id-1234";
|
|
13
|
-
|
|
14
|
-
function generateValidPoW(salt: string): string {
|
|
15
|
-
let nonce = 0;
|
|
16
|
-
while (true) {
|
|
17
|
-
const hash = createHash("sha256").update(salt + nonce.toString()).digest("hex");
|
|
18
|
-
if (hash.startsWith("0000")) {
|
|
19
|
-
return nonce.toString();
|
|
20
|
-
}
|
|
21
|
-
nonce++;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
describe("Control Plane API Tests", () => {
|
|
26
|
-
let server: http.Server;
|
|
27
|
-
let port: number;
|
|
28
|
-
let validNonce: string;
|
|
29
|
-
|
|
30
|
-
const initialConfig: ServerConfig = {
|
|
31
|
-
port: 53,
|
|
32
|
-
hosts: {
|
|
33
|
-
"initial.loop": { records: [{ type: "A", address: "1.1.1.1" }] }
|
|
34
|
-
}
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
before(async () => {
|
|
38
|
-
validNonce = generateValidPoW(MOCK_SALT);
|
|
39
|
-
const service = new ConfigService(initialConfig);
|
|
40
|
-
const handler = new ConfigHandler(service, MOCK_API_KEY, MOCK_SALT);
|
|
41
|
-
|
|
42
|
-
server = http.createServer((req, res) => handler.handleRequest(req, res));
|
|
43
|
-
await new Promise<void>((resolve) => {
|
|
44
|
-
server.listen(0, "127.0.0.1", () => {
|
|
45
|
-
port = (server.address() as net.AddressInfo).port;
|
|
46
|
-
resolve();
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
after(() => {
|
|
52
|
-
server.close();
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
function makeRequest(method: string, path: string, headers: Record<string, string>, body?: string): Promise<{ status: number, data: any }> {
|
|
56
|
-
return new Promise((resolve, reject) => {
|
|
57
|
-
const options = {
|
|
58
|
-
hostname: "127.0.0.1",
|
|
59
|
-
port: port,
|
|
60
|
-
path: path,
|
|
61
|
-
method: method,
|
|
62
|
-
headers: headers
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
const req = http.request(options, (res) => {
|
|
66
|
-
let responseBody = "";
|
|
67
|
-
res.on("data", chunk => responseBody += chunk);
|
|
68
|
-
res.on("end", () => {
|
|
69
|
-
try {
|
|
70
|
-
resolve({ status: res.statusCode || 500, data: JSON.parse(responseBody) });
|
|
71
|
-
} catch {
|
|
72
|
-
resolve({ status: res.statusCode || 500, data: responseBody });
|
|
73
|
-
}
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
req.on("error", reject);
|
|
78
|
-
if (body) req.write(body);
|
|
79
|
-
req.end();
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
it("rejects GET requests without authentication", async () => {
|
|
84
|
-
const res = await makeRequest("GET", "/api/v1/config", {});
|
|
85
|
-
assert.strictEqual(res.status, 401);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("accepts GET requests with valid API key and device ID", async () => {
|
|
89
|
-
const res = await makeRequest("GET", "/api/v1/config", {
|
|
90
|
-
"x-api-key": MOCK_API_KEY,
|
|
91
|
-
"x-device-id": MOCK_DEVICE_ID
|
|
92
|
-
});
|
|
93
|
-
assert.strictEqual(res.status, 200);
|
|
94
|
-
assert.ok("hosts" in res.data);
|
|
95
|
-
assert.ok("initial.loop" in res.data.hosts);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("rejects PUT requests without Proof of Work challenge", async () => {
|
|
99
|
-
const newConfig = { ...initialConfig, port: 5353 };
|
|
100
|
-
const res = await makeRequest("PUT", "/api/v1/config", {
|
|
101
|
-
"x-api-key": MOCK_API_KEY,
|
|
102
|
-
"x-device-id": MOCK_DEVICE_ID,
|
|
103
|
-
"Content-Type": "application/json"
|
|
104
|
-
}, JSON.stringify(newConfig));
|
|
105
|
-
assert.strictEqual(res.status, 401);
|
|
106
|
-
assert.ok(res.data.error.includes("x-pow-nonce"));
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("rejects PUT requests with invalid Proof of Work challenge", async () => {
|
|
110
|
-
const newConfig = { ...initialConfig, port: 5353 };
|
|
111
|
-
const res = await makeRequest("PUT", "/api/v1/config", {
|
|
112
|
-
"x-api-key": MOCK_API_KEY,
|
|
113
|
-
"x-device-id": MOCK_DEVICE_ID,
|
|
114
|
-
"x-pow-nonce": "invalid-nonce-value",
|
|
115
|
-
"Content-Type": "application/json"
|
|
116
|
-
}, JSON.stringify(newConfig));
|
|
117
|
-
assert.strictEqual(res.status, 403);
|
|
118
|
-
assert.ok(res.data.error.includes("Invalid Proof of Work"));
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("accepts PUT requests with valid configuration and PoW", async () => {
|
|
122
|
-
const newConfig: ServerConfig = {
|
|
123
|
-
port: 5353,
|
|
124
|
-
hosts: {
|
|
125
|
-
"updated.loop": { records: [{ type: "A", address: "8.8.8.8" }] }
|
|
126
|
-
}
|
|
127
|
-
};
|
|
128
|
-
const res = await makeRequest("PUT", "/api/v1/config", {
|
|
129
|
-
"x-api-key": MOCK_API_KEY,
|
|
130
|
-
"x-device-id": MOCK_DEVICE_ID,
|
|
131
|
-
"x-pow-nonce": validNonce,
|
|
132
|
-
"Content-Type": "application/json"
|
|
133
|
-
}, JSON.stringify(newConfig));
|
|
134
|
-
|
|
135
|
-
assert.strictEqual(res.status, 200);
|
|
136
|
-
assert.strictEqual(res.data.success, true);
|
|
137
|
-
|
|
138
|
-
const checkRes = await makeRequest("GET", "/api/v1/config", {
|
|
139
|
-
"x-api-key": MOCK_API_KEY,
|
|
140
|
-
"x-device-id": MOCK_DEVICE_ID
|
|
141
|
-
});
|
|
142
|
-
assert.strictEqual(checkRes.status, 200);
|
|
143
|
-
assert.strictEqual(checkRes.data.port, 5353);
|
|
144
|
-
assert.ok("updated.loop" in checkRes.data.hosts);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it("rejects payloads exceeding the memory boundary", async () => {
|
|
148
|
-
const hugePayload = JSON.stringify({ port: 53, hosts: {} }) + " ".repeat(1.5 * 1024 * 1024);
|
|
149
|
-
const res = await makeRequest("PUT", "/api/v1/config", {
|
|
150
|
-
"x-api-key": MOCK_API_KEY,
|
|
151
|
-
"x-device-id": MOCK_DEVICE_ID,
|
|
152
|
-
"x-pow-nonce": validNonce,
|
|
153
|
-
"Content-Type": "application/json"
|
|
154
|
-
}, hugePayload);
|
|
155
|
-
|
|
156
|
-
assert.strictEqual(res.status, 413);
|
|
157
|
-
});
|
|
158
|
-
});
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { ServerConfig } from "@opensecurity/zonzon-core";
|
|
2
|
-
|
|
3
|
-
export interface ConfigContext {
|
|
4
|
-
tenantId: string;
|
|
5
|
-
deviceHash: string;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export interface ConfigUpdateResponse {
|
|
9
|
-
success: boolean;
|
|
10
|
-
timestamp: number;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface ApiAuthHeaders {
|
|
14
|
-
authorization?: string;
|
|
15
|
-
"x-api-key"?: string;
|
|
16
|
-
"x-device-id": string;
|
|
17
|
-
"x-pow-nonce"?: string;
|
|
18
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import * as http from "http";
|
|
2
|
-
import { ServerConfig, validateServerConfig, audit } from "@opensecurity/zonzon-core";
|
|
3
|
-
import { ConfigService } from "./domain/config/config.service.js";
|
|
4
|
-
import { ConfigHandler } from "./domain/config/config.handler.js";
|
|
5
|
-
|
|
6
|
-
export interface ControlPlaneOptions {
|
|
7
|
-
port: number;
|
|
8
|
-
apiKey: string;
|
|
9
|
-
blindIndexSalt: string;
|
|
10
|
-
initialConfig: ServerConfig;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export class ControlPlane {
|
|
14
|
-
private service: ConfigService;
|
|
15
|
-
private server: http.Server | null = null;
|
|
16
|
-
private options: ControlPlaneOptions;
|
|
17
|
-
|
|
18
|
-
constructor(options: ControlPlaneOptions) {
|
|
19
|
-
this.options = options;
|
|
20
|
-
this.service = new ConfigService(options.initialConfig);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
public subscribe(callback: (config: ServerConfig) => void): void {
|
|
24
|
-
this.service.subscribe(callback);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
public async start(): Promise<void> {
|
|
28
|
-
const handler = new ConfigHandler(this.service, this.options.apiKey, this.options.blindIndexSalt);
|
|
29
|
-
|
|
30
|
-
return new Promise((resolve, reject) => {
|
|
31
|
-
this.server = http.createServer((req, res) => {
|
|
32
|
-
handler.handleRequest(req, res).catch(err => {
|
|
33
|
-
audit.error(`Control Plane Native Fault: ${err.message}`);
|
|
34
|
-
if (!res.headersSent) {
|
|
35
|
-
res.writeHead(500);
|
|
36
|
-
res.end(JSON.stringify({ error: "Internal Server Fault" }));
|
|
37
|
-
}
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
this.server.on("error", reject);
|
|
42
|
-
|
|
43
|
-
this.server.listen(this.options.port, "127.0.0.1", () => {
|
|
44
|
-
audit.system(`Control Plane locked strictly to loopback interface on port ${this.options.port}`);
|
|
45
|
-
resolve();
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
public async stop(): Promise<void> {
|
|
51
|
-
if (this.server) {
|
|
52
|
-
await new Promise<void>((resolve) => {
|
|
53
|
-
this.server!.close(() => resolve());
|
|
54
|
-
});
|
|
55
|
-
this.server = null;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
package/tsconfig.json
DELETED