codex-endpoint-switcher 1.0.0 → 1.1.0

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.
@@ -0,0 +1,419 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+ const os = require("node:os");
4
+ const { randomBytes, scryptSync, timingSafeEqual } = require("node:crypto");
5
+ const express = require("express");
6
+ const { DatabaseSync } = require("node:sqlite");
7
+
8
+ const defaultRoot = path.join(os.homedir(), ".codex-cloud-sync");
9
+ const defaultDbPath = path.join(defaultRoot, "cloud-sync.db");
10
+ const defaultHost = process.env.SYNC_SERVER_HOST || "127.0.0.1";
11
+ const defaultPort = Number(process.env.SYNC_SERVER_PORT || 3190);
12
+ const sessionTtlDays = Number(process.env.SYNC_SERVER_SESSION_DAYS || 30);
13
+
14
+ function wrapAsync(handler) {
15
+ return async (req, res, next) => {
16
+ try {
17
+ const data = await handler(req, res);
18
+ res.json({ ok: true, data });
19
+ } catch (error) {
20
+ next(error);
21
+ }
22
+ };
23
+ }
24
+
25
+ function nowIso() {
26
+ return new Date().toISOString();
27
+ }
28
+
29
+ function addDaysToNow(days) {
30
+ return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
31
+ }
32
+
33
+ function normalizeUsername(value) {
34
+ const username = String(value || "").trim();
35
+ if (!username) {
36
+ throw new Error("账号不能为空。");
37
+ }
38
+
39
+ if (username.length < 3 || username.length > 64) {
40
+ throw new Error("账号长度需要在 3 到 64 个字符之间。");
41
+ }
42
+
43
+ if (/\s/.test(username)) {
44
+ throw new Error("账号不能包含空白字符。");
45
+ }
46
+
47
+ return username;
48
+ }
49
+
50
+ function normalizePassword(value) {
51
+ const password = String(value || "");
52
+ if (!password.trim()) {
53
+ throw new Error("密码不能为空。");
54
+ }
55
+
56
+ if (password.length < 6) {
57
+ throw new Error("密码至少需要 6 位。");
58
+ }
59
+
60
+ return password;
61
+ }
62
+
63
+ function hashPassword(password, salt) {
64
+ return scryptSync(password, salt, 64).toString("base64url");
65
+ }
66
+
67
+ function verifyPassword(password, salt, expectedHash) {
68
+ const actual = Buffer.from(hashPassword(password, salt), "base64url");
69
+ const expected = Buffer.from(String(expectedHash || ""), "base64url");
70
+
71
+ if (actual.length !== expected.length) {
72
+ return false;
73
+ }
74
+
75
+ return timingSafeEqual(actual, expected);
76
+ }
77
+
78
+ function ensureParentDir(filePath) {
79
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
80
+ }
81
+
82
+ function createDatabase(dbPath) {
83
+ ensureParentDir(dbPath);
84
+ const db = new DatabaseSync(dbPath);
85
+
86
+ db.exec(`
87
+ PRAGMA journal_mode = WAL;
88
+ PRAGMA foreign_keys = ON;
89
+
90
+ CREATE TABLE IF NOT EXISTS users (
91
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
92
+ username TEXT NOT NULL UNIQUE,
93
+ password_salt TEXT NOT NULL,
94
+ password_hash TEXT NOT NULL,
95
+ created_at TEXT NOT NULL,
96
+ updated_at TEXT NOT NULL
97
+ );
98
+
99
+ CREATE TABLE IF NOT EXISTS sessions (
100
+ token TEXT PRIMARY KEY,
101
+ user_id INTEGER NOT NULL,
102
+ expires_at TEXT NOT NULL,
103
+ created_at TEXT NOT NULL,
104
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
105
+ );
106
+
107
+ CREATE TABLE IF NOT EXISTS sync_profiles (
108
+ user_id INTEGER PRIMARY KEY,
109
+ sync_code TEXT NOT NULL,
110
+ endpoint_count INTEGER NOT NULL DEFAULT 0,
111
+ updated_at TEXT NOT NULL,
112
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
113
+ );
114
+ `);
115
+
116
+ return db;
117
+ }
118
+
119
+ function clearExpiredSessions(db) {
120
+ db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(nowIso());
121
+ }
122
+
123
+ function createSession(db, userId) {
124
+ clearExpiredSessions(db);
125
+ const token = randomBytes(32).toString("base64url");
126
+ const createdAt = nowIso();
127
+ const expiresAt = addDaysToNow(sessionTtlDays);
128
+
129
+ db.prepare(
130
+ `
131
+ INSERT INTO sessions (token, user_id, expires_at, created_at)
132
+ VALUES (?, ?, ?, ?)
133
+ `,
134
+ ).run(token, userId, expiresAt, createdAt);
135
+
136
+ return {
137
+ token,
138
+ expiresAt,
139
+ };
140
+ }
141
+
142
+ function getUserByUsername(db, username) {
143
+ return (
144
+ db
145
+ .prepare(
146
+ `
147
+ SELECT id, username, password_salt AS passwordSalt, password_hash AS passwordHash
148
+ FROM users
149
+ WHERE username = ?
150
+ `,
151
+ )
152
+ .get(username) || null
153
+ );
154
+ }
155
+
156
+ function getSessionWithUser(db, token) {
157
+ clearExpiredSessions(db);
158
+
159
+ return (
160
+ db
161
+ .prepare(
162
+ `
163
+ SELECT
164
+ sessions.token AS token,
165
+ sessions.expires_at AS expiresAt,
166
+ users.id AS userId,
167
+ users.username AS username
168
+ FROM sessions
169
+ JOIN users ON users.id = sessions.user_id
170
+ WHERE sessions.token = ? AND sessions.expires_at > ?
171
+ `,
172
+ )
173
+ .get(token, nowIso()) || null
174
+ );
175
+ }
176
+
177
+ function getSyncProfile(db, userId) {
178
+ return (
179
+ db
180
+ .prepare(
181
+ `
182
+ SELECT
183
+ sync_code AS syncCode,
184
+ endpoint_count AS endpointCount,
185
+ updated_at AS updatedAt
186
+ FROM sync_profiles
187
+ WHERE user_id = ?
188
+ `,
189
+ )
190
+ .get(userId) || null
191
+ );
192
+ }
193
+
194
+ function buildAuthMiddleware(db) {
195
+ return (req, _res, next) => {
196
+ const authorization = String(req.headers.authorization || "").trim();
197
+ const matched = authorization.match(/^Bearer\s+(.+)$/i);
198
+ if (!matched) {
199
+ const error = new Error("缺少登录令牌。");
200
+ error.statusCode = 401;
201
+ next(error);
202
+ return;
203
+ }
204
+
205
+ const session = getSessionWithUser(db, matched[1].trim());
206
+ if (!session) {
207
+ const error = new Error("登录令牌无效或已过期,请重新登录。");
208
+ error.statusCode = 401;
209
+ next(error);
210
+ return;
211
+ }
212
+
213
+ req.auth = session;
214
+ next();
215
+ };
216
+ }
217
+
218
+ function createCloudSyncApp(options = {}) {
219
+ const dbPath = path.resolve(options.dbPath || process.env.SYNC_SERVER_DB_PATH || defaultDbPath);
220
+ const host = options.host || defaultHost;
221
+ const port = Number(options.port || defaultPort);
222
+ const db = createDatabase(dbPath);
223
+ const authRequired = buildAuthMiddleware(db);
224
+ const app = express();
225
+
226
+ app.use(express.json({ limit: "2mb" }));
227
+
228
+ app.get("/api/health", (_req, res) => {
229
+ res.json({
230
+ ok: true,
231
+ data: {
232
+ status: "ok",
233
+ host,
234
+ port,
235
+ dbPath,
236
+ },
237
+ });
238
+ });
239
+
240
+ app.post(
241
+ "/api/auth/register",
242
+ wrapAsync(async (req) => {
243
+ const username = normalizeUsername(req.body.username);
244
+ const password = normalizePassword(req.body.password);
245
+ const existing = getUserByUsername(db, username);
246
+
247
+ if (existing) {
248
+ const error = new Error("这个账号已经存在,请直接登录。");
249
+ error.statusCode = 409;
250
+ throw error;
251
+ }
252
+
253
+ const salt = randomBytes(16).toString("base64url");
254
+ const createdAt = nowIso();
255
+ const inserted = db
256
+ .prepare(
257
+ `
258
+ INSERT INTO users (username, password_salt, password_hash, created_at, updated_at)
259
+ VALUES (?, ?, ?, ?, ?)
260
+ `,
261
+ )
262
+ .run(username, salt, hashPassword(password, salt), createdAt, createdAt);
263
+ const userId = Number(inserted.lastInsertRowid);
264
+ const session = createSession(db, userId);
265
+
266
+ return {
267
+ token: session.token,
268
+ expiresAt: session.expiresAt,
269
+ user: {
270
+ id: userId,
271
+ username,
272
+ },
273
+ };
274
+ }),
275
+ );
276
+
277
+ app.post(
278
+ "/api/auth/login",
279
+ wrapAsync(async (req) => {
280
+ const username = normalizeUsername(req.body.username);
281
+ const password = normalizePassword(req.body.password);
282
+ const user = getUserByUsername(db, username);
283
+
284
+ if (!user || !verifyPassword(password, user.passwordSalt, user.passwordHash)) {
285
+ const error = new Error("账号或密码不正确。");
286
+ error.statusCode = 401;
287
+ throw error;
288
+ }
289
+
290
+ const session = createSession(db, user.id);
291
+ return {
292
+ token: session.token,
293
+ expiresAt: session.expiresAt,
294
+ user: {
295
+ id: user.id,
296
+ username: user.username,
297
+ },
298
+ };
299
+ }),
300
+ );
301
+
302
+ app.post(
303
+ "/api/auth/logout",
304
+ authRequired,
305
+ wrapAsync(async (req) => {
306
+ db.prepare("DELETE FROM sessions WHERE token = ?").run(req.auth.token);
307
+ return {
308
+ loggedOut: true,
309
+ };
310
+ }),
311
+ );
312
+
313
+ app.get(
314
+ "/api/auth/me",
315
+ authRequired,
316
+ wrapAsync(async (req) => {
317
+ const profile = getSyncProfile(db, req.auth.userId);
318
+ return {
319
+ user: {
320
+ id: req.auth.userId,
321
+ username: req.auth.username,
322
+ },
323
+ session: {
324
+ expiresAt: req.auth.expiresAt,
325
+ },
326
+ syncProfileUpdatedAt: profile ? profile.updatedAt : "",
327
+ };
328
+ }),
329
+ );
330
+
331
+ app.put(
332
+ "/api/sync/config",
333
+ authRequired,
334
+ wrapAsync(async (req) => {
335
+ const syncCode = String(req.body.syncCode || "").trim();
336
+ const endpointCount = Math.max(0, Number(req.body.endpointCount || 0));
337
+
338
+ if (!syncCode) {
339
+ throw new Error("缺少要同步的配置数据。");
340
+ }
341
+
342
+ const updatedAt = nowIso();
343
+ db.prepare(
344
+ `
345
+ INSERT INTO sync_profiles (user_id, sync_code, endpoint_count, updated_at)
346
+ VALUES (?, ?, ?, ?)
347
+ ON CONFLICT(user_id) DO UPDATE SET
348
+ sync_code = excluded.sync_code,
349
+ endpoint_count = excluded.endpoint_count,
350
+ updated_at = excluded.updated_at
351
+ `,
352
+ ).run(req.auth.userId, syncCode, endpointCount, updatedAt);
353
+
354
+ return {
355
+ endpointCount,
356
+ updatedAt,
357
+ };
358
+ }),
359
+ );
360
+
361
+ app.get(
362
+ "/api/sync/config",
363
+ authRequired,
364
+ wrapAsync(async (req) => {
365
+ const profile = getSyncProfile(db, req.auth.userId);
366
+ if (!profile) {
367
+ const error = new Error("当前账号还没有保存过同步配置。");
368
+ error.statusCode = 404;
369
+ throw error;
370
+ }
371
+
372
+ return profile;
373
+ }),
374
+ );
375
+
376
+ app.use((error, _req, res, _next) => {
377
+ const statusCode = Number(error.statusCode || 400);
378
+ res.status(statusCode).json({
379
+ ok: false,
380
+ error: error instanceof Error ? error.message : String(error),
381
+ });
382
+ });
383
+
384
+ return {
385
+ app,
386
+ db,
387
+ dbPath,
388
+ host,
389
+ port,
390
+ };
391
+ }
392
+
393
+ function startCloudSyncServer(options = {}) {
394
+ const context = createCloudSyncApp(options);
395
+ const server = context.app.listen(context.port, context.host, () => {
396
+ console.log(`Codex 账号同步服务已启动:http://${context.host}:${context.port}`);
397
+ console.log(`SQLite 数据库:${context.dbPath}`);
398
+ });
399
+
400
+ return {
401
+ ...context,
402
+ server,
403
+ close(callback) {
404
+ server.close((error) => {
405
+ context.db.close();
406
+ callback(error);
407
+ });
408
+ },
409
+ };
410
+ }
411
+
412
+ if (require.main === module) {
413
+ startCloudSyncServer();
414
+ }
415
+
416
+ module.exports = {
417
+ createCloudSyncApp,
418
+ startCloudSyncServer,
419
+ };
@@ -7,7 +7,9 @@ const projectRoot = path.resolve(__dirname, "../..");
7
7
  const runtimeDir = path.join(projectRoot, "runtime");
8
8
  const serverEntryPath = path.join(__dirname, "server.js");
9
9
  const port = Number(process.env.PORT || 3186);
10
+ const proxyPort = 3187;
10
11
  const healthUrl = `http://127.0.0.1:${port}/api/health`;
12
+ const proxyHealthUrl = `http://127.0.0.1:${proxyPort}/__switcher/health`;
11
13
  const consoleUrl = `http://localhost:${port}`;
12
14
 
13
15
  function ensureRuntimeDir() {
@@ -45,7 +47,8 @@ function requestJson(url, timeoutMs = 1200) {
45
47
  async function checkServerHealth() {
46
48
  try {
47
49
  const payload = await requestJson(healthUrl);
48
- return Boolean(payload && payload.ok);
50
+ const proxyPayload = await requestJson(proxyHealthUrl);
51
+ return Boolean(payload && payload.ok && proxyPayload && proxyPayload.ok);
49
52
  } catch {
50
53
  return false;
51
54
  }
@@ -211,6 +214,7 @@ async function handleCommand(command) {
211
214
  running,
212
215
  url: consoleUrl,
213
216
  port,
217
+ proxyPort,
214
218
  },
215
219
  null,
216
220
  2,
@@ -245,4 +249,5 @@ module.exports = {
245
249
  runtimeDir,
246
250
  consoleUrl,
247
251
  port,
252
+ proxyPort,
248
253
  };
@@ -0,0 +1,69 @@
1
+ const http = require("node:http");
2
+ const https = require("node:https");
3
+ const profileManager = require("../main/profile-manager");
4
+
5
+ function createProxyServer() {
6
+ return http.createServer(async (req, res) => {
7
+ try {
8
+ if (req.url === "/__switcher/health") {
9
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
10
+ res.end(JSON.stringify({ ok: true, proxyBaseUrl: profileManager.proxyBaseUrl }));
11
+ return;
12
+ }
13
+
14
+ const target = await profileManager.getProxyTarget();
15
+ const upstreamBase = new URL(target.url);
16
+ const requestPath = req.url && req.url.startsWith("/") ? req.url : `/${req.url || ""}`;
17
+ const upstreamUrl = new URL(requestPath, upstreamBase);
18
+ const requestModule = upstreamUrl.protocol === "https:" ? https : http;
19
+ const headers = {
20
+ ...req.headers,
21
+ host: upstreamUrl.host,
22
+ authorization: `Bearer ${target.key}`,
23
+ };
24
+
25
+ delete headers.connection;
26
+
27
+ const upstreamRequest = requestModule.request(
28
+ upstreamUrl,
29
+ {
30
+ method: req.method,
31
+ headers,
32
+ },
33
+ (upstreamResponse) => {
34
+ res.writeHead(upstreamResponse.statusCode || 502, upstreamResponse.headers);
35
+ upstreamResponse.pipe(res);
36
+ },
37
+ );
38
+
39
+ upstreamRequest.on("error", (error) => {
40
+ if (!res.headersSent) {
41
+ res.writeHead(502, { "Content-Type": "application/json; charset=utf-8" });
42
+ }
43
+ res.end(
44
+ JSON.stringify({
45
+ ok: false,
46
+ error: `代理转发失败:${error.message}`,
47
+ }),
48
+ );
49
+ });
50
+
51
+ req.pipe(upstreamRequest);
52
+ } catch (error) {
53
+ if (!res.headersSent) {
54
+ res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
55
+ }
56
+
57
+ res.end(
58
+ JSON.stringify({
59
+ ok: false,
60
+ error: error instanceof Error ? error.message : String(error),
61
+ }),
62
+ );
63
+ }
64
+ });
65
+ }
66
+
67
+ module.exports = {
68
+ createProxyServer,
69
+ };
package/src/web/server.js CHANGED
@@ -2,6 +2,8 @@ const path = require("node:path");
2
2
  const { exec } = require("node:child_process");
3
3
  const express = require("express");
4
4
  const profileManager = require("../main/profile-manager");
5
+ const cloudSyncClient = require("../main/cloud-sync-client");
6
+ const { createProxyServer } = require("./proxy-server");
5
7
 
6
8
  function wrapAsync(handler) {
7
9
  return async (req, res) => {
@@ -77,6 +79,69 @@ function createApp() {
77
79
  }),
78
80
  );
79
81
 
82
+ app.post(
83
+ "/api/proxy/enable",
84
+ wrapAsync(async () => {
85
+ return profileManager.enableProxyMode();
86
+ }),
87
+ );
88
+
89
+ app.get(
90
+ "/api/sync/export",
91
+ wrapAsync(async () => {
92
+ return profileManager.exportSyncPackage();
93
+ }),
94
+ );
95
+
96
+ app.post(
97
+ "/api/sync/import",
98
+ wrapAsync(async (req) => {
99
+ return profileManager.importSyncPackage(req.body.syncCode, req.body.mode);
100
+ }),
101
+ );
102
+
103
+ app.get(
104
+ "/api/cloud/status",
105
+ wrapAsync(async () => {
106
+ return cloudSyncClient.getCloudSyncStatus();
107
+ }),
108
+ );
109
+
110
+ app.post(
111
+ "/api/cloud/register",
112
+ wrapAsync(async (req) => {
113
+ return cloudSyncClient.registerAccount(req.body);
114
+ }),
115
+ );
116
+
117
+ app.post(
118
+ "/api/cloud/login",
119
+ wrapAsync(async (req) => {
120
+ return cloudSyncClient.loginAccount(req.body);
121
+ }),
122
+ );
123
+
124
+ app.post(
125
+ "/api/cloud/logout",
126
+ wrapAsync(async () => {
127
+ return cloudSyncClient.logoutAccount();
128
+ }),
129
+ );
130
+
131
+ app.post(
132
+ "/api/cloud/push",
133
+ wrapAsync(async () => {
134
+ return cloudSyncClient.pushCurrentConfig();
135
+ }),
136
+ );
137
+
138
+ app.post(
139
+ "/api/cloud/pull",
140
+ wrapAsync(async (req) => {
141
+ return cloudSyncClient.pullRemoteConfig(req.body.mode);
142
+ }),
143
+ );
144
+
80
145
  app.post(
81
146
  "/api/open-path",
82
147
  wrapAsync(async (req) => {
@@ -110,16 +175,49 @@ function createApp() {
110
175
  function startServer(options = {}) {
111
176
  const port = Number(options.port || process.env.PORT || 3186);
112
177
  const app = createApp();
113
- const server = app.listen(port, () => {
178
+ const proxyServer = createProxyServer();
179
+ const proxyPort = profileManager.proxyPort;
180
+ const webServer = app.listen(port, () => {
114
181
  const url = `http://localhost:${port}`;
115
182
  console.log(`Codex 网页控制台已启动:${url}`);
183
+ console.log(`Codex 热更新代理已启动:${profileManager.proxyBaseUrl}`);
116
184
 
117
185
  if (options.autoOpen !== false && process.env.AUTO_OPEN_BROWSER !== "false") {
118
186
  exec(`cmd /c start "" "${url}"`);
119
187
  }
120
188
  });
121
189
 
122
- return server;
190
+ proxyServer.listen(proxyPort);
191
+
192
+ return {
193
+ webServer,
194
+ proxyServer,
195
+ close(callback) {
196
+ let pending = 2;
197
+ let closed = false;
198
+
199
+ function done(error) {
200
+ if (closed) {
201
+ return;
202
+ }
203
+
204
+ if (error) {
205
+ closed = true;
206
+ callback(error);
207
+ return;
208
+ }
209
+
210
+ pending -= 1;
211
+ if (pending === 0) {
212
+ closed = true;
213
+ callback();
214
+ }
215
+ }
216
+
217
+ webServer.close((error) => done(error));
218
+ proxyServer.close((error) => done(error));
219
+ },
220
+ };
123
221
  }
124
222
 
125
223
  if (require.main === module) {