@bothat-io/molenkopf 0.1.2
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/.env.example +2 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/SECURITY.md +36 -0
- package/bin/launcher.js +76 -0
- package/bin/molenkopf.js +4 -0
- package/docs/DEPLOYMENT.md +104 -0
- package/docs/MOLENKOPF_PLUGIN_API.md +113 -0
- package/docs/MOLENKOPF_PROVIDER_ENV.md +123 -0
- package/docs/MOLENKOPF_USAGE.md +195 -0
- package/docs/PRODUCT_INTENT.md +36 -0
- package/docs/THREAT_MODEL.md +94 -0
- package/molenkopf.config.example.json +68 -0
- package/package.json +98 -0
- package/packages/core/src/auth/password.ts +47 -0
- package/packages/core/src/auth/session.ts +64 -0
- package/packages/core/src/ci/ci-mode.ts +71 -0
- package/packages/core/src/compression/content-classifier.ts +25 -0
- package/packages/core/src/compression/context-compressor.ts +48 -0
- package/packages/core/src/compression/json-compressor.ts +54 -0
- package/packages/core/src/compression/log-compressor.ts +32 -0
- package/packages/core/src/compression/operational-block-compressor.ts +43 -0
- package/packages/core/src/compression/stacktrace-compressor.ts +23 -0
- package/packages/core/src/config/config-policies.ts +146 -0
- package/packages/core/src/config/molenkopf-config.ts +137 -0
- package/packages/core/src/config/provider-config.ts +139 -0
- package/packages/core/src/events/event-bus.ts +88 -0
- package/packages/core/src/identity/api-keys.ts +149 -0
- package/packages/core/src/identity/budget.ts +51 -0
- package/packages/core/src/identity/db.ts +68 -0
- package/packages/core/src/identity/identity-store.ts +175 -0
- package/packages/core/src/identity/identity-validation.ts +102 -0
- package/packages/core/src/identity/key-permissions.ts +18 -0
- package/packages/core/src/identity/pricing.ts +11 -0
- package/packages/core/src/identity/types.ts +87 -0
- package/packages/core/src/identity/usage-snapshot.ts +116 -0
- package/packages/core/src/manifest/audit-activity.ts +74 -0
- package/packages/core/src/manifest/audit-metrics.ts +7 -0
- package/packages/core/src/manifest/audit-safety.ts +113 -0
- package/packages/core/src/manifest/audit-store.ts +189 -0
- package/packages/core/src/manifest/audit-summary.ts +184 -0
- package/packages/core/src/manifest/usage-meter.ts +105 -0
- package/packages/core/src/memory/memory-extractor.ts +57 -0
- package/packages/core/src/memory/memory-graph.ts +55 -0
- package/packages/core/src/pipeline/json-string-spans.ts +143 -0
- package/packages/core/src/pipeline/openai-request-rewriter.ts +66 -0
- package/packages/core/src/plugins/builtin-plugin-descriptors.ts +10 -0
- package/packages/core/src/plugins/builtin-plugin-modules.ts +9 -0
- package/packages/core/src/plugins/plugin-api.ts +96 -0
- package/packages/core/src/plugins/plugin-catalog.ts +42 -0
- package/packages/core/src/plugins/plugin-descriptor.ts +51 -0
- package/packages/core/src/plugins/plugin-sdk.ts +47 -0
- package/packages/core/src/plugins/static-pipeline.ts +5 -0
- package/packages/core/src/profiles/profile-router.ts +45 -0
- package/packages/core/src/providers/provider-catalog.ts +186 -0
- package/packages/core/src/routing/distribution.ts +31 -0
- package/packages/core/src/security/secret-redactor.ts +139 -0
- package/packages/core/src/security/target-policy.ts +61 -0
- package/packages/core/src/storage/local-paths.ts +6 -0
- package/packages/core/src/storage/private-state.ts +30 -0
- package/packages/core/src/storage/purge-dir.ts +10 -0
- package/packages/core/src/store/retrieval-store.ts +114 -0
- package/packages/core/src/utils/hash.ts +9 -0
- package/packages/core/src/utils/text.ts +18 -0
- package/packages/core/src/utils/tokens.ts +3 -0
- package/packages/dashboard/dist/assets/index-B_aSPgHx.js +11 -0
- package/packages/dashboard/dist/assets/index-D6z2TEL2.css +1 -0
- package/packages/dashboard/dist/favicon.png +0 -0
- package/packages/dashboard/dist/index.html +15 -0
- package/packages/dashboard/dist/molenkopf-logo.png +0 -0
- package/packages/dashboard/public/favicon.png +0 -0
- package/packages/dashboard/public/molenkopf-logo.png +0 -0
- package/packages/plugins/context-compressor-plugin/descriptor.ts +19 -0
- package/packages/plugins/context-compressor-plugin/page.html +191 -0
- package/packages/plugins/context-compressor-plugin/plugin.ts +40 -0
- package/packages/plugins/obsidian-graph-plugin/descriptor.ts +19 -0
- package/packages/plugins/obsidian-graph-plugin/page.html +68 -0
- package/packages/plugins/obsidian-graph-plugin/plugin.ts +27 -0
- package/packages/plugins/shared/audit-projects.ts +32 -0
- package/packages/proxy/src/cli/args.ts +34 -0
- package/packages/proxy/src/cli/config-loader.ts +43 -0
- package/packages/proxy/src/cli/env-file.ts +43 -0
- package/packages/proxy/src/cli/main.ts +132 -0
- package/packages/proxy/src/cli/profile-server.ts +176 -0
- package/packages/proxy/src/cli/target.ts +7 -0
- package/packages/proxy/src/http/agent-drafts.ts +103 -0
- package/packages/proxy/src/http/agent-router.ts +69 -0
- package/packages/proxy/src/http/audit-view.ts +15 -0
- package/packages/proxy/src/http/auth-state.ts +44 -0
- package/packages/proxy/src/http/budget-gate.ts +45 -0
- package/packages/proxy/src/http/budget-warnings.ts +7 -0
- package/packages/proxy/src/http/cli-stream-response.ts +51 -0
- package/packages/proxy/src/http/client-identity.ts +51 -0
- package/packages/proxy/src/http/communication-graph.ts +139 -0
- package/packages/proxy/src/http/control-plane-guard.ts +56 -0
- package/packages/proxy/src/http/dashboard-assets.ts +115 -0
- package/packages/proxy/src/http/encoded-usage-meter.ts +32 -0
- package/packages/proxy/src/http/header-utils.ts +65 -0
- package/packages/proxy/src/http/identity-id.ts +11 -0
- package/packages/proxy/src/http/local-api-agent-actions.ts +17 -0
- package/packages/proxy/src/http/local-api-auth.ts +120 -0
- package/packages/proxy/src/http/local-api-consumer-actions.ts +20 -0
- package/packages/proxy/src/http/local-api-identity.ts +194 -0
- package/packages/proxy/src/http/local-api-io.ts +82 -0
- package/packages/proxy/src/http/local-api-keys.ts +126 -0
- package/packages/proxy/src/http/local-api-pipeline.ts +41 -0
- package/packages/proxy/src/http/local-api-plugin-actions.ts +31 -0
- package/packages/proxy/src/http/local-api-provider-actions.ts +181 -0
- package/packages/proxy/src/http/local-api-retention.ts +28 -0
- package/packages/proxy/src/http/local-api-runtime-auth.ts +119 -0
- package/packages/proxy/src/http/local-api-scope.ts +47 -0
- package/packages/proxy/src/http/local-api-state.ts +180 -0
- package/packages/proxy/src/http/local-api.ts +166 -0
- package/packages/proxy/src/http/password-policy.ts +5 -0
- package/packages/proxy/src/http/plugin-data.ts +38 -0
- package/packages/proxy/src/http/plugin-host.ts +87 -0
- package/packages/proxy/src/http/plugin-modules.ts +1 -0
- package/packages/proxy/src/http/plugin-page-loader.ts +24 -0
- package/packages/proxy/src/http/plugin-pipeline.ts +125 -0
- package/packages/proxy/src/http/provider-access.ts +33 -0
- package/packages/proxy/src/http/provider-http-test.ts +133 -0
- package/packages/proxy/src/http/provider-input.ts +39 -0
- package/packages/proxy/src/http/provider-routing-snapshot.ts +28 -0
- package/packages/proxy/src/http/provider-test.ts +149 -0
- package/packages/proxy/src/http/proxy-identity.ts +78 -0
- package/packages/proxy/src/http/public-bind.ts +8 -0
- package/packages/proxy/src/http/request-finish.ts +62 -0
- package/packages/proxy/src/http/request-path.ts +8 -0
- package/packages/proxy/src/http/request-policy.ts +46 -0
- package/packages/proxy/src/http/runtime-auth-proof.ts +55 -0
- package/packages/proxy/src/http/runtime-auth-registry.ts +105 -0
- package/packages/proxy/src/http/runtime-settings.ts +199 -0
- package/packages/proxy/src/http/runtime-state.ts +198 -0
- package/packages/proxy/src/http/server-io.ts +80 -0
- package/packages/proxy/src/http/server-types.ts +17 -0
- package/packages/proxy/src/http/server.ts +190 -0
- package/packages/proxy/src/http/session-secret.ts +19 -0
- package/packages/proxy/src/http/streaming-proxy.ts +88 -0
- package/packages/proxy/src/http/usage-accounting.ts +100 -0
- package/packages/proxy/src/http/usage-restore.ts +15 -0
- package/packages/proxy/src/runtime/cli-diagnostics.ts +64 -0
- package/packages/proxy/src/runtime/cli-env.ts +22 -0
- package/packages/proxy/src/runtime/cli-executor.ts +134 -0
- package/packages/proxy/src/runtime/cli-provider.ts +162 -0
- package/packages/proxy/src/runtime/cli-request.ts +79 -0
- package/packages/proxy/src/runtime/codex-runtime-config.ts +37 -0
- package/packages/proxy/src/runtime/runtime-profile.ts +170 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { pluginCatalog } from "../../../core/src/plugins/plugin-catalog.ts";
|
|
2
|
+
import { staticPluginPipeline } from "../../../core/src/plugins/static-pipeline.ts";
|
|
3
|
+
import { buildProviderCatalog, type ProviderConfig } from "../../../core/src/providers/provider-catalog.ts";
|
|
4
|
+
import type { AuditManifest } from "../../../core/src/manifest/audit-store.ts";
|
|
5
|
+
import { createCommunicationGraph, type CommunicationGraph } from "./communication-graph.ts";
|
|
6
|
+
import { createMemoryGraph, type MemoryGraph } from "../../../core/src/memory/memory-graph.ts";
|
|
7
|
+
import type { AuthUser } from "./auth-state.ts";
|
|
8
|
+
import type { IdentityStore } from "../../../core/src/identity/identity-store.ts";
|
|
9
|
+
import type { UsageSnapshotStore } from "../../../core/src/identity/usage-snapshot.ts";
|
|
10
|
+
import type { ResolvedAgent } from "../../../core/src/config/config-policies.ts";
|
|
11
|
+
import { restoreRuntimeAuthProviders } from "./runtime-auth-registry.ts";
|
|
12
|
+
import { loadRuntimeSettings } from "./runtime-settings.ts";
|
|
13
|
+
import type { RuntimeAuthProofStore } from "./runtime-auth-proof.ts";
|
|
14
|
+
import { requireSessionSecret } from "./session-secret.ts";
|
|
15
|
+
|
|
16
|
+
export type { ResolvedAgent };
|
|
17
|
+
export type RuntimeOptions = {
|
|
18
|
+
target: string;
|
|
19
|
+
dataDir?: string;
|
|
20
|
+
providers?: ProviderConfig[];
|
|
21
|
+
activeProviderId?: string;
|
|
22
|
+
configAgents?: ResolvedAgent[];
|
|
23
|
+
providerCatalogMode?: "auto" | "explicit";
|
|
24
|
+
configSource?: { kind: "env" | "file"; path?: string };
|
|
25
|
+
};
|
|
26
|
+
export const CONTROL_PLANE_LIMITS = {
|
|
27
|
+
agentDrafts: 25,
|
|
28
|
+
providerItems: 50,
|
|
29
|
+
graphNodes: 80,
|
|
30
|
+
graphEdges: 120,
|
|
31
|
+
requestBodyBytes: 8192,
|
|
32
|
+
idLength: 64,
|
|
33
|
+
labelLength: 80,
|
|
34
|
+
pluginIds: 20
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type AgentDraftMetadata = {
|
|
38
|
+
id: string;
|
|
39
|
+
label: string;
|
|
40
|
+
kind: "CI agent" | "Local agent" | "External agent";
|
|
41
|
+
providerId: string;
|
|
42
|
+
enabledPluginIds: string[];
|
|
43
|
+
tokenHash?: string;
|
|
44
|
+
tokenHashAlgorithm?: "sha256";
|
|
45
|
+
disabled?: boolean;
|
|
46
|
+
tokenLimit?: number;
|
|
47
|
+
status: "draft";
|
|
48
|
+
createdAt: string;
|
|
49
|
+
updatedAt: string;
|
|
50
|
+
};
|
|
51
|
+
export type AgentDraftView = Omit<AgentDraftMetadata, "tokenHash"> & { tokenHashPresent: boolean; tokenFingerprint?: string; usage: UsageTotals };
|
|
52
|
+
|
|
53
|
+
export type UsagePeriodTotals = { requests: number; inputTokens: number; outputTokens: number; costEur?: number };
|
|
54
|
+
export type UsageTotals = UsagePeriodTotals & { periods?: Record<string, UsagePeriodTotals> };
|
|
55
|
+
export type RoutingMode = "manual" | "distribute";
|
|
56
|
+
export type PluginLifecycle = { status: "disabled" | "booted" | "enabled" | "stopped" | "error"; hook?: string; error?: string };
|
|
57
|
+
|
|
58
|
+
export type RuntimeStateResult<T> = { ok: true; value: T } | { ok: false; status: number; error: string; reason?: string };
|
|
59
|
+
|
|
60
|
+
export type RuntimeState = {
|
|
61
|
+
requests: number;
|
|
62
|
+
compressedItems: number;
|
|
63
|
+
startedAt: string;
|
|
64
|
+
host: string;
|
|
65
|
+
port?: number;
|
|
66
|
+
dataDir?: string;
|
|
67
|
+
latest?: AuditManifest;
|
|
68
|
+
pluginEnabled: Record<string, boolean>;
|
|
69
|
+
pluginUpdatedAt: Record<string, string>;
|
|
70
|
+
pluginLifecycle: Record<string, PluginLifecycle>;
|
|
71
|
+
providers: ProviderConfig[];
|
|
72
|
+
activeProviderId: string;
|
|
73
|
+
providerSelectedAt: string;
|
|
74
|
+
routingMode: RoutingMode;
|
|
75
|
+
providerWeights: Record<string, number>;
|
|
76
|
+
pluginOrder: string[];
|
|
77
|
+
usageByProvider: Record<string, UsageTotals>;
|
|
78
|
+
usageByUser: Record<string, UsageTotals>;
|
|
79
|
+
usageByAgent: Record<string, UsageTotals>;
|
|
80
|
+
usageByKey: Record<string, UsageTotals>;
|
|
81
|
+
usageByTeam: Record<string, UsageTotals>;
|
|
82
|
+
consumerBudgets: Record<string, number>;
|
|
83
|
+
configAgents: ResolvedAgent[];
|
|
84
|
+
agentDrafts: AgentDraftMetadata[];
|
|
85
|
+
communicationGraph: CommunicationGraph;
|
|
86
|
+
memoryGraph: MemoryGraph;
|
|
87
|
+
sessionSecret: string;
|
|
88
|
+
authAttempts: Record<string, { count: number; resetAt: number }>;
|
|
89
|
+
runtimeAuthProofs: RuntimeAuthProofStore;
|
|
90
|
+
bootstrapSetup?: Promise<void>;
|
|
91
|
+
settingsLoadWarning?: string;
|
|
92
|
+
configSource: { kind: "env" | "file"; path?: string };
|
|
93
|
+
identity?: IdentityStore;
|
|
94
|
+
usageSnapshot?: UsageSnapshotStore;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export function createRuntimeState(options: RuntimeOptions, host: string): RuntimeState {
|
|
98
|
+
const now = new Date().toISOString();
|
|
99
|
+
const explicit = options.providerCatalogMode === "explicit";
|
|
100
|
+
if (explicit && !(options.providers ?? []).length) throw new Error("explicit provider config requires providers");
|
|
101
|
+
const restored: ReturnType<typeof restoreRuntimeAuthProviders> = explicit ? { providers: [] } : restoreRuntimeAuthProviders(options.dataDir);
|
|
102
|
+
const loadedSettings = loadRuntimeSettings(options.dataDir);
|
|
103
|
+
const settings = loadedSettings.settings;
|
|
104
|
+
const persistedProviders = explicit ? [] : settings.providers ?? [];
|
|
105
|
+
const providers = buildProviderCatalog(options.target, [...(options.providers ?? []), ...persistedProviders, ...restored.providers], process.env, { includeBuiltIns: !explicit, includeEnvProviders: !explicit });
|
|
106
|
+
const weights = Object.fromEntries(providers.map((provider) => [provider.id, 1]));
|
|
107
|
+
const requestedActive = options.activeProviderId ?? settings.activeProviderId ?? restored.activeProviderId ?? (explicit ? providers[0]?.id : "default") ?? "default";
|
|
108
|
+
return {
|
|
109
|
+
requests: 0,
|
|
110
|
+
compressedItems: 0,
|
|
111
|
+
startedAt: now,
|
|
112
|
+
host,
|
|
113
|
+
dataDir: options.dataDir,
|
|
114
|
+
pluginEnabled: normalizedPluginEnabled(settings.pluginEnabled),
|
|
115
|
+
pluginUpdatedAt: {},
|
|
116
|
+
pluginLifecycle: {},
|
|
117
|
+
providers,
|
|
118
|
+
activeProviderId: selectedProviderId(providers, requestedActive),
|
|
119
|
+
providerSelectedAt: now,
|
|
120
|
+
routingMode: cleanRoutingMode(settings.routingMode) ?? restored.routingMode ?? "manual",
|
|
121
|
+
providerWeights: { ...weights, ...(settings.providerWeights ?? {}) },
|
|
122
|
+
pluginOrder: settings.pluginOrder ?? [...staticPluginPipeline],
|
|
123
|
+
usageByProvider: {},
|
|
124
|
+
usageByUser: {},
|
|
125
|
+
usageByAgent: {},
|
|
126
|
+
usageByKey: {},
|
|
127
|
+
usageByTeam: {},
|
|
128
|
+
consumerBudgets: settings.consumerBudgets ?? {},
|
|
129
|
+
configAgents: options.configAgents ?? [],
|
|
130
|
+
agentDrafts: (settings.agentDrafts ?? []).filter((draft) => providers.some((provider) => provider.id === draft.providerId && provider.enabled !== false)),
|
|
131
|
+
communicationGraph: createCommunicationGraph(),
|
|
132
|
+
memoryGraph: createMemoryGraph(),
|
|
133
|
+
sessionSecret: requireSessionSecret(),
|
|
134
|
+
authAttempts: {},
|
|
135
|
+
runtimeAuthProofs: {},
|
|
136
|
+
settingsLoadWarning: loadedSettings.warning,
|
|
137
|
+
configSource: options.configSource ?? { kind: "env" }
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function activeProvider(state: RuntimeState): ProviderConfig {
|
|
142
|
+
return state.providers.find((provider) => provider.id === state.activeProviderId && provider.enabled !== false) ?? firstEnabledProvider(state.providers) ?? state.providers[0];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function repairActiveProvider(state: RuntimeState): boolean {
|
|
146
|
+
const id = activeProvider(state)?.id ?? "default";
|
|
147
|
+
if (state.activeProviderId === id) return false;
|
|
148
|
+
state.activeProviderId = id;
|
|
149
|
+
state.providerSelectedAt = new Date().toISOString();
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function distributionEligible(provider: ProviderConfig): boolean {
|
|
154
|
+
if (provider.id === "default" || provider.enabled === false || provider.allowDistribution === false) return false;
|
|
155
|
+
return provider.kind !== "cli" || provider.allowDistribution === true || Boolean(provider.runtimeAuthDir);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function isPluginEnabled(state: RuntimeState, id: string): boolean {
|
|
159
|
+
const plugin = pluginCatalog.find((item) => item.id === id);
|
|
160
|
+
return plugin ? state.pluginEnabled[id] ?? plugin.enabledByDefault : false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function enabledPluginIds(state: RuntimeState): string[] {
|
|
164
|
+
return pluginCatalog.filter((plugin) => isPluginEnabled(state, plugin.id)).map((plugin) => plugin.id);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function emptyUsage(): UsageTotals {
|
|
168
|
+
return { requests: 0, inputTokens: 0, outputTokens: 0, costEur: 0 };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function normalizedPluginEnabled(settings: Record<string, boolean> | undefined): Record<string, boolean> {
|
|
172
|
+
const enabled: Record<string, boolean> = {};
|
|
173
|
+
for (const plugin of pluginCatalog) {
|
|
174
|
+
const configured = settings?.[plugin.id];
|
|
175
|
+
enabled[plugin.id] = plugin.canToggle && typeof configured === "boolean" ? configured : plugin.enabledByDefault;
|
|
176
|
+
}
|
|
177
|
+
return enabled;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function selectedProviderId(providers: ProviderConfig[], requested: string): string {
|
|
181
|
+
const enabled = providers.find((provider) => provider.id === requested && provider.enabled !== false);
|
|
182
|
+
return enabled?.id ?? firstEnabledProvider(providers)?.id ?? providers[0]?.id ?? "default";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function firstEnabledProvider(providers: ProviderConfig[]): ProviderConfig | undefined {
|
|
186
|
+
return providers.find((provider) => provider.enabled !== false);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function cleanRoutingMode(value: unknown): RoutingMode | undefined {
|
|
190
|
+
return value === "manual" || value === "distribute" ? value : undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function providerWeight(state: RuntimeState, id: string): number {
|
|
194
|
+
const weight = state.providerWeights[id];
|
|
195
|
+
return typeof weight === "number" ? weight : 1;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export { agentTokensUsed, keyCostUsed, keyTokensUsed, orgCostUsed, orgTokensUsed, recordUsage, teamCostUsed, teamTokensUsed, usageForPeriod, userCostUsed, userTokensUsed, userUsageKey } from "./usage-accounting.ts";
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
|
|
4
|
+
export class HttpInputError extends Error {
|
|
5
|
+
status: number;
|
|
6
|
+
code: string;
|
|
7
|
+
constructor(status: number, code: string) {
|
|
8
|
+
super(code);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function listen(server: ReturnType<typeof createServer>, port: number, host: string): Promise<void> {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const onError = (error: Error) => {
|
|
17
|
+
server.off("error", onError);
|
|
18
|
+
reject(error);
|
|
19
|
+
};
|
|
20
|
+
server.once("error", onError);
|
|
21
|
+
server.listen(port, host, () => {
|
|
22
|
+
server.off("error", onError);
|
|
23
|
+
resolve();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readBody(req: IncomingMessage, timeoutMs = bodyTimeoutMs(), maxBytes = bodyLimitBytes()): Promise<string> {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const chunks: Buffer[] = [];
|
|
31
|
+
let size = 0;
|
|
32
|
+
let settled = false;
|
|
33
|
+
const timer = setTimeout(() => fail(new Error(`request body timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
34
|
+
req.on("data", (chunk) => {
|
|
35
|
+
size += Buffer.byteLength(chunk);
|
|
36
|
+
if (size > maxBytes) return fail(new HttpInputError(413, "request_body_too_large"));
|
|
37
|
+
chunks.push(Buffer.from(chunk));
|
|
38
|
+
});
|
|
39
|
+
req.on("aborted", () => fail(new Error("request body aborted")));
|
|
40
|
+
req.on("close", () => { if (!req.complete) fail(new Error("request body aborted")); });
|
|
41
|
+
req.on("error", fail);
|
|
42
|
+
req.on("end", () => done(Buffer.concat(chunks).toString("utf8")));
|
|
43
|
+
function done(value: string): void {
|
|
44
|
+
if (settled) return;
|
|
45
|
+
settled = true;
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
resolve(value);
|
|
48
|
+
}
|
|
49
|
+
function fail(error: Error): void {
|
|
50
|
+
if (settled) return;
|
|
51
|
+
settled = true;
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
reject(error);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function inputError(error: unknown): { status: number; code: string } | undefined {
|
|
59
|
+
return error instanceof HttpInputError ? { status: error.status, code: error.code } : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function bodyTimeoutMs(): number {
|
|
63
|
+
const configured = Number(process.env.MOLENKOPF_REQUEST_BODY_TIMEOUT_MS);
|
|
64
|
+
return Number.isFinite(configured) && configured > 0 ? configured : 30000;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function bodyLimitBytes(): number {
|
|
68
|
+
const configured = Number(process.env.MOLENKOPF_PROXY_BODY_LIMIT_BYTES);
|
|
69
|
+
return Number.isFinite(configured) && configured > 0 ? configured : 8 * 1024 * 1024;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function writeJson(res: ServerResponse, status: number, data: unknown) {
|
|
73
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
74
|
+
res.end(JSON.stringify(data));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function writeRedirect(res: ServerResponse, location: string) {
|
|
78
|
+
res.writeHead(302, { location });
|
|
79
|
+
res.end();
|
|
80
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ProviderConfig } from "../../../core/src/providers/provider-catalog.ts";
|
|
2
|
+
import type { ResolvedAgent } from "./runtime-state.ts";
|
|
3
|
+
|
|
4
|
+
export type ProxyOptions = {
|
|
5
|
+
target: string;
|
|
6
|
+
port: number;
|
|
7
|
+
host?: string;
|
|
8
|
+
allowPublicBind?: boolean;
|
|
9
|
+
dataDir?: string;
|
|
10
|
+
providers?: ProviderConfig[];
|
|
11
|
+
activeProviderId?: string;
|
|
12
|
+
configAgents?: ResolvedAgent[];
|
|
13
|
+
providerCatalogMode?: "auto" | "explicit";
|
|
14
|
+
configSource?: { kind: "env" | "file"; path?: string };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type RunningProxy = { port: number; close: () => Promise<void> };
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
3
|
+
import { AuditStore } from "../../../core/src/manifest/audit-store.ts";
|
|
4
|
+
import { EventBus } from "../../../core/src/events/event-bus.ts";
|
|
5
|
+
import { type RewriteAudit } from "../../../core/src/pipeline/openai-request-rewriter.ts";
|
|
6
|
+
import { estimateTokens } from "../../../core/src/utils/tokens.ts";
|
|
7
|
+
import { redactSecrets } from "../../../core/src/security/secret-redactor.ts";
|
|
8
|
+
import { builtinMiddlewares, runRequestPipeline, type PluginContext } from "./plugin-pipeline.ts";
|
|
9
|
+
import { orderIndex } from "./local-api-pipeline.ts";
|
|
10
|
+
import { extractConcepts } from "../../../core/src/memory/memory-extractor.ts";
|
|
11
|
+
import { recordConcepts } from "../../../core/src/memory/memory-graph.ts";
|
|
12
|
+
import { RetrievalStore } from "../../../core/src/store/retrieval-store.ts";
|
|
13
|
+
import { buildForwardHeaders, missingProviderCredential } from "./header-utils.ts";
|
|
14
|
+
import { handleLocalRequest } from "./local-api.ts";
|
|
15
|
+
import { createRuntimeState, emptyUsage, isPluginEnabled, type RuntimeState } from "./runtime-state.ts";
|
|
16
|
+
import { resolveRouting } from "./agent-router.ts";
|
|
17
|
+
import { buildManifest, finishRequest } from "./request-finish.ts";
|
|
18
|
+
import { resolveClientIdentity, stripMolenkopfAuthHeaders } from "./proxy-identity.ts";
|
|
19
|
+
import { checkBudgets } from "./budget-gate.ts";
|
|
20
|
+
import { withBudgetWarnings } from "./budget-warnings.ts";
|
|
21
|
+
import { IdentityStore } from "../../../core/src/identity/identity-store.ts";
|
|
22
|
+
import { UsageSnapshotStore } from "../../../core/src/identity/usage-snapshot.ts";
|
|
23
|
+
import { isCliProvider, runCliProvider } from "../runtime/cli-provider.ts";
|
|
24
|
+
import { canStreamOpenAiCli, streamOpenAiCliProvider } from "./cli-stream-response.ts";
|
|
25
|
+
import { forwardStream } from "./streaming-proxy.ts";
|
|
26
|
+
import { createResponseUsageScanner } from "./encoded-usage-meter.ts";
|
|
27
|
+
import { auditPath } from "./request-path.ts";
|
|
28
|
+
import { inputError, listen, readBody, writeJson, writeRedirect } from "./server-io.ts";
|
|
29
|
+
import { requirePublicBindFlag } from "./public-bind.ts";
|
|
30
|
+
import { providerAllowedForClient } from "./provider-access.ts";
|
|
31
|
+
import { restoreUsage } from "./usage-restore.ts";
|
|
32
|
+
import { handleDashboardRequest, isDashboardRequest } from "./dashboard-assets.ts";
|
|
33
|
+
import { createPluginHost, type PluginHost } from "./plugin-host.ts";
|
|
34
|
+
import { effectiveRequestPolicy, enforceModelPolicy, pluginAllowedByPolicy } from "./request-policy.ts";
|
|
35
|
+
import type { ProxyOptions, RunningProxy } from "./server-types.ts";
|
|
36
|
+
export async function startProxy(options: ProxyOptions): Promise<RunningProxy> {
|
|
37
|
+
const host = options.host ?? "127.0.0.1";
|
|
38
|
+
requirePublicBindFlag(host, options.allowPublicBind);
|
|
39
|
+
const state = createRuntimeState(options, host);
|
|
40
|
+
const identity = new IdentityStore(options.dataDir);
|
|
41
|
+
await identity.load();
|
|
42
|
+
state.identity = identity;
|
|
43
|
+
const usageSnapshot = new UsageSnapshotStore(options.dataDir);
|
|
44
|
+
const store = new RetrievalStore(options.dataDir);
|
|
45
|
+
const audit = new AuditStore(options.dataDir);
|
|
46
|
+
await restoreUsage(state, usageSnapshot, audit);
|
|
47
|
+
state.usageSnapshot = usageSnapshot;
|
|
48
|
+
const events = new EventBus(), pluginHost = createPluginHost(state, { store, events });
|
|
49
|
+
await pluginHost.boot();
|
|
50
|
+
const server = createServer((req, res) => handle(req, res, store, audit, events, state, pluginHost));
|
|
51
|
+
await listen(server, options.port, host);
|
|
52
|
+
const address = server.address();
|
|
53
|
+
const port = typeof address === "object" && address ? address.port : options.port;
|
|
54
|
+
state.port = port;
|
|
55
|
+
await pluginHost.start(port);
|
|
56
|
+
return {
|
|
57
|
+
port,
|
|
58
|
+
close: async () => {
|
|
59
|
+
await pluginHost.stop().catch(() => {});
|
|
60
|
+
usageSnapshot.schedule(state);
|
|
61
|
+
await usageSnapshot.close();
|
|
62
|
+
identity.close();
|
|
63
|
+
await new Promise<void>((resolve, reject) => server.close((err) => err ? reject(err) : resolve()));
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
async function handle(req: IncomingMessage, res: ServerResponse, store: RetrievalStore, audit: AuditStore, events: EventBus, state: RuntimeState, pluginHost: PluginHost) {
|
|
68
|
+
try {
|
|
69
|
+
if (req.url === "/") return writeRedirect(res, "/__molenkopf/dashboard");
|
|
70
|
+
const probePath = auditPath(req.url);
|
|
71
|
+
if (probePath === "/favicon.ico" || probePath.startsWith("/.well-known/appspecific/")) { res.writeHead(204); return res.end(); }
|
|
72
|
+
if (isDashboardRequest(req.url)) return await handleDashboardRequest(req, res);
|
|
73
|
+
if (req.url?.startsWith("/__molenkopf/")) return await handleLocalRequest(req, res, audit, events, state, pluginHost);
|
|
74
|
+
await handleProxy(req, res, store, audit, events, state, pluginHost);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const input = inputError(error);
|
|
77
|
+
if (input) return writeJson(res, input.status, { error: input.code });
|
|
78
|
+
events.emit("request_failed", { data: { error: "proxy_error" } });
|
|
79
|
+
writeJson(res, 502, { error: "proxy_error" });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function handleProxy(req: IncomingMessage, res: ServerResponse, store: RetrievalStore, auditStore: AuditStore, events: EventBus, state: RuntimeState, pluginHost: PluginHost) {
|
|
83
|
+
const started = Date.now();
|
|
84
|
+
const requestId = randomUUID();
|
|
85
|
+
const rawPath = req.url ?? "/";
|
|
86
|
+
const path = auditPath(rawPath);
|
|
87
|
+
const inbound = new Headers(req.headers as Record<string, string>);
|
|
88
|
+
const resolved = resolveClientIdentity(state.identity, inbound);
|
|
89
|
+
if (!resolved.keyOk) {
|
|
90
|
+
events.emit("request_failed", { requestId, data: { error: "invalid_api_key" } });
|
|
91
|
+
return writeJson(res, 401, { error: "invalid_api_key" });
|
|
92
|
+
}
|
|
93
|
+
const client = resolved.client;
|
|
94
|
+
stripMolenkopfAuthHeaders(inbound);
|
|
95
|
+
const budget = checkBudgets(state, client);
|
|
96
|
+
if (budget.ok === false) return rejectBudget(res, events, requestId, budget);
|
|
97
|
+
for (const warning of budget.warnings) events.emit("request_warning", { requestId, data: { warning } });
|
|
98
|
+
const routing = resolveRouting(state, inbound, client);
|
|
99
|
+
if (routing.ok === false) {
|
|
100
|
+
events.emit("request_failed", { requestId, data: { error: routing.error } });
|
|
101
|
+
return writeJson(res, routing.status, { error: routing.error });
|
|
102
|
+
}
|
|
103
|
+
let provider = routing.provider;
|
|
104
|
+
events.emit("request_started", { requestId, data: { method: req.method, path } });
|
|
105
|
+
const originalBody = await readBody(req);
|
|
106
|
+
const jsonRequest = (inbound.get("content-type") ?? "").includes("application/json");
|
|
107
|
+
const policy = effectiveRequestPolicy(state, inbound, client);
|
|
108
|
+
const pluginActive = (id: string) => isPluginEnabled(state, id) && pluginAllowedByPolicy(policy, id);
|
|
109
|
+
if (jsonRequest && originalBody) {
|
|
110
|
+
const modelPolicy = enforceModelPolicy(policy, originalBody);
|
|
111
|
+
if (modelPolicy.ok === false) { events.emit("request_failed", { requestId, data: { error: modelPolicy.error } }); return writeJson(res, modelPolicy.status, { error: modelPolicy.error }); }
|
|
112
|
+
}
|
|
113
|
+
if (pluginActive("obsidian-graph-plugin") && originalBody) recordConcepts(state.memoryGraph, extractConcepts(redactSecrets(originalBody).text), new Date().toISOString());
|
|
114
|
+
let body = originalBody;
|
|
115
|
+
let audit: RewriteAudit | undefined;
|
|
116
|
+
if (jsonRequest && originalBody) {
|
|
117
|
+
const ctx: PluginContext = {
|
|
118
|
+
requestId, method: req.method ?? "GET", path, consumerId: client.id, providerId: provider.id,
|
|
119
|
+
body: originalBody, redactedSecrets: 0, compressedItems: 0, savedTokens: 0, retrievalIds: [], compressorsUsed: [], notes: [],
|
|
120
|
+
usageOf: (id) => state.usageByAgent[id] ?? emptyUsage(),
|
|
121
|
+
note(message) { ctx.notes.push(message); }
|
|
122
|
+
};
|
|
123
|
+
const ordered = [...builtinMiddlewares].sort((a, b) => orderIndex(state, a.id) - orderIndex(state, b.id));
|
|
124
|
+
await runRequestPipeline(ctx, pluginActive, { store }, ordered);
|
|
125
|
+
if (ctx.block) { events.emit("request_failed", { requestId, data: { error: ctx.block.error } }); return writeJson(res, ctx.block.status, { error: ctx.block.error }); }
|
|
126
|
+
if (ctx.providerId !== provider.id) {
|
|
127
|
+
const next = state.providers.find((item) => item.id === ctx.providerId && item.enabled !== false);
|
|
128
|
+
if (next) {
|
|
129
|
+
if (!providerAllowedForClient(client, next.id)) { events.emit("request_failed", { requestId, data: { error: "provider_forbidden" } }); return writeJson(res, 403, { error: "provider_forbidden" }); }
|
|
130
|
+
provider = next;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
body = ctx.body;
|
|
134
|
+
audit = {
|
|
135
|
+
compressedItems: ctx.compressedItems,
|
|
136
|
+
estimatedOriginalTokens: estimateTokens(originalBody),
|
|
137
|
+
estimatedCompressedTokens: estimateTokens(body),
|
|
138
|
+
estimatedSavedTokens: ctx.savedTokens,
|
|
139
|
+
redactedSecrets: ctx.redactedSecrets,
|
|
140
|
+
retrievalIds: ctx.retrievalIds,
|
|
141
|
+
compressorsUsed: ctx.compressorsUsed,
|
|
142
|
+
warnings: ctx.notes
|
|
143
|
+
};
|
|
144
|
+
if (ctx.compressedItems) events.emit("request_compressed", { requestId, data: { items: ctx.compressedItems } });
|
|
145
|
+
}
|
|
146
|
+
audit = withBudgetWarnings(audit, budget.warnings);
|
|
147
|
+
const target = provider.target;
|
|
148
|
+
if (missingProviderCredential(provider)) return writeJson(res, 502, { error: "missing_provider_credential" });
|
|
149
|
+
const headers = buildForwardHeaders(inbound, provider);
|
|
150
|
+
if (body) headers.set("content-length", String(Buffer.byteLength(body)));
|
|
151
|
+
if (isCliProvider(provider)) {
|
|
152
|
+
events.emit("request_forwarded", { requestId, data: { path } });
|
|
153
|
+
if (canStreamOpenAiCli(path, body)) {
|
|
154
|
+
const cli = await streamOpenAiCliProvider(provider, body, requestId, res);
|
|
155
|
+
const manifest = buildManifest(requestId, req.method ?? "GET", path, target, provider.id, cli.status, Date.now() - started, client, audit, cli.usage);
|
|
156
|
+
await finishRequest(manifest, auditStore, events, state, pluginHost);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const cli = await runCliProvider(provider, body, requestId, path);
|
|
161
|
+
const manifest = buildManifest(requestId, req.method ?? "GET", path, target, provider.id, cli.status, Date.now() - started, client, audit, cli.usage);
|
|
162
|
+
await finishRequest(manifest, auditStore, events, state, pluginHost);
|
|
163
|
+
res.writeHead(cli.status, cli.headers);
|
|
164
|
+
return res.end(cli.body);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
const manifest = buildManifest(requestId, req.method ?? "GET", path, target, provider.id, 502, Date.now() - started, client, audit);
|
|
167
|
+
await finishRequest(manifest, auditStore, events, state, pluginHost);
|
|
168
|
+
return writeJson(res, 502, { error: "proxy_error", requestId });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
events.emit("request_forwarded", { requestId, data: { path } });
|
|
172
|
+
let scanner = createResponseUsageScanner(undefined), statusCode: number;
|
|
173
|
+
try {
|
|
174
|
+
const result = await forwardStream(res, target, rawPath, req.method ?? "GET", headers, body || undefined, {
|
|
175
|
+
allowPrivateTarget: provider.kind === "local" || provider.id === "default",
|
|
176
|
+
onResponseHead: (_status, responseHeaders) => { scanner = createResponseUsageScanner(headerValue(responseHeaders["content-encoding"])); },
|
|
177
|
+
onResponseBody: (chunk) => { scanner.feed(chunk); }
|
|
178
|
+
});
|
|
179
|
+
statusCode = result.statusCode;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
const manifest = buildManifest(requestId, req.method ?? "GET", path, target, provider.id, 502, Date.now() - started, client, audit);
|
|
182
|
+
await finishRequest(manifest, auditStore, events, state, pluginHost);
|
|
183
|
+
if (!res.headersSent) return writeJson(res, 502, { error: "proxy_error", requestId });
|
|
184
|
+
return res.end();
|
|
185
|
+
}
|
|
186
|
+
const manifest = buildManifest(requestId, req.method ?? "GET", path, target, provider.id, statusCode, Date.now() - started, client, audit, await scanner.finish());
|
|
187
|
+
await finishRequest(manifest, auditStore, events, state, pluginHost);
|
|
188
|
+
}
|
|
189
|
+
function rejectBudget(res: ServerResponse, events: EventBus, requestId: string, budget: Exclude<ReturnType<typeof checkBudgets>, { ok: true }>) { events.emit("request_failed", { requestId, data: { error: budget.error } }); res.writeHead(budget.status, { "content-type": "application/json", "retry-after": "60" }); return res.end(JSON.stringify({ error: budget.error, tier: budget.tier, scope: budget.scopeId, metric: budget.metric })); }
|
|
190
|
+
const headerValue = (value: number | string | string[] | undefined) => Array.isArray(value) ? value[0] : typeof value === "string" ? value : undefined;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const MIN_LENGTH = 32;
|
|
2
|
+
const PLACEHOLDERS = new Set([
|
|
3
|
+
"your-super-secret-key",
|
|
4
|
+
"replace-with-at-least-32-random-characters",
|
|
5
|
+
"changeme",
|
|
6
|
+
"change-me",
|
|
7
|
+
"secret",
|
|
8
|
+
"password"
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const HELP = "MOLENKOPF_SESSION_SECRET is required. Copy .env.example to .env, set a unique value with at least 32 characters, and pass it to Docker with --env-file .env. For source runs, place it in ./.env or export it in your shell.";
|
|
12
|
+
|
|
13
|
+
export function requireSessionSecret(env: Record<string, string | undefined> = process.env): string {
|
|
14
|
+
const value = env.MOLENKOPF_SESSION_SECRET?.trim() ?? "";
|
|
15
|
+
if (!value || value.length < MIN_LENGTH || PLACEHOLDERS.has(value.toLowerCase())) {
|
|
16
|
+
throw new Error(HELP);
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { request as httpRequest, type ServerResponse, type OutgoingHttpHeaders } from "node:http";
|
|
2
|
+
import { request as httpsRequest } from "node:https";
|
|
3
|
+
import { resolveConnectTarget } from "../../../core/src/security/target-policy.ts";
|
|
4
|
+
|
|
5
|
+
// Transparent streaming proxy: forward to upstream and stream the response
|
|
6
|
+
// straight back to the client, preserving status, headers, and encoding
|
|
7
|
+
// byte-for-byte. Node built-ins only (no fetch — fetch auto-decodes gzip and
|
|
8
|
+
// would break transparency for streaming/compressed responses).
|
|
9
|
+
|
|
10
|
+
export type StreamObserver = {
|
|
11
|
+
onResponseHead?: (statusCode: number, headers: OutgoingHttpHeaders) => void;
|
|
12
|
+
onResponseBody?: (chunk: Buffer) => void;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
allowPrivateTarget?: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type ForwardResult = { statusCode: number };
|
|
17
|
+
|
|
18
|
+
export async function forwardStream(res: ServerResponse, target: string, rawPath: string, method: string, headers: Headers, body: string | undefined, observer: StreamObserver = {}): Promise<ForwardResult> {
|
|
19
|
+
const upstream = new URL(originFormTarget(rawPath), target.endsWith("/") ? target : `${target}/`);
|
|
20
|
+
const checked = await resolveConnectTarget(upstream.toString(), { path: "provider target", allowPrivate: observer.allowPrivateTarget, allowSearch: true });
|
|
21
|
+
const transport = checked.url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
22
|
+
return new Promise<ForwardResult>((resolve, reject) => {
|
|
23
|
+
const timeoutMs = observer.timeoutMs ?? upstreamTimeoutMs();
|
|
24
|
+
let settled = false;
|
|
25
|
+
const done = (value: ForwardResult) => { if (!settled) { settled = true; resolve(value); } };
|
|
26
|
+
const fail = (error: Error) => { if (!settled) { settled = true; reject(error); } };
|
|
27
|
+
const upstreamReq = transport(checked.url, { method, headers: toOutgoingHeaders(headers, checked.url.host), lookup: pinnedLookup(checked.address, checked.family) }, (upstreamRes) => {
|
|
28
|
+
const statusCode = upstreamRes.statusCode ?? 502;
|
|
29
|
+
const safeHeaders = filterResponseHeaders(upstreamRes.headers);
|
|
30
|
+
observer.onResponseHead?.(statusCode, safeHeaders);
|
|
31
|
+
res.writeHead(statusCode, safeHeaders);
|
|
32
|
+
if (observer.onResponseBody) upstreamRes.on("data", (chunk: Buffer | string) => observer.onResponseBody?.(toBuffer(chunk)));
|
|
33
|
+
upstreamRes.on("end", () => done({ statusCode }));
|
|
34
|
+
upstreamRes.on("aborted", () => fail(new Error("upstream response aborted")));
|
|
35
|
+
upstreamRes.on("error", fail);
|
|
36
|
+
upstreamRes.pipe(res);
|
|
37
|
+
});
|
|
38
|
+
upstreamReq.setTimeout(timeoutMs, () => upstreamReq.destroy(new Error(`upstream timed out after ${timeoutMs}ms`)));
|
|
39
|
+
upstreamReq.on("error", fail);
|
|
40
|
+
res.on("close", () => { if (!res.writableEnded) upstreamReq.destroy(); });
|
|
41
|
+
if (body) upstreamReq.end(body);
|
|
42
|
+
else upstreamReq.end();
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function pinnedLookup(address: string, family: 4 | 6) {
|
|
47
|
+
return (_hostname: string, _options: unknown, callback: (error: NodeJS.ErrnoException | null, address: string, family: number) => void) => {
|
|
48
|
+
callback(null, address, family);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function upstreamTimeoutMs(): number {
|
|
53
|
+
const configured = Number(process.env.MOLENKOPF_UPSTREAM_TIMEOUT_MS);
|
|
54
|
+
return Number.isFinite(configured) && configured > 0 ? configured : 120000;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function originFormTarget(rawPath: string): string {
|
|
58
|
+
if (!rawPath.startsWith("/") || rawPath.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(rawPath)) {
|
|
59
|
+
throw new Error("invalid_request_target");
|
|
60
|
+
}
|
|
61
|
+
return rawPath;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function toOutgoingHeaders(headers: Headers, host: string): OutgoingHttpHeaders {
|
|
65
|
+
const out: OutgoingHttpHeaders = {};
|
|
66
|
+
headers.forEach((value, key) => { out[key] = value; });
|
|
67
|
+
out.host = host;
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function filterResponseHeaders(headers: OutgoingHttpHeaders): OutgoingHttpHeaders {
|
|
72
|
+
const blocked = new Set([
|
|
73
|
+
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
|
74
|
+
"te", "trailer", "transfer-encoding", "upgrade", "set-cookie",
|
|
75
|
+
"content-security-policy", "content-security-policy-report-only",
|
|
76
|
+
"cross-origin-opener-policy", "cross-origin-resource-policy",
|
|
77
|
+
"cross-origin-embedder-policy", "permissions-policy"
|
|
78
|
+
]);
|
|
79
|
+
const out: OutgoingHttpHeaders = {};
|
|
80
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
81
|
+
if (!blocked.has(key.toLowerCase())) out[key] = value;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toBuffer(chunk: Buffer | string): Buffer {
|
|
87
|
+
return typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
88
|
+
}
|