-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.ts
More file actions
267 lines (247 loc) · 11.2 KB
/
Copy pathserver.ts
File metadata and controls
267 lines (247 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
import "./src/loadEnv.js";
import express, { Request, Response, NextFunction } from "express";
import fs from "node:fs";
import path from "node:path";
import { ensureKeyExists, packageExtension } from "./src/packager.js";
import { startScheduler } from "./src/scheduler.js";
import { atomicWriteCreds, ensureCredsStore, getCredsStorePath } from "./src/rotate.js";
import { securityHeadersMiddleware, safeCorsMiddleware, createTokenAuthMiddleware, resolveAdminToken } from "./src/middleware/security.js";
import { createCredsRouter } from "./src/routes/credsRoutes.js";
import { createRoutingRouter } from "./src/routes/routingRoutes.js";
import { createInstancesRouter } from "./src/routes/instancesRoutes.js";
import { createBuilderRouter } from "./src/routes/builderRoutes.js";
import { createRotationRouter } from "./src/routes/rotationRoutes.js";
import { createSystemRouter } from "./src/routes/systemRoutes.js";
import { createAuthRouter, createCookieAuthenticator } from "./src/routes/authRoutes.js";
import { createProxiesRouter } from "./src/routes/proxiesRoutes.js";
import { initDefaultProxyIfNeeded } from "./src/proxies.js";
import { initDashboardCredentials } from "./src/auth.js";
import { renderDashboardHtml } from "./src/views/dashboardView.js";
// .env is loaded by the "./src/loadEnv.js" side-effect import above - before
// any module body (scheduler, auth, packager...) reads env at load time.
const app = express();
const PORT = parseInt(process.env.PORT || "3000", 10);
const HOST = process.env.HOST || "0.0.0.0";
// Express 4 does not route async rejections to the error handler; this net
// keeps an unforeseen rejected promise from taking the whole server down.
process.on("unhandledRejection", (reason) => {
console.error("[unhandledRejection]", reason);
});
// Trust proxy configuration: controls how Express resolves client IPs from
// X-Forwarded-For. Leave disabled (default) unless the server runs behind a
// reverse proxy such as nginx - otherwise clients can spoof their IP and
// bypass rate limits. Valid values: false | true | <number of trusted hops>.
const TRUST_PROXY_RAW = (process.env.TRUST_PROXY || "false").trim();
let trustProxySetting: boolean | number = false;
if (TRUST_PROXY_RAW === "true") {
trustProxySetting = true;
} else if (/^\d+$/.test(TRUST_PROXY_RAW) && TRUST_PROXY_RAW !== "0") {
trustProxySetting = parseInt(TRUST_PROXY_RAW, 10);
}
app.set("trust proxy", trustProxySetting);
const DEFAULT_TOKEN = "corp-proxy-secret-token-change-me";
const EXT_SHARED_TOKEN = process.env.EXT_SHARED_TOKEN || DEFAULT_TOKEN;
const isDefaultTokenInUse = EXT_SHARED_TOKEN === DEFAULT_TOKEN;
// Two-token model: the fleet token (EXT_SHARED_TOKEN) is low-privilege - it
// authenticates extensions and is intentionally baked into CRX/GPO artifacts.
// The admin token (ADMIN_TOKEN) never leaves the server and unlocks every
// management API. Keeping them apart stops a leaked artifact or a workstation
// registry read from granting full admin access.
let ADMIN_TOKEN: string;
let adminTokenGenerated = false;
try {
const resolved = resolveAdminToken(process.env);
ADMIN_TOKEN = resolved.token;
adminTokenGenerated = resolved.generated;
} catch (err) {
console.error("[pec-server] FATAL:", err instanceof Error ? err.message : err);
process.exit(1);
}
if (ADMIN_TOKEN === EXT_SHARED_TOKEN) {
console.error("[SECURITY WARNING] ADMIN_TOKEN must be distinct from EXT_SHARED_TOKEN - the fleet token ships in public artifacts.");
process.exit(1);
}
// PUBLIC_BASE_URL must be a bare origin when set: it is baked verbatim into
// updates.xml codebase and GPO reg config, so a sloppy value poisons fleet
// artifacts. Invalid value -> fail fast; missing -> loud one-time warning.
const PUBLIC_BASE_URL_RAW = (process.env.PUBLIC_BASE_URL || "").trim();
if (PUBLIC_BASE_URL_RAW) {
if (!/^https?:\/\/[a-z0-9.\-]+(:\d{1,5})?$/i.test(PUBLIC_BASE_URL_RAW.replace(/\/+$/, "")) || PUBLIC_BASE_URL_RAW !== PUBLIC_BASE_URL_RAW.replace(/\/+$/, "")) {
console.error(
`[pec-server] FATAL: PUBLIC_BASE_URL must be a bare origin like https://pec.example.corp (no path, no trailing slash). Got: "${PUBLIC_BASE_URL_RAW}"`
);
process.exit(1);
}
} else {
console.warn(
"[SECURITY WARNING] PUBLIC_BASE_URL is not set - generated artifacts (updates.xml, GPO, pacUrl) will be derived from the incoming Host header. Set PUBLIC_BASE_URL in .env for production."
);
}
const CREDS_STORE = getCredsStorePath();
// Dashboard login credentials (username + password, scrypt-hashed on disk).
// Initialized from dashboard_auth.json, or awaits first-run onboarding
// via ADMIN_TOKEN if the file is not yet created.
const ADMIN_USERNAME = (process.env.ADMIN_USERNAME || "admin").trim().toLowerCase() || "admin";
const credBootstrap = initDashboardCredentials({ username: ADMIN_USERNAME, fallbackPassword: ADMIN_TOKEN, autoCreate: false });
if (credBootstrap.setupRequired) {
console.log(
"[auth] Initial setup required. Open the dashboard to authenticate with ADMIN_TOKEN and configure your credentials."
);
} else if (credBootstrap.usingFallbackPassword) {
console.warn(
"[SECURITY WARNING] Dashboard password defaults to ADMIN_TOKEN (username: " + ADMIN_USERNAME + "). " +
"Set ADMIN_PASSWORD in .env or change the password in the dashboard settings."
);
}
// Initialize initial credentials if not found (random password, never hardcoded)
if (!fs.existsSync(CREDS_STORE)) {
console.log(`[pec-server] Initializing credentials storage at ${CREDS_STORE}`);
ensureCredsStore(CREDS_STORE);
}
initDefaultProxyIfNeeded();
// Ensure RSA private/public key and base extension distribution package exist
try {
ensureKeyExists();
const initialBaseUrl = (process.env.PUBLIC_BASE_URL || `http://localhost:${PORT}`).replace(/\/+$/, "");
packageExtension(initialBaseUrl);
console.log(`[pec-server] Initial Chrome Extension package generated for ${initialBaseUrl}`);
} catch (err) {
console.warn("[pec-server] Initial packaging notice:", err);
}
// Start background rotation scheduler
startScheduler();
// Security Middleware: Headers & CORS
app.use(securityHeadersMiddleware);
app.use(safeCorsMiddleware);
app.use(express.json({ limit: "5mb" }));
app.use(express.urlencoded({ extended: true }));
// Authentication gate for management APIs. Two ways in:
// 1. X-Admin-Token bearer header (scripts, automation, API testers);
// 2. a dashboard session cookie (HttpOnly, SameSite=Strict) with the CSRF
// marker header - the browser never stores the admin token itself.
// /api/sync stays public at the routing layer because it carries its own
// token verification and sliding-window rate limiter (the extension fleet
// authenticates there); /api/ip-echo is a diagnostic echo endpoint used by
// extension popups; /api/auth/* power the dashboard onboarding and login.
const adminAuth = createTokenAuthMiddleware(() => ADMIN_TOKEN, "x-admin-token", createCookieAuthenticator());
const PUBLIC_API_PATHS = new Set([
"/ip-echo",
"/sync",
"/auth/status",
"/auth/setup-verify",
"/auth/setup-credentials",
"/auth/login",
"/auth/session",
]);
app.use("/api", (req: Request, res: Response, next: NextFunction) => {
if (PUBLIC_API_PATHS.has(req.path)) {
return next();
}
return adminAuth(req, res, next);
});
// Dashboard static assets (dashboard.css / dashboard.js) - the client script
// ships as a real .js file so CI can syntax-check it (a TS-only cast once
// leaked into the inline script and killed every dashboard handler).
app.use(
express.static(path.resolve("./public"), {
setHeaders: (res) => {
res.setHeader("Cache-Control", "no-cache");
},
})
);
// Chrome Extension distribution updates
app.use(
"/updates",
express.static(path.resolve("./dist/updates"), {
setHeaders: (res, filePath) => {
if (filePath.endsWith(".crx")) {
res.setHeader("Content-Type", "application/x-chrome-extension");
res.setHeader("Content-Disposition", 'attachment; filename="extension.crx"');
} else if (filePath.endsWith(".zip")) {
res.setHeader("Content-Type", "application/zip");
res.setHeader("Content-Disposition", 'attachment; filename="corp-proxy-extension.zip"');
} else if (filePath.endsWith(".xml")) {
res.setHeader("Content-Type", "application/xml; charset=utf-8");
}
},
})
);
// Mount Modular Routers
app.use(createAuthRouter(() => ADMIN_TOKEN));
app.use(createCredsRouter(() => EXT_SHARED_TOKEN));
app.use(createRoutingRouter());
app.use(createInstancesRouter());
app.use(createBuilderRouter(() => EXT_SHARED_TOKEN, () => ADMIN_TOKEN));
app.use(createRotationRouter());
app.use(createProxiesRouter());
app.use(
createSystemRouter({
port: PORT,
getFleetToken: () => EXT_SHARED_TOKEN,
defaultFleetToken: DEFAULT_TOKEN,
adminTokenConfigured: Boolean(process.env.ADMIN_TOKEN),
credsStorePath: CREDS_STORE,
})
);
// Management Dashboard UI
app.get("/", (req: Request, res: Response) => {
if (req.headers.accept?.includes("application/json") && !req.headers.accept?.includes("text/html")) {
return res.json({
title: "Corp Proxy Auth & Extension Studio",
version: "1.4.0",
description: "Mini-server managing selective routing, GeoBases, extension constructor studio, GPO distribution, and 3x-ui rotation.",
endpoints: [
"GET /healthz",
"GET /creds",
"POST /api/sync",
"GET /proxy.pac",
"GET /updates/updates.xml",
"GET /updates/extension.crx",
"GET /api/routing/profiles",
"POST /api/routing/profiles",
"GET /api/routing/presets",
"GET /api/builder/config",
"POST /api/builder/build",
"GET /api/builder/files",
"POST /api/builder/file",
"GET /api/instances",
"POST /api/instances/assign-profile",
"GET /api/rotation/config",
"POST /api/rotation/config",
"POST /api/rotation/rotate-now",
"POST /api/3xui/test",
"GET /api/status",
"GET /api/github/releases",
],
});
}
const html = renderDashboardHtml({
isDefaultTokenInUse,
port: PORT,
});
// Content-Security-Policy: blocks loading of external scripts/styles and
// neutralizes whole classes of injected-content attacks. 'unsafe-inline' in
// script-src is still required by the dashboard's inline onclick handlers
// (the script body itself ships as /dashboard.js); all dynamic values are
// additionally HTML-escaped at render time.
res.setHeader(
"Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'none'; " +
"base-uri 'self'; form-action 'self'"
);
res.type("html").send(html);
});
app.listen(PORT, HOST, () => {
console.log(`[pec-server] PEC Proxy Server running on http://${HOST}:${PORT}`);
if (isDefaultTokenInUse) {
console.warn(`[SECURITY WARNING] The default authentication token is in use! Please configure EXT_SHARED_TOKEN in .env for production safety.`);
}
if (adminTokenGenerated) {
console.warn(
`[SECURITY WARNING] ADMIN_TOKEN is not configured - generated a development-only admin token for this run: ${ADMIN_TOKEN}\n` +
`[SECURITY WARNING] Development only: restarting the server will change this token. Set ADMIN_TOKEN in .env for a stable value.`
);
}
});
export default app;