@wu529778790/open-im 1.7.1-beta.1 → 1.7.1-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -76,48 +76,95 @@ WeChat is not in the web UI; configure it in `~/.open-im/config.json` or via `op
76
76
 
77
77
  ### On a headless server (no GUI)
78
78
 
79
- Many servers do not have a desktop environment or browser. In that case, trying to auto-launch a browser (`xdg-open`, `open`, `start`) is unnecessary and may even fail. Use this pattern instead:
79
+ Many servers do not have a desktop environment or browser. In that case, trying to auto-launch a browser (`xdg-open`, `open`, `start`) is unnecessary and may even fail. Use these patterns instead.
80
80
 
81
- - **1) Disable automatic browser launch**
81
+ #### 1) Disable automatic browser launch
82
82
 
83
- On the server:
83
+ On the server:
84
+
85
+ ```bash
86
+ export OPEN_IM_NO_BROWSER=1
87
+ open-im start
88
+ ```
89
+
90
+ This starts the bridge and the config web server in the background without attempting to open a browser.
91
+
92
+ #### 2) Verify that the config page is listening on the server
93
+
94
+ On the server:
95
+
96
+ ```bash
97
+ ss -lntp | grep 39282 # or: netstat -lntp | grep 39282
98
+ curl -v http://127.0.0.1:39282/
99
+ ```
100
+
101
+ If you see a `LISTEN` line for `127.0.0.1:39282` and `curl` returns HTML, the config UI is running.
102
+
103
+ #### 3) Safest option: SSH tunnel to local browser
104
+
105
+ Instead of exposing port 39282 to the public internet, use SSH port forwarding:
106
+
107
+ ```bash
108
+ # On your local machine:
109
+ ssh -L 39282:127.0.0.1:39282 user@your-server-ip
110
+ ```
111
+
112
+ Then open in your local browser:
113
+
114
+ ```text
115
+ http://127.0.0.1:39282/
116
+ ```
117
+
118
+ This safely tunnels the config page from the server to your local browser.
119
+
120
+ #### 4) Optional: Remote access with one-time login link
121
+
122
+ If you want to open the config UI directly from another device without SSH tunneling, you can bind the web config server to all interfaces and use a one-time login URL:
123
+
124
+ - **Bind to all interfaces and keep the browser closed on the server:**
84
125
 
85
126
  ```bash
86
127
  export OPEN_IM_NO_BROWSER=1
128
+ export OPEN_IM_WEB_HOST=0.0.0.0
87
129
  open-im start
88
130
  ```
89
131
 
90
- This starts the bridge and the config web server in the background without attempting to open a browser.
91
-
92
- - **2) Verify that the config page is listening on the server**
132
+ - By default, `OPEN_IM_WEB_HOST` is `127.0.0.1` (local only).
133
+ - Setting it to `0.0.0.0` makes the config page listen on all interfaces.
93
134
 
94
- On the server:
135
+ - **On startup, open-im will log a one-time login URL**, for example:
95
136
 
96
- ```bash
97
- ss -lntp | grep 39282 # or: netstat -lntp | grep 39282
98
- curl -v http://127.0.0.1:39282/
137
+ ```text
138
+ ━━━━━━━━ Web Config Login ━━━━━━━━
139
+ Host binding : 0.0.0.0
140
+ Login URL : http://127.0.0.1:39282/?login_token=xxxx
141
+ Note: replace 127.0.0.1 with your server IP or hostname when opening from another device.
142
+ This login link is valid for approximately 15 minutes and can be used only once.
143
+ After login, subsequent requests will use a short-lived session cookie.
99
144
  ```
100
145
 
101
- If you see a `LISTEN` line for `127.0.0.1:39282` and `curl` returns HTML, the config UI is running.
146
+ - **From your laptop/phone**, replace `127.0.0.1` with the server IP or hostname and open the URL in a browser:
102
147
 
103
- - **3) Access the config UI from your local machine via SSH tunnel**
148
+ ```text
149
+ http://your-server-ip:39282/?login_token=xxxx
150
+ ```
104
151
 
105
- Instead of exposing port 39282 to the public internet, use SSH port forwarding:
152
+ The first successful visit:
106
153
 
107
- ```bash
108
- # On your local machine:
109
- ssh -L 39282:127.0.0.1:39282 user@your-server-ip
110
- ```
154
+ - Consumes the one-time `login_token` (subsequent uses will fail with 401);
155
+ - Creates a short-lived session and sets a `openim_session` cookie in your browser;
156
+ - Redirects you to the config page without query parameters.
111
157
 
112
- Then open in your local browser:
158
+ After that, as long as the `openim_session` cookie is valid and the process is still running, you can continue visiting:
113
159
 
114
160
  ```text
115
- http://127.0.0.1:39282/
161
+ http://your-server-ip:39282/
116
162
  ```
117
163
 
118
- This safely tunnels the config page from the server to your local browser.
119
-
120
- > If you really want to expose the config UI directly, you can change the listener in `config-web.ts` from `server.listen(port, "127.0.0.1", ...)` to `0.0.0.0` and open port 39282 in your firewall / security group. For security reasons, SSH tunneling is strongly recommended instead.
164
+ > Security notes:
165
+ >
166
+ > - Binding `OPEN_IM_WEB_HOST=0.0.0.0` exposes the config port on all interfaces. Always combine this with firewall rules / security groups and consider fronting the port with HTTPS + auth via a reverse proxy.
167
+ > - When in doubt, prefer SSH tunneling (step 3) over direct exposure.
121
168
 
122
169
  ## Session Behavior
123
170
 
package/README.zh-CN.md CHANGED
@@ -84,46 +84,93 @@ open-im start
84
84
 
85
85
  很多服务器没有桌面环境和浏览器,此时「自动打开浏览器」既没意义,还可能因为缺少 `xdg-open` 报错。推荐如下用法:
86
86
 
87
- - **1)关闭自动打开浏览器**
87
+ #### 1)关闭自动打开浏览器
88
88
 
89
- 在服务器上设置环境变量,然后启动:
89
+ 在服务器上设置环境变量,然后启动:
90
+
91
+ ```bash
92
+ export OPEN_IM_NO_BROWSER=1
93
+ open-im start
94
+ ```
95
+
96
+ 这样只会在后台启动服务与配置页面,不会尝试执行 `xdg-open` / `open` / `start`。
97
+
98
+ #### 2)检查配置页面是否已在服务器本机监听
99
+
100
+ 在服务器上执行:
101
+
102
+ ```bash
103
+ ss -lntp | grep 39282 # 或 netstat -lntp | grep 39282
104
+ curl -v http://127.0.0.1:39282/
105
+ ```
106
+
107
+ 若看到 `LISTEN 0 ... 127.0.0.1:39282` 且 `curl` 返回 HTML,则说明 Web 配置页已正常启动。
108
+
109
+ #### 3)推荐方式:通过 SSH 隧道在本地浏览器访问
110
+
111
+ 不建议直接对外开放 39282 端口,而是使用 SSH 端口转发:
112
+
113
+ ```bash
114
+ # 在本地电脑执行,将本地 39282 转发到服务器 127.0.0.1:39282
115
+ ssh -L 39282:127.0.0.1:39282 user@your-server-ip
116
+ ```
117
+
118
+ 然后在本地浏览器访问:
119
+
120
+ ```text
121
+ http://127.0.0.1:39282/
122
+ ```
123
+
124
+ 即可打开服务器上的配置页面。
125
+
126
+ #### 4)可选:在服务器上直接访问的一次性登录链接
127
+
128
+ 如果你确实希望在服务器上绑定到公网 IP,从其他设备直接访问配置页面,可以:
129
+
130
+ - **将 Web 配置服务绑定到所有网卡:**
90
131
 
91
132
  ```bash
92
133
  export OPEN_IM_NO_BROWSER=1
134
+ export OPEN_IM_WEB_HOST=0.0.0.0
93
135
  open-im start
94
136
  ```
95
137
 
96
- 这样只会在后台启动服务与配置页面,不会尝试执行 `xdg-open` / `open` / `start`。
97
-
98
- - **2)检查配置页面是否已在服务器本机监听**
138
+ - 默认情况下,`OPEN_IM_WEB_HOST` `127.0.0.1`(仅本机访问)。
139
+ - 设置为 `0.0.0.0` 后,配置页面会监听在所有网卡上。
99
140
 
100
- 在服务器上执行:
141
+ - **启动后,open-im 会在日志中输出一次性登录链接**,类似:
101
142
 
102
- ```bash
103
- ss -lntp | grep 39282 # 或 netstat -lntp | grep 39282
104
- curl -v http://127.0.0.1:39282/
143
+ ```text
144
+ ━━━━━━━━ Web Config Login ━━━━━━━━
145
+ Host binding : 0.0.0.0
146
+ Login URL : http://127.0.0.1:39282/?login_token=xxxx
147
+ Note: replace 127.0.0.1 with your server IP or hostname when opening from another device.
148
+ This login link is valid for approximately 15 minutes and can be used only once.
149
+ After login, subsequent requests will use a short-lived session cookie.
105
150
  ```
106
151
 
107
- 若看到 `LISTEN 0 ... 127.0.0.1:39282` `curl` 返回 HTML,则说明 Web 配置页已正常启动。
152
+ - **在本地电脑或手机浏览器中**,将 `127.0.0.1` 换成服务器 IP 或域名,打开该链接:
108
153
 
109
- - **3)通过 SSH 隧道在本地浏览器访问**
154
+ ```text
155
+ http://your-server-ip:39282/?login_token=xxxx
156
+ ```
110
157
 
111
- 不建议直接对外开放 39282 端口,而是使用 SSH 端口转发:
158
+ 第一次成功访问会:
112
159
 
113
- ```bash
114
- # 在本地电脑执行,将本地 39282 转发到服务器 127.0.0.1:39282
115
- ssh -L 39282:127.0.0.1:39282 user@your-server-ip
116
- ```
160
+ - 消费掉这枚一次性 `login_token`(后续再访问同一链接会 401);
161
+ - 在浏览器中创建一个短期会话,设置 `openim_session` Cookie;
162
+ - 自动重定向到不带参数的配置页。
117
163
 
118
- 然后在本地浏览器访问:
164
+ 之后,只要 `openim_session` Cookie 仍然有效、进程仍在运行,就可以直接访问:
119
165
 
120
166
  ```text
121
- http://127.0.0.1:39282/
167
+ http://your-server-ip:39282/
122
168
  ```
123
169
 
124
- 即可打开服务器上的配置页面。
125
-
126
- > 如确有需要,也可以自行修改 `config-web.ts` 中的监听地址,将 `server.listen(port, "127.0.0.1", ...)` 调整为 `0.0.0.0`,并在防火墙/安全组放行 39282 端口。但出于安全考虑,官方推荐使用 SSH 隧道方式。
170
+ > 安全提示:
171
+ >
172
+ > - `OPEN_IM_WEB_HOST=0.0.0.0` 意味着该端口会对所有网卡开放,请务必结合防火墙/安全组、尽量配合 HTTPS + 反向代理(例如 Nginx/Caddy 的 Basic Auth 或 OIDC 登录)一起使用。
173
+ > - 如无把握,优先使用上面的 SSH 隧道方案(第 3 步),安全性更高。
127
174
 
128
175
  ## 会话说明
129
176
 
package/dist/cli.js CHANGED
@@ -60,6 +60,13 @@ async function cmdStart() {
60
60
  console.log("\nopen-im started in the background.");
61
61
  console.log(` pid: ${child.pid}`);
62
62
  console.log(` config page: ${getWebConfigUrl()}`);
63
+ if (process.env.OPEN_IM_WEB_HOST && process.env.OPEN_IM_WEB_HOST !== "127.0.0.1") {
64
+ console.log("");
65
+ console.log("NOTE:");
66
+ console.log(" The config page is bound to OPEN_IM_WEB_HOST.");
67
+ console.log(" A one-time login URL (with login_token) has been printed by the config-web server logger.");
68
+ console.log(" Please use that URL (replacing 127.0.0.1 with your server IP/hostname) for the first login.");
69
+ }
63
70
  }
64
71
  async function cmdStop() {
65
72
  const status = getManagerStatus();
@@ -5,6 +5,7 @@ export interface StartedWebConfigServer {
5
5
  close: () => Promise<void>;
6
6
  url: string;
7
7
  waitForResult: Promise<WebFlowResult>;
8
+ loginUrl?: string;
8
9
  }
9
10
  export declare function getHealthPlatformSnapshot(file: FileConfig, env?: NodeJS.ProcessEnv): Record<string, {
10
11
  configured: boolean;
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createServer } from "node:http";
3
3
  import { URL } from "node:url";
4
+ import { randomBytes } from "node:crypto";
4
5
  import { DWClient } from "dingtalk-stream";
5
6
  import { WEB_CONFIG_PORT } from "./constants.js";
6
7
  import { CONFIG_PATH, getClaudeConfigHome, loadClaudeSettingsEnv, saveClaudeSettingsEnv, loadConfig, loadFileConfig, saveFileConfig } from "./config.js";
@@ -10,6 +11,99 @@ import { initWeWork, stopWeWork } from "./wework/client.js";
10
11
  import { createLogger } from "./logger.js";
11
12
  const log = createLogger("ConfigWeb");
12
13
  const TEST_TIMEOUT_MS = 10000;
14
+ const pendingLogins = new Map();
15
+ const activeSessions = new Map();
16
+ function getWebConfigHost() {
17
+ const envHost = process.env.OPEN_IM_WEB_HOST?.trim();
18
+ if (envHost)
19
+ return envHost;
20
+ return "127.0.0.1";
21
+ }
22
+ function generateRandomToken(bytes = 32) {
23
+ return randomBytes(bytes).toString("base64url");
24
+ }
25
+ function cleanupExpiredAuth(now) {
26
+ for (const [token, info] of pendingLogins) {
27
+ if (info.expiresAt <= now)
28
+ pendingLogins.delete(token);
29
+ }
30
+ for (const [sessionId, info] of activeSessions) {
31
+ if (info.expiresAt <= now)
32
+ activeSessions.delete(sessionId);
33
+ }
34
+ }
35
+ function createLoginToken(ttlMs) {
36
+ const now = Date.now();
37
+ cleanupExpiredAuth(now);
38
+ const token = generateRandomToken(32);
39
+ pendingLogins.set(token, { expiresAt: now + ttlMs });
40
+ return token;
41
+ }
42
+ function createSession(request, ttlMs) {
43
+ const now = Date.now();
44
+ cleanupExpiredAuth(now);
45
+ const sessionId = generateRandomToken(32);
46
+ const remoteAddr = request.socket.remoteAddress;
47
+ const userAgent = typeof request.headers["user-agent"] === "string" ? request.headers["user-agent"] : undefined;
48
+ activeSessions.set(sessionId, {
49
+ expiresAt: now + ttlMs,
50
+ remoteAddr,
51
+ userAgent,
52
+ });
53
+ return sessionId;
54
+ }
55
+ function parseCookies(request) {
56
+ const header = request.headers.cookie;
57
+ if (!header)
58
+ return {};
59
+ const cookies = {};
60
+ const parts = header.split(";");
61
+ for (const part of parts) {
62
+ const [rawKey, ...rest] = part.split("=");
63
+ const key = rawKey.trim();
64
+ if (!key)
65
+ continue;
66
+ const value = rest.join("=").trim();
67
+ cookies[key] = decodeURIComponent(value);
68
+ }
69
+ return cookies;
70
+ }
71
+ function getSessionIdFromRequest(request) {
72
+ const cookies = parseCookies(request);
73
+ const sessionId = cookies.openim_session;
74
+ return sessionId && typeof sessionId === "string" && sessionId.length > 0 ? sessionId : null;
75
+ }
76
+ function isSessionValid(request) {
77
+ const sessionId = getSessionIdFromRequest(request);
78
+ if (!sessionId)
79
+ return false;
80
+ const info = activeSessions.get(sessionId);
81
+ if (!info)
82
+ return false;
83
+ const now = Date.now();
84
+ if (info.expiresAt <= now) {
85
+ activeSessions.delete(sessionId);
86
+ return false;
87
+ }
88
+ // Optional: tie session to basic client fingerprint (remote address)
89
+ const remoteAddr = request.socket.remoteAddress;
90
+ if (info.remoteAddr && remoteAddr && remoteAddr !== info.remoteAddr) {
91
+ return false;
92
+ }
93
+ return true;
94
+ }
95
+ function buildSessionCookie(sessionId, ttlMs) {
96
+ const maxAgeSec = Math.floor(ttlMs / 1000);
97
+ const parts = [
98
+ `openim_session=${encodeURIComponent(sessionId)}`,
99
+ "Path=/",
100
+ "HttpOnly",
101
+ "SameSite=Lax",
102
+ `Max-Age=${maxAgeSec}`,
103
+ ];
104
+ // 不设置 Secure,方便本地 http 使用;如果放在 https 反代后,可以在代理层加 Secure
105
+ return parts.join("; ");
106
+ }
13
107
  export function getHealthPlatformSnapshot(file, env = process.env) {
14
108
  const fileTelegram = file.platforms?.telegram;
15
109
  const fileFeishu = file.platforms?.feishu;
@@ -514,6 +608,7 @@ export async function startWebConfigServer(options) {
514
608
  resolve(value);
515
609
  };
516
610
  });
611
+ const host = getWebConfigHost();
517
612
  const server = createServer(async (request, response) => {
518
613
  const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
519
614
  const finishFlow = (result) => {
@@ -522,6 +617,43 @@ export async function startWebConfigServer(options) {
522
617
  server.close();
523
618
  settle(result);
524
619
  };
620
+ // Auth gating:
621
+ // - 当仅绑定 127.0.0.1 时,保持完全本地免登录(向后兼容)
622
+ // - 当绑定到 0.0.0.0 或其他地址时,启用一次性登录 + Session Cookie 机制
623
+ const isLocalOnly = host === "127.0.0.1";
624
+ const hasLoginTokenFeature = !isLocalOnly;
625
+ if (hasLoginTokenFeature) {
626
+ const loginToken = requestUrl.searchParams.get("login_token");
627
+ if (loginToken) {
628
+ const info = pendingLogins.get(loginToken);
629
+ const now = Date.now();
630
+ if (info && info.expiresAt > now) {
631
+ // 有效的一次性登录 token:创建会话,设置 Cookie,并重定向到去掉 login_token 的 URL
632
+ pendingLogins.delete(loginToken);
633
+ const sessionTtlMs = 24 * 60 * 60 * 1000; // 24 小时
634
+ const sessionId = createSession(request, sessionTtlMs);
635
+ const cookie = buildSessionCookie(sessionId, sessionTtlMs);
636
+ requestUrl.searchParams.delete("login_token");
637
+ const redirectPath = requestUrl.pathname + (requestUrl.search ? requestUrl.search : "");
638
+ response.writeHead(302, {
639
+ Location: redirectPath || "/",
640
+ "Set-Cookie": cookie,
641
+ });
642
+ response.end();
643
+ return;
644
+ }
645
+ // 无效或过期的一次性 token
646
+ response.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
647
+ response.end("Invalid or expired login link. Please generate a new one from the server.");
648
+ return;
649
+ }
650
+ // 其他请求:必须已有有效 session
651
+ if (!isSessionValid(request)) {
652
+ response.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
653
+ response.end("Unauthorized. Please open the latest login URL from the server output.");
654
+ return;
655
+ }
656
+ }
525
657
  if (request.method === "GET" && requestUrl.pathname === "/") {
526
658
  response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
527
659
  response.end(PAGE_HTML);
@@ -676,7 +808,7 @@ export async function startWebConfigServer(options) {
676
808
  }
677
809
  reject(error);
678
810
  });
679
- server.listen(port, "127.0.0.1", () => resolve());
811
+ server.listen(port, host, () => resolve());
680
812
  });
681
813
  const address = server.address();
682
814
  if (!address || typeof address === "string") {
@@ -698,6 +830,24 @@ export async function startWebConfigServer(options) {
698
830
  if (timer)
699
831
  clearTimeout(timer);
700
832
  });
833
+ let loginUrlForReturn;
834
+ // 当绑定到非 127.0.0.1(例如 0.0.0.0)时,为远程访问生成一次性登录链接
835
+ if (host !== "127.0.0.1") {
836
+ const loginTtlMs = 15 * 60 * 1000; // 15 分钟内有效
837
+ const loginToken = createLoginToken(loginTtlMs);
838
+ const displayHost = host === "0.0.0.0" ? "127.0.0.1" : host;
839
+ const baseUrl = `http://${displayHost}:${port}`;
840
+ const loginUrl = `${baseUrl}/?login_token=${encodeURIComponent(loginToken)}`;
841
+ loginUrlForReturn = loginUrl;
842
+ log.info("━━━━━━━━ Web Config Login ━━━━━━━━");
843
+ log.info(`Host binding : ${host}`);
844
+ log.info(`Login URL : ${loginUrl}`);
845
+ if (host === "0.0.0.0") {
846
+ log.info("Note: replace 127.0.0.1 with your server IP or hostname when opening from another device.");
847
+ }
848
+ log.info(`This login link is valid for approximately ${Math.floor(loginTtlMs / 60000)} minutes and can be used only once.`);
849
+ log.info("After login, subsequent requests will use a short-lived session cookie.");
850
+ }
701
851
  return {
702
852
  close: async () => {
703
853
  if (timer)
@@ -706,13 +856,15 @@ export async function startWebConfigServer(options) {
706
856
  settle("cancel");
707
857
  },
708
858
  url: `http://127.0.0.1:${port}`,
859
+ loginUrl: loginUrlForReturn,
709
860
  waitForResult,
710
861
  };
711
862
  }
712
863
  export async function runWebConfigFlow(options) {
713
864
  const started = await startWebConfigServer(options);
714
- openBrowser(started.url);
715
- log.info(`Opened local configuration page: ${started.url}`);
865
+ const targetUrl = started.loginUrl ?? started.url;
866
+ openBrowser(targetUrl);
867
+ log.info(`Opened local configuration page: ${targetUrl}`);
716
868
  log.info(process.env.OPEN_IM_NO_BROWSER === "1" ? "Browser launch disabled. Open the URL manually." : "Save the configuration in your browser to continue.");
717
869
  return started.waitForResult;
718
870
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.7.1-beta.1",
3
+ "version": "1.7.1-beta.3",
4
4
  "description": "Multi-platform IM bridge for AI CLI tools (Claude, Codex, CodeBuddy)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",