@neuralnomads/codenomad-dev 0.13.3-dev-20260402-19a4c3df → 0.13.3-dev-20260404-7996e514
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/auth/manager.js +5 -1
- package/dist/events/bus.js +4 -0
- package/dist/index.js +14 -0
- package/dist/server/http-server.js +279 -1
- package/dist/server/routes/sidecars.js +46 -0
- package/dist/sidecars/manager.js +193 -0
- package/package.json +1 -1
- package/public/assets/{ChangesTab-CmUh1lD6.js → ChangesTab-DvItgv-K.js} +2 -2
- package/public/assets/{DiffToolbar-BO2mraG6.js → DiffToolbar-Cu7psKR5.js} +1 -1
- package/public/assets/{FilesTab-eqP9iJFz.js → FilesTab-BRgtKHwp.js} +2 -2
- package/public/assets/{GitChangesTab-DyQyRmoO.js → GitChangesTab-BeiJf1yp.js} +2 -2
- package/public/assets/{SplitFilePanel-DvXBnKhO.js → SplitFilePanel-DyezmCo2.js} +1 -1
- package/public/assets/{StatusTab-B2B6v-Eg.js → StatusTab-BI7MfzHL.js} +1 -1
- package/public/assets/{bundle-full-CWbff1l0.js → bundle-full-DwOhsFtX.js} +1 -1
- package/public/assets/{diff-viewer-D0a6LOK6.js → diff-viewer-D8057mmx.js} +1 -1
- package/public/assets/index-4cIw-BDV.js +1 -0
- package/public/assets/{index-C9VkLCt-.js → index-Bfgb6hSQ.js} +1 -1
- package/public/assets/index-C3ecDP5_.js +1 -0
- package/public/assets/{index-Dl_aI0rQ.js → index-C5H7aIod.js} +1 -1
- package/public/assets/index-C5c6mvR5.js +1 -0
- package/public/assets/index-CD-C2Kdk.js +1 -0
- package/public/assets/index-CJQRbTtv.js +2 -0
- package/public/assets/index-CQD0rDm8.js +1 -0
- package/public/assets/{index-Y07FhVRK.css → index-CRyEAEtJ.css} +1 -1
- package/public/assets/index-Dcj6nNO-.js +1 -0
- package/public/assets/{loading-BMaruoBO.js → loading-C5GsedyF.js} +1 -1
- package/public/assets/main-C35SKI2Q.js +56 -0
- package/public/assets/{markdown-2ayBrByq.js → markdown-B0aobgPK.js} +3 -3
- package/public/assets/{monaco-viewer-DUhWC7BI.js → monaco-viewer-BCffFoQZ.js} +1 -1
- package/public/assets/{todo-D5pIlYqO.js → todo-CGOKkpdI.js} +1 -1
- package/public/assets/{tool-call-BuEF2-g9.js → tool-call-CpqnhOn0.js} +3 -3
- package/public/assets/unified-picker-8j7yVuRo.js +1 -0
- package/public/assets/{wrap-text-DD_eCVvu.js → wrap-text-CzY37NHS.js} +1 -1
- package/public/index.html +4 -4
- package/public/loading.html +4 -4
- package/public/sw.js +1 -1
- package/public/assets/index-BTg3IYTI.js +0 -1
- package/public/assets/index-CY6GNG9A.js +0 -1
- package/public/assets/index-CfoVNw2d.js +0 -2
- package/public/assets/index-DHqgtrcH.js +0 -1
- package/public/assets/index-DPhIvE8c.js +0 -1
- package/public/assets/index-Wj66Yen3.js +0 -1
- package/public/assets/index-vxahkr1I.js +0 -1
- package/public/assets/main-CH4aDUD4.js +0 -56
- package/public/assets/unified-picker-C-OVvqSd.js +0 -1
package/dist/auth/manager.js
CHANGED
|
@@ -75,12 +75,16 @@ export class AuthManager {
|
|
|
75
75
|
return isLoopbackAddress(request.socket.remoteAddress);
|
|
76
76
|
}
|
|
77
77
|
getSessionFromRequest(request) {
|
|
78
|
+
return this.getSessionFromHeaders(request.headers);
|
|
79
|
+
}
|
|
80
|
+
getSessionFromHeaders(headers) {
|
|
78
81
|
if (!this.authEnabled) {
|
|
79
82
|
// When auth is disabled, treat all requests as authenticated.
|
|
80
83
|
// We still return a stable username so callers can display it.
|
|
81
84
|
return { username: this.init.username, sessionId: "auth-disabled" };
|
|
82
85
|
}
|
|
83
|
-
const
|
|
86
|
+
const cookieHeader = Array.isArray(headers.cookie) ? headers.cookie.join("; ") : headers.cookie;
|
|
87
|
+
const cookies = parseCookies(cookieHeader);
|
|
84
88
|
const sessionId = cookies[this.cookieName];
|
|
85
89
|
const session = this.sessionManager.getSession(sessionId);
|
|
86
90
|
if (!session)
|
package/dist/events/bus.js
CHANGED
|
@@ -20,6 +20,8 @@ export class EventBus extends EventEmitter {
|
|
|
20
20
|
this.on("workspace.error", handler);
|
|
21
21
|
this.on("workspace.stopped", handler);
|
|
22
22
|
this.on("workspace.log", handler);
|
|
23
|
+
this.on("sidecar.updated", handler);
|
|
24
|
+
this.on("sidecar.removed", handler);
|
|
23
25
|
this.on("storage.configChanged", handler);
|
|
24
26
|
this.on("storage.stateChanged", handler);
|
|
25
27
|
this.on("instance.dataChanged", handler);
|
|
@@ -31,6 +33,8 @@ export class EventBus extends EventEmitter {
|
|
|
31
33
|
this.off("workspace.error", handler);
|
|
32
34
|
this.off("workspace.stopped", handler);
|
|
33
35
|
this.off("workspace.log", handler);
|
|
36
|
+
this.off("sidecar.updated", handler);
|
|
37
|
+
this.off("sidecar.removed", handler);
|
|
34
38
|
this.off("storage.configChanged", handler);
|
|
35
39
|
this.off("storage.stateChanged", handler);
|
|
36
40
|
this.off("instance.dataChanged", handler);
|
package/dist/index.js
CHANGED
|
@@ -23,6 +23,7 @@ import { resolveHttpsOptions } from "./server/tls";
|
|
|
23
23
|
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses";
|
|
24
24
|
import { startDevReleaseMonitor } from "./releases/dev-release-monitor";
|
|
25
25
|
import { SpeechService } from "./speech/service";
|
|
26
|
+
import { SideCarManager } from "./sidecars/manager";
|
|
26
27
|
const require = createRequire(import.meta.url);
|
|
27
28
|
const packageJson = require("../package.json");
|
|
28
29
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -208,6 +209,11 @@ async function main() {
|
|
|
208
209
|
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot });
|
|
209
210
|
const instanceStore = new InstanceStore(configLocation.instancesDir);
|
|
210
211
|
const speechService = new SpeechService(settings, logger.child({ component: "speech" }));
|
|
212
|
+
const sidecarManager = new SideCarManager({
|
|
213
|
+
settings,
|
|
214
|
+
eventBus,
|
|
215
|
+
logger: logger.child({ component: "sidecars" }),
|
|
216
|
+
});
|
|
211
217
|
const instanceEventBridge = new InstanceEventBridge({
|
|
212
218
|
workspaceManager,
|
|
213
219
|
eventBus,
|
|
@@ -281,6 +287,7 @@ async function main() {
|
|
|
281
287
|
serverMeta,
|
|
282
288
|
instanceStore,
|
|
283
289
|
speechService,
|
|
290
|
+
sidecarManager,
|
|
284
291
|
authManager,
|
|
285
292
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
|
286
293
|
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
|
@@ -301,6 +308,7 @@ async function main() {
|
|
|
301
308
|
serverMeta,
|
|
302
309
|
instanceStore,
|
|
303
310
|
speechService,
|
|
311
|
+
sidecarManager,
|
|
304
312
|
authManager,
|
|
305
313
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
|
306
314
|
uiDevServerUrl: undefined,
|
|
@@ -391,6 +399,12 @@ async function main() {
|
|
|
391
399
|
catch (error) {
|
|
392
400
|
logger.warn({ err: error }, "Instance event bridge shutdown failed");
|
|
393
401
|
}
|
|
402
|
+
try {
|
|
403
|
+
await sidecarManager.shutdown();
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
logger.error({ err: error }, "SideCar manager shutdown failed");
|
|
407
|
+
}
|
|
394
408
|
try {
|
|
395
409
|
await workspaceManager.shutdown();
|
|
396
410
|
logger.info("Workspace manager shutdown complete");
|
|
@@ -3,7 +3,9 @@ import cors from "@fastify/cors";
|
|
|
3
3
|
import fastifyStatic from "@fastify/static";
|
|
4
4
|
import replyFrom from "@fastify/reply-from";
|
|
5
5
|
import fs from "fs";
|
|
6
|
+
import { connect as connectTcp } from "net";
|
|
6
7
|
import path from "path";
|
|
8
|
+
import { connect as connectTls } from "tls";
|
|
7
9
|
import { fetch } from "undici";
|
|
8
10
|
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees";
|
|
9
11
|
import { registerWorkspaceRoutes } from "./routes/workspaces";
|
|
@@ -17,6 +19,7 @@ import { registerBackgroundProcessRoutes } from "./routes/background-processes";
|
|
|
17
19
|
import { registerWorktreeRoutes } from "./routes/worktrees";
|
|
18
20
|
import { registerSpeechRoutes } from "./routes/speech";
|
|
19
21
|
import { registerRemoteServerRoutes } from "./routes/remote-servers";
|
|
22
|
+
import { registerSideCarRoutes } from "./routes/sidecars";
|
|
20
23
|
import { BackgroundProcessManager } from "../background-processes/manager";
|
|
21
24
|
import { registerAuthRoutes } from "./routes/auth";
|
|
22
25
|
import { sendUnauthorized, wantsHtml } from "../auth/http-auth";
|
|
@@ -150,7 +153,7 @@ export function createHttpServer(deps) {
|
|
|
150
153
|
return;
|
|
151
154
|
}
|
|
152
155
|
const session = deps.authManager.getSessionFromRequest(request);
|
|
153
|
-
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/");
|
|
156
|
+
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/");
|
|
154
157
|
if (requiresAuthForApi && !session) {
|
|
155
158
|
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
|
156
159
|
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/);
|
|
@@ -210,6 +213,13 @@ export function createHttpServer(deps) {
|
|
|
210
213
|
});
|
|
211
214
|
registerRemoteServerRoutes(app, { logger: apiLogger });
|
|
212
215
|
registerSpeechRoutes(app, { speechService: deps.speechService });
|
|
216
|
+
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager });
|
|
217
|
+
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger });
|
|
218
|
+
setupSideCarWebSocketProxy(app, {
|
|
219
|
+
sidecarManager: deps.sidecarManager,
|
|
220
|
+
authManager: deps.authManager,
|
|
221
|
+
logger: proxyLogger,
|
|
222
|
+
});
|
|
213
223
|
registerPluginRoutes(app, {
|
|
214
224
|
workspaceManager: deps.workspaceManager,
|
|
215
225
|
eventBus: deps.eventBus,
|
|
@@ -279,6 +289,48 @@ export function createHttpServer(deps) {
|
|
|
279
289
|
},
|
|
280
290
|
};
|
|
281
291
|
}
|
|
292
|
+
function registerSideCarProxyRoutes(app, deps) {
|
|
293
|
+
const proxyBaseHandler = async (request, reply) => {
|
|
294
|
+
await proxySideCarRequest({
|
|
295
|
+
request,
|
|
296
|
+
reply,
|
|
297
|
+
sidecarManager: deps.sidecarManager,
|
|
298
|
+
logger: deps.logger,
|
|
299
|
+
pathSuffix: "",
|
|
300
|
+
});
|
|
301
|
+
};
|
|
302
|
+
const proxyWildcardHandler = async (request, reply) => {
|
|
303
|
+
await proxySideCarRequest({
|
|
304
|
+
request,
|
|
305
|
+
reply,
|
|
306
|
+
sidecarManager: deps.sidecarManager,
|
|
307
|
+
logger: deps.logger,
|
|
308
|
+
pathSuffix: request.params["*"] ?? "",
|
|
309
|
+
});
|
|
310
|
+
};
|
|
311
|
+
app.all("/sidecars/:id", proxyBaseHandler);
|
|
312
|
+
app.all("/sidecars/:id/*", proxyWildcardHandler);
|
|
313
|
+
}
|
|
314
|
+
function setupSideCarWebSocketProxy(app, deps) {
|
|
315
|
+
app.server.on("upgrade", (request, socket, head) => {
|
|
316
|
+
const rawUrl = request.url ?? "/";
|
|
317
|
+
const parsed = parseSideCarUpgradePath(rawUrl);
|
|
318
|
+
if (!parsed) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
void proxySideCarWebSocketUpgrade({
|
|
322
|
+
request,
|
|
323
|
+
socket: socket,
|
|
324
|
+
head,
|
|
325
|
+
sidecarId: parsed.sidecarId,
|
|
326
|
+
incomingPath: parsed.pathname,
|
|
327
|
+
search: parsed.search,
|
|
328
|
+
sidecarManager: deps.sidecarManager,
|
|
329
|
+
authManager: deps.authManager,
|
|
330
|
+
logger: deps.logger,
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
}
|
|
282
334
|
function registerInstanceProxyRoutes(app, deps) {
|
|
283
335
|
app.register(async (instance) => {
|
|
284
336
|
instance.removeAllContentTypeParsers();
|
|
@@ -681,3 +733,229 @@ function buildProxyHeaders(headers) {
|
|
|
681
733
|
}
|
|
682
734
|
return result;
|
|
683
735
|
}
|
|
736
|
+
async function proxySideCarRequest(args) {
|
|
737
|
+
const sidecarId = args.request.params.id ?? "";
|
|
738
|
+
const sidecar = await args.sidecarManager.get(sidecarId);
|
|
739
|
+
if (!sidecar) {
|
|
740
|
+
args.reply.code(404).send({ error: "SideCar not found" });
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const pathname = (args.request.raw.url ?? args.request.url ?? "").split("?")[0] ?? "";
|
|
744
|
+
const queryIndex = (args.request.raw.url ?? args.request.url ?? "").indexOf("?");
|
|
745
|
+
const search = queryIndex >= 0 ? (args.request.raw.url ?? args.request.url ?? "").slice(queryIndex) : "";
|
|
746
|
+
const pathSuffix = args.pathSuffix ?? "";
|
|
747
|
+
const requestPath = pathSuffix ? `${args.sidecarManager.buildProxyBasePath(sidecarId)}/${pathSuffix.replace(/^\/+/, "")}` : args.sidecarManager.buildProxyBasePath(sidecarId);
|
|
748
|
+
const targetPath = args.sidecarManager.buildTargetPath(sidecarId, requestPath, search);
|
|
749
|
+
const targetOrigin = args.sidecarManager.buildTargetOrigin(sidecar);
|
|
750
|
+
const targetUrl = `${targetOrigin}${targetPath}`;
|
|
751
|
+
args.logger.debug({ sidecarId: sidecar.id, targetUrl, pathname, prefixMode: sidecar.prefixMode }, "Proxying request to SideCar");
|
|
752
|
+
await args.reply.from(targetUrl, {
|
|
753
|
+
rewriteRequestHeaders: (_originalRequest, headers) => sanitizeSideCarProxyRequestHeaders(headers, targetOrigin),
|
|
754
|
+
rewriteHeaders: (headers) => rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, sidecar.prefixMode),
|
|
755
|
+
onError: (reply, { error }) => {
|
|
756
|
+
args.logger.error({ sidecarId: sidecar.id, err: error, targetUrl }, "Failed to proxy SideCar request");
|
|
757
|
+
if (!reply.sent) {
|
|
758
|
+
reply.code(502).send({ error: "SideCar proxy failed" });
|
|
759
|
+
}
|
|
760
|
+
},
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
function parseSideCarUpgradePath(rawUrl) {
|
|
764
|
+
let parsed;
|
|
765
|
+
try {
|
|
766
|
+
parsed = new URL(rawUrl, "http://localhost");
|
|
767
|
+
}
|
|
768
|
+
catch {
|
|
769
|
+
return null;
|
|
770
|
+
}
|
|
771
|
+
const match = parsed.pathname.match(/^\/sidecars\/([^/]+)(?:\/.*)?$/);
|
|
772
|
+
if (!match) {
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
try {
|
|
776
|
+
return {
|
|
777
|
+
sidecarId: decodeURIComponent(match[1] ?? ""),
|
|
778
|
+
pathname: parsed.pathname,
|
|
779
|
+
search: parsed.search,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
catch {
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
async function proxySideCarWebSocketUpgrade(args) {
|
|
787
|
+
const { request, socket, head, sidecarId, incomingPath, search, sidecarManager, authManager, logger } = args;
|
|
788
|
+
if (!isWebSocketUpgradeRequest(request)) {
|
|
789
|
+
rejectUpgrade(socket, 400, "Bad Request");
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const session = authManager.getSessionFromHeaders(request.headers);
|
|
793
|
+
if (!session) {
|
|
794
|
+
rejectUpgrade(socket, 401, "Unauthorized");
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
const sidecar = await sidecarManager.get(sidecarId);
|
|
798
|
+
if (!sidecar) {
|
|
799
|
+
rejectUpgrade(socket, 404, "Not Found");
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
const targetOrigin = sidecarManager.buildTargetOrigin(sidecar);
|
|
803
|
+
const targetPath = sidecarManager.buildTargetPath(sidecarId, incomingPath, search);
|
|
804
|
+
const targetUrl = new URL(`${targetOrigin}${targetPath}`);
|
|
805
|
+
logger.debug({ sidecarId, targetUrl: targetUrl.toString(), prefixMode: sidecar.prefixMode }, "Proxying websocket to SideCar");
|
|
806
|
+
const { socket: upstream, readyEvent } = createSideCarUpstreamSocket(targetUrl);
|
|
807
|
+
const closeBoth = () => {
|
|
808
|
+
if (!socket.destroyed) {
|
|
809
|
+
socket.destroy();
|
|
810
|
+
}
|
|
811
|
+
if (!upstream.destroyed) {
|
|
812
|
+
upstream.destroy();
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
upstream.once("error", (error) => {
|
|
816
|
+
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to proxy SideCar websocket");
|
|
817
|
+
rejectUpgrade(socket, 502, "Bad Gateway");
|
|
818
|
+
if (!upstream.destroyed) {
|
|
819
|
+
upstream.destroy();
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
socket.once("error", (error) => {
|
|
823
|
+
logger.debug({ sidecarId, err: error }, "SideCar websocket client socket errored");
|
|
824
|
+
if (!upstream.destroyed) {
|
|
825
|
+
upstream.destroy();
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
upstream.once(readyEvent, () => {
|
|
829
|
+
try {
|
|
830
|
+
upstream.write(buildSideCarWebSocketRequest(request, targetUrl));
|
|
831
|
+
if (head.length > 0) {
|
|
832
|
+
upstream.write(head);
|
|
833
|
+
}
|
|
834
|
+
upstream.pipe(socket);
|
|
835
|
+
socket.pipe(upstream);
|
|
836
|
+
}
|
|
837
|
+
catch (error) {
|
|
838
|
+
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to forward SideCar websocket upgrade");
|
|
839
|
+
closeBoth();
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
upstream.once("close", () => {
|
|
843
|
+
if (!socket.destroyed) {
|
|
844
|
+
socket.end();
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
socket.once("close", () => {
|
|
848
|
+
if (!upstream.destroyed) {
|
|
849
|
+
upstream.end();
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
function createSideCarUpstreamSocket(targetUrl) {
|
|
854
|
+
const port = Number(targetUrl.port || (targetUrl.protocol === "https:" ? 443 : 80));
|
|
855
|
+
if (targetUrl.protocol === "https:") {
|
|
856
|
+
return {
|
|
857
|
+
socket: connectTls({
|
|
858
|
+
host: targetUrl.hostname,
|
|
859
|
+
port,
|
|
860
|
+
servername: targetUrl.hostname,
|
|
861
|
+
}),
|
|
862
|
+
readyEvent: "secureConnect",
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
return {
|
|
866
|
+
socket: connectTcp(port, targetUrl.hostname),
|
|
867
|
+
readyEvent: "connect",
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
function buildSideCarWebSocketRequest(request, targetUrl) {
|
|
871
|
+
const pathWithQuery = `${targetUrl.pathname}${targetUrl.search}`;
|
|
872
|
+
const requestLine = `${request.method ?? "GET"} ${pathWithQuery} HTTP/${request.httpVersion}\r\n`;
|
|
873
|
+
const headerLines = [];
|
|
874
|
+
const rawHeaders = request.rawHeaders ?? [];
|
|
875
|
+
const blockedHeaders = getBlockedSideCarRequestHeaders();
|
|
876
|
+
for (let index = 0; index < rawHeaders.length; index += 2) {
|
|
877
|
+
const key = rawHeaders[index];
|
|
878
|
+
const value = rawHeaders[index + 1];
|
|
879
|
+
if (!key || value === undefined)
|
|
880
|
+
continue;
|
|
881
|
+
const lower = key.toLowerCase();
|
|
882
|
+
if (blockedHeaders.has(lower))
|
|
883
|
+
continue;
|
|
884
|
+
if (lower === "origin") {
|
|
885
|
+
headerLines.push(`Origin: ${targetUrl.origin}\r\n`);
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
headerLines.push(`${key}: ${value}\r\n`);
|
|
889
|
+
}
|
|
890
|
+
const hostValue = targetUrl.port ? `${targetUrl.hostname}:${targetUrl.port}` : targetUrl.hostname;
|
|
891
|
+
headerLines.push(`Host: ${hostValue}\r\n`);
|
|
892
|
+
headerLines.push("\r\n");
|
|
893
|
+
return requestLine + headerLines.join("");
|
|
894
|
+
}
|
|
895
|
+
function isWebSocketUpgradeRequest(request) {
|
|
896
|
+
const upgrade = request.headers.upgrade;
|
|
897
|
+
if (typeof upgrade !== "string" || upgrade.toLowerCase() !== "websocket") {
|
|
898
|
+
return false;
|
|
899
|
+
}
|
|
900
|
+
const connection = request.headers.connection;
|
|
901
|
+
const connectionValue = Array.isArray(connection) ? connection.join(",") : connection ?? "";
|
|
902
|
+
return connectionValue.toLowerCase().split(",").map((part) => part.trim()).includes("upgrade");
|
|
903
|
+
}
|
|
904
|
+
function rejectUpgrade(socket, statusCode, statusText) {
|
|
905
|
+
if (socket.destroyed) {
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
909
|
+
socket.destroy();
|
|
910
|
+
}
|
|
911
|
+
function rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, prefixMode) {
|
|
912
|
+
if (prefixMode === "preserve") {
|
|
913
|
+
return headers;
|
|
914
|
+
}
|
|
915
|
+
const next = { ...headers };
|
|
916
|
+
const locationHeader = next.location;
|
|
917
|
+
const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader;
|
|
918
|
+
if (!location) {
|
|
919
|
+
return next;
|
|
920
|
+
}
|
|
921
|
+
const publicBase = `/sidecars/${encodeURIComponent(sidecarId)}`;
|
|
922
|
+
if (location.startsWith("/")) {
|
|
923
|
+
next.location = `${publicBase}${location}`;
|
|
924
|
+
return next;
|
|
925
|
+
}
|
|
926
|
+
try {
|
|
927
|
+
const parsed = new URL(location);
|
|
928
|
+
if (parsed.origin === targetOrigin) {
|
|
929
|
+
next.location = `${publicBase}${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
catch {
|
|
933
|
+
// Relative redirects should continue to resolve against the public sidecar path.
|
|
934
|
+
}
|
|
935
|
+
return next;
|
|
936
|
+
}
|
|
937
|
+
function sanitizeSideCarProxyRequestHeaders(headers, targetOrigin) {
|
|
938
|
+
const blockedHeaders = getBlockedSideCarRequestHeaders();
|
|
939
|
+
const next = {};
|
|
940
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
941
|
+
if (!value)
|
|
942
|
+
continue;
|
|
943
|
+
if (blockedHeaders.has(key.toLowerCase()))
|
|
944
|
+
continue;
|
|
945
|
+
next[key] = value;
|
|
946
|
+
}
|
|
947
|
+
next.origin = targetOrigin;
|
|
948
|
+
return next;
|
|
949
|
+
}
|
|
950
|
+
function getBlockedSideCarRequestHeaders() {
|
|
951
|
+
return new Set([
|
|
952
|
+
"host",
|
|
953
|
+
"authorization",
|
|
954
|
+
"proxy-authorization",
|
|
955
|
+
"forwarded",
|
|
956
|
+
"x-forwarded-for",
|
|
957
|
+
"x-forwarded-host",
|
|
958
|
+
"x-forwarded-port",
|
|
959
|
+
"x-forwarded-proto",
|
|
960
|
+
]);
|
|
961
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const SideCarCreateSchema = z.object({
|
|
3
|
+
kind: z.literal("port").default("port"),
|
|
4
|
+
name: z.string().trim().min(1),
|
|
5
|
+
port: z.number().int().min(1).max(65535),
|
|
6
|
+
insecure: z.boolean().default(false),
|
|
7
|
+
prefixMode: z.enum(["strip", "preserve"]).default("strip"),
|
|
8
|
+
});
|
|
9
|
+
const SideCarUpdateSchema = SideCarCreateSchema.omit({ kind: true }).partial().refine((value) => Object.keys(value).length > 0, {
|
|
10
|
+
message: "At least one field is required",
|
|
11
|
+
});
|
|
12
|
+
export function registerSideCarRoutes(app, deps) {
|
|
13
|
+
app.get("/api/sidecars", async () => {
|
|
14
|
+
return { sidecars: await deps.sidecarManager.list() };
|
|
15
|
+
});
|
|
16
|
+
app.post("/api/sidecars", async (request, reply) => {
|
|
17
|
+
try {
|
|
18
|
+
const body = SideCarCreateSchema.parse(request.body ?? {});
|
|
19
|
+
const sidecar = await deps.sidecarManager.create(body);
|
|
20
|
+
reply.code(201);
|
|
21
|
+
return sidecar;
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
reply.code(400);
|
|
25
|
+
return { error: error instanceof Error ? error.message : "Failed to create SideCar" };
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
app.put("/api/sidecars/:id", async (request, reply) => {
|
|
29
|
+
try {
|
|
30
|
+
const body = SideCarUpdateSchema.parse(request.body ?? {});
|
|
31
|
+
return await deps.sidecarManager.update(request.params.id, body);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
reply.code(400);
|
|
35
|
+
return { error: error instanceof Error ? error.message : "Failed to update SideCar" };
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
app.delete("/api/sidecars/:id", async (request, reply) => {
|
|
39
|
+
const removed = await deps.sidecarManager.delete(request.params.id);
|
|
40
|
+
if (!removed) {
|
|
41
|
+
reply.code(404);
|
|
42
|
+
return { error: "SideCar not found" };
|
|
43
|
+
}
|
|
44
|
+
reply.code(204);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { connect } from "net";
|
|
2
|
+
export class SideCarManager {
|
|
3
|
+
constructor(options) {
|
|
4
|
+
this.options = options;
|
|
5
|
+
this.configs = new Map();
|
|
6
|
+
this.runtime = new Map();
|
|
7
|
+
for (const record of this.loadConfiguredSideCars()) {
|
|
8
|
+
this.configs.set(record.id, record);
|
|
9
|
+
this.runtime.set(record.id, { status: "stopped" });
|
|
10
|
+
}
|
|
11
|
+
queueMicrotask(() => {
|
|
12
|
+
for (const record of this.configs.values()) {
|
|
13
|
+
void this.refreshPortSideCar(record.id).catch((error) => {
|
|
14
|
+
this.options.logger.warn({ sidecarId: record.id, err: error }, "Failed to probe sidecar port");
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
async list() {
|
|
20
|
+
await this.refreshPortStatuses();
|
|
21
|
+
return Array.from(this.configs.values()).map((record) => this.toSideCar(record));
|
|
22
|
+
}
|
|
23
|
+
async get(id) {
|
|
24
|
+
if (!this.configs.has(id))
|
|
25
|
+
return undefined;
|
|
26
|
+
await this.refreshPortSideCar(id);
|
|
27
|
+
return this.toSideCar(this.requireConfig(id));
|
|
28
|
+
}
|
|
29
|
+
async create(input) {
|
|
30
|
+
const normalizedName = input.name.trim();
|
|
31
|
+
const id = this.buildSideCarId(normalizedName);
|
|
32
|
+
if (this.configs.has(id)) {
|
|
33
|
+
throw new Error(`SideCar '${id}' already exists`);
|
|
34
|
+
}
|
|
35
|
+
const now = new Date().toISOString();
|
|
36
|
+
const record = {
|
|
37
|
+
id,
|
|
38
|
+
kind: input.kind,
|
|
39
|
+
name: normalizedName,
|
|
40
|
+
port: input.port,
|
|
41
|
+
insecure: input.insecure,
|
|
42
|
+
prefixMode: input.prefixMode,
|
|
43
|
+
createdAt: now,
|
|
44
|
+
updatedAt: now,
|
|
45
|
+
};
|
|
46
|
+
this.configs.set(record.id, record);
|
|
47
|
+
this.runtime.set(record.id, { status: "stopped" });
|
|
48
|
+
this.persistConfigs();
|
|
49
|
+
await this.refreshPortSideCar(record.id);
|
|
50
|
+
return this.toSideCar(record);
|
|
51
|
+
}
|
|
52
|
+
async update(id, input) {
|
|
53
|
+
const record = this.requireConfig(id);
|
|
54
|
+
record.name = typeof input.name === "string" ? input.name.trim() : record.name;
|
|
55
|
+
record.port = typeof input.port === "number" ? input.port : record.port;
|
|
56
|
+
record.insecure = typeof input.insecure === "boolean" ? input.insecure : record.insecure;
|
|
57
|
+
record.prefixMode = typeof input.prefixMode === "string" ? input.prefixMode : record.prefixMode;
|
|
58
|
+
record.updatedAt = new Date().toISOString();
|
|
59
|
+
this.persistConfigs();
|
|
60
|
+
await this.refreshPortSideCar(id);
|
|
61
|
+
return this.toSideCar(record);
|
|
62
|
+
}
|
|
63
|
+
async delete(id) {
|
|
64
|
+
const record = this.configs.get(id);
|
|
65
|
+
if (!record)
|
|
66
|
+
return false;
|
|
67
|
+
this.configs.delete(id);
|
|
68
|
+
this.runtime.delete(id);
|
|
69
|
+
this.persistConfigs();
|
|
70
|
+
this.options.eventBus.publish({ type: "sidecar.removed", sidecarId: id });
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
async shutdown() {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
buildTargetOrigin(sidecar) {
|
|
77
|
+
const protocol = sidecar.insecure ? "http" : "https";
|
|
78
|
+
return `${protocol}://127.0.0.1:${sidecar.port}`;
|
|
79
|
+
}
|
|
80
|
+
buildProxyBasePath(id) {
|
|
81
|
+
return `/sidecars/${encodeURIComponent(id)}`;
|
|
82
|
+
}
|
|
83
|
+
buildTargetPath(id, incomingPath, search = "") {
|
|
84
|
+
const record = this.requireConfig(id);
|
|
85
|
+
const publicBase = this.buildProxyBasePath(id);
|
|
86
|
+
const normalizedPath = incomingPath || publicBase;
|
|
87
|
+
if (record.prefixMode === "preserve") {
|
|
88
|
+
return `${normalizedPath}${search}`;
|
|
89
|
+
}
|
|
90
|
+
let stripped = normalizedPath.startsWith(publicBase) ? normalizedPath.slice(publicBase.length) : normalizedPath;
|
|
91
|
+
if (!stripped || stripped === "/") {
|
|
92
|
+
stripped = "/";
|
|
93
|
+
}
|
|
94
|
+
else if (!stripped.startsWith("/")) {
|
|
95
|
+
stripped = `/${stripped}`;
|
|
96
|
+
}
|
|
97
|
+
return `${stripped}${search}`;
|
|
98
|
+
}
|
|
99
|
+
async refreshPortStatuses() {
|
|
100
|
+
await Promise.all(Array.from(this.configs.values()).map((record) => this.refreshPortSideCar(record.id)));
|
|
101
|
+
}
|
|
102
|
+
async refreshPortSideCar(id) {
|
|
103
|
+
const record = this.configs.get(id);
|
|
104
|
+
if (!record)
|
|
105
|
+
return;
|
|
106
|
+
const isAvailable = await this.isPortAvailable(record.port);
|
|
107
|
+
const current = this.runtime.get(id);
|
|
108
|
+
const nextStatus = isAvailable ? "running" : "stopped";
|
|
109
|
+
if (current?.status === nextStatus) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
this.runtime.set(id, { status: nextStatus });
|
|
113
|
+
record.updatedAt = new Date().toISOString();
|
|
114
|
+
this.publish(id);
|
|
115
|
+
}
|
|
116
|
+
publish(id) {
|
|
117
|
+
const record = this.configs.get(id);
|
|
118
|
+
if (!record)
|
|
119
|
+
return;
|
|
120
|
+
this.options.eventBus.publish({ type: "sidecar.updated", sidecar: this.toSideCar(record) });
|
|
121
|
+
}
|
|
122
|
+
toSideCar(record) {
|
|
123
|
+
const runtime = this.runtime.get(record.id);
|
|
124
|
+
return {
|
|
125
|
+
id: record.id,
|
|
126
|
+
kind: record.kind,
|
|
127
|
+
name: record.name,
|
|
128
|
+
port: record.port,
|
|
129
|
+
insecure: record.insecure,
|
|
130
|
+
prefixMode: record.prefixMode,
|
|
131
|
+
status: runtime?.status ?? "stopped",
|
|
132
|
+
createdAt: record.createdAt,
|
|
133
|
+
updatedAt: record.updatedAt,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
requireConfig(id) {
|
|
137
|
+
const record = this.configs.get(id);
|
|
138
|
+
if (!record) {
|
|
139
|
+
throw new Error("SideCar not found");
|
|
140
|
+
}
|
|
141
|
+
return record;
|
|
142
|
+
}
|
|
143
|
+
persistConfigs() {
|
|
144
|
+
const sidecars = Array.from(this.configs.values()).map((record) => ({ ...record }));
|
|
145
|
+
this.options.settings.mergePatchOwner("config", "server", { sidecars });
|
|
146
|
+
}
|
|
147
|
+
loadConfiguredSideCars() {
|
|
148
|
+
const serverConfig = this.options.settings.getOwner("config", "server");
|
|
149
|
+
const list = Array.isArray(serverConfig?.sidecars) ? serverConfig.sidecars : [];
|
|
150
|
+
const records = [];
|
|
151
|
+
for (const item of list) {
|
|
152
|
+
if (!item || typeof item !== "object")
|
|
153
|
+
continue;
|
|
154
|
+
const record = item;
|
|
155
|
+
const kind = record.kind === "port" ? "port" : null;
|
|
156
|
+
const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : null;
|
|
157
|
+
const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : null;
|
|
158
|
+
const port = typeof record.port === "number" && Number.isInteger(record.port) ? record.port : null;
|
|
159
|
+
if (!kind || !id || !name || !port)
|
|
160
|
+
continue;
|
|
161
|
+
const insecure = record.insecure === true;
|
|
162
|
+
const prefixMode = record.prefixMode === "preserve" ? "preserve" : "strip";
|
|
163
|
+
const createdAt = typeof record.createdAt === "string" && record.createdAt ? record.createdAt : new Date().toISOString();
|
|
164
|
+
const updatedAt = typeof record.updatedAt === "string" && record.updatedAt ? record.updatedAt : createdAt;
|
|
165
|
+
records.push({ id, kind, name, port, insecure, prefixMode, createdAt, updatedAt });
|
|
166
|
+
}
|
|
167
|
+
return records;
|
|
168
|
+
}
|
|
169
|
+
isPortAvailable(port) {
|
|
170
|
+
return new Promise((resolve) => {
|
|
171
|
+
const socket = connect({ port, host: "127.0.0.1" }, () => {
|
|
172
|
+
socket.end();
|
|
173
|
+
resolve(true);
|
|
174
|
+
});
|
|
175
|
+
socket.once("error", () => {
|
|
176
|
+
socket.destroy();
|
|
177
|
+
resolve(false);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
buildSideCarId(name) {
|
|
182
|
+
const normalized = name
|
|
183
|
+
.trim()
|
|
184
|
+
.toLowerCase()
|
|
185
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
186
|
+
.replace(/-{2,}/g, "-")
|
|
187
|
+
.replace(/^-|-$/g, "");
|
|
188
|
+
if (!normalized) {
|
|
189
|
+
throw new Error("SideCar name must include letters or numbers");
|
|
190
|
+
}
|
|
191
|
+
return normalized;
|
|
192
|
+
}
|
|
193
|
+
}
|