ccoding 0.2.2__tar.gz → 0.2.4__tar.gz
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.
- {ccoding-0.2.2 → ccoding-0.2.4}/PKG-INFO +31 -23
- {ccoding-0.2.2 → ccoding-0.2.4}/README.md +30 -22
- {ccoding-0.2.2 → ccoding-0.2.4}/pyproject.toml +1 -1
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/config.py +28 -8
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/remote.py +58 -46
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/remote_cli.py +5 -3
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/remote_client.py +7 -7
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/remote_common.py +89 -32
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/remote_server.py +67 -30
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/ui.py +35 -23
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/web/app.js +99 -30
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/web/index.html +6 -4
- {ccoding-0.2.2 → ccoding-0.2.4}/uv.lock +1 -1
- {ccoding-0.2.2 → ccoding-0.2.4}/.github/workflows/publish.yml +0 -0
- {ccoding-0.2.2 → ccoding-0.2.4}/.gitignore +0 -0
- {ccoding-0.2.2 → ccoding-0.2.4}/CLAUDE.md +0 -0
- {ccoding-0.2.2 → ccoding-0.2.4}/LICENSE +0 -0
- {ccoding-0.2.2 → ccoding-0.2.4}/screenshot.png +0 -0
- {ccoding-0.2.2 → ccoding-0.2.4}/screenshot_mobile1.jpeg +0 -0
- {ccoding-0.2.2 → ccoding-0.2.4}/screenshot_mobile2.jpeg +0 -0
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/__init__.py +0 -0
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/__main__.py +0 -0
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/api.py +0 -0
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/cli.py +0 -0
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/env_schema.json +0 -0
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/logo.py +0 -0
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/web/__init__.py +0 -0
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/web/assets/addon-fit.js +0 -0
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/web/assets/xterm-LICENSE +0 -0
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/web/assets/xterm-addon-fit-LICENSE +0 -0
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/web/assets/xterm.css +0 -0
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/web/assets/xterm.js +0 -0
- {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/web/style.css +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ccoding
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Curses-based Claude Code launcher
|
|
5
5
|
Project-URL: Homepage, https://github.com/8DE4732A/ccode
|
|
6
6
|
Project-URL: Repository, https://github.com/8DE4732A/ccode
|
|
@@ -173,8 +173,8 @@ uv sync --extra remote # 同时安装 client/server 依赖
|
|
|
173
173
|
|
|
174
174
|
- `↑ / ↓`:移动字段焦点
|
|
175
175
|
- `Enter / Space`:切换开关项
|
|
176
|
-
- 文本输入:编辑 BASE_URL / API_KEY、remote local/server URL/
|
|
177
|
-
- `g`:在 Remote
|
|
176
|
+
- 文本输入:编辑 BASE_URL / API_KEY、remote local/server URL/appId/appKey/device/prefix 等字段
|
|
177
|
+
- `g`:在 Remote appId 或 appKey 字段重新生成对应值
|
|
178
178
|
- `ESC`:自动保存并返回主界面
|
|
179
179
|
|
|
180
180
|
## 配置文件
|
|
@@ -209,11 +209,13 @@ uv sync --extra remote # 同时安装 client/server 依赖
|
|
|
209
209
|
"local": {
|
|
210
210
|
"host": "127.0.0.1",
|
|
211
211
|
"port": 8765,
|
|
212
|
-
"
|
|
212
|
+
"appId": "",
|
|
213
|
+
"appKey": ""
|
|
213
214
|
},
|
|
214
215
|
"server": {
|
|
215
216
|
"url": "",
|
|
216
|
-
"
|
|
217
|
+
"appId": "",
|
|
218
|
+
"appKey": "",
|
|
217
219
|
"device_name": "",
|
|
218
220
|
"device_id": "",
|
|
219
221
|
"auto_connect": true,
|
|
@@ -232,6 +234,8 @@ Remote/Web 让 Claude Code 运行在 tmux session 中,浏览器通过 WebSocke
|
|
|
232
234
|
| `local` | 直接访问本机 local hub | 当前这台机器 | 单机使用,或把这一台机器通过 tunnel 暴露出去 |
|
|
233
235
|
| `server` | 访问公网中心 `ccode-remote-server` | 各客户端设备本机 | 多设备、NAT 后设备、统一入口管理多个 sessions |
|
|
234
236
|
|
|
237
|
+
Remote/Web 使用 appId/appKey HMAC 认证。网络请求只携带 `appId`、`timestamp` 和 `sign`,其中 `sign` 由 appKey 计算,appKey 不会被发送。浏览器端只保存 24 小时有效的签名票据,过期后需要重新输入 appKey;客户端 connector 会从本地配置读取 appKey 并在重连时自动重新签名。旧版配置中的 `token` 会在读取时迁移为 `appKey`,并自动生成 `appId`。
|
|
238
|
+
|
|
235
239
|
### local 模式
|
|
236
240
|
|
|
237
241
|
local 模式会在本机启动一个共享 FastAPI hub。浏览器直接访问这个 hub,hub 再 attach 到本机 tmux session。
|
|
@@ -262,23 +266,23 @@ claude
|
|
|
262
266
|
- `MODE`:`local`
|
|
263
267
|
- `LOCAL HOST`:默认 `127.0.0.1`
|
|
264
268
|
- `LOCAL PORT`:默认 `8765`
|
|
265
|
-
- `LOCAL
|
|
269
|
+
- `LOCAL APP ID` / `LOCAL APP KEY`:手动输入,或在对应字段按 `g` 生成
|
|
266
270
|
|
|
267
271
|
3. 回到主界面,按 `r` 开启 Remote/Web,再按 `Enter` 启动 remote session。
|
|
268
272
|
|
|
269
|
-
4. 浏览器打开终端输出的本地地址,输入 `LOCAL
|
|
273
|
+
4. 浏览器打开终端输出的本地地址,输入 `LOCAL APP ID` / `LOCAL APP KEY`,选择 session 后进入 terminal。
|
|
270
274
|
|
|
271
|
-
local 模式默认只监听 `127.0.0.1
|
|
275
|
+
local 模式默认只监听 `127.0.0.1`。终端只输出不带认证信息的本地访问地址。浏览器打开页面后需要手动输入 appId/appKey,前端只保存由 appKey 生成的 24 小时 HMAC 签名票据,不保存 appKey;票据过期或关闭浏览器会话后需要重新输入。所有 session list、session API 和 WebSocket 连接仍然需要签名认证。
|
|
272
276
|
|
|
273
|
-
每个启用 Remote/Web 的 `ccode` 进程都会创建一个唯一 tmux session,session 名由配置里的 `session_name` 前缀、时间戳和进程号组成,例如 `ccode-claude-20260509-153022-12345`。本机只启动一个共享 remote hub,多个本地 `ccode` 进程会复用同一个 host/port/
|
|
277
|
+
每个启用 Remote/Web 的 `ccode` 进程都会创建一个唯一 tmux session,session 名由配置里的 `session_name` 前缀、时间戳和进程号组成,例如 `ccode-claude-20260509-153022-12345`。本机只启动一个共享 remote hub,多个本地 `ccode` 进程会复用同一个 host/port/appId/appKey。浏览器认证后会先显示 session 列表,选择某个 session 后进入 terminal,也可以返回列表重新选择。
|
|
274
278
|
|
|
275
|
-
如果通过 Cloudflare Tunnel、Nginx、Caddy 等反向代理暴露 local hub,建议额外叠加 Cloudflare Access、Basic Auth 或 OAuth2 proxy。监听 `0.0.0.0` 会把所有 remote sessions 暴露给网络内可达主机,请保护好访问入口和
|
|
279
|
+
如果通过 Cloudflare Tunnel、Nginx、Caddy 等反向代理暴露 local hub,建议额外叠加 Cloudflare Access、Basic Auth 或 OAuth2 proxy。监听 `0.0.0.0` 会把所有 remote sessions 暴露给网络内可达主机,请保护好访问入口和 app credentials。
|
|
276
280
|
|
|
277
281
|
```bash
|
|
278
282
|
cloudflared tunnel --url http://127.0.0.1:8765
|
|
279
283
|
```
|
|
280
284
|
|
|
281
|
-
local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、session 注册、session API 和 WebSocket 连接状态,不记录
|
|
285
|
+
local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、session 注册、session API 和 WebSocket 连接状态,不记录 appKey。启动 hub 前如果日志超过 5 MiB 会自动轮转,默认保留 3 个历史文件(`.1`、`.2`、`.3`)。
|
|
282
286
|
|
|
283
287
|
### server 模式和 `ccode-remote-server`
|
|
284
288
|
|
|
@@ -289,7 +293,7 @@ local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、sessio
|
|
|
289
293
|
```text
|
|
290
294
|
Browser
|
|
291
295
|
│
|
|
292
|
-
│ HTTPS/WSS + admin
|
|
296
|
+
│ HTTPS/WSS + admin HMAC
|
|
293
297
|
▼
|
|
294
298
|
┌───────────────────────┐
|
|
295
299
|
│ ccode-remote-server │
|
|
@@ -314,31 +318,35 @@ local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、sessio
|
|
|
314
318
|
|
|
315
319
|
server 不反连客户端,所以客户端可以在 NAT、家庭网络或公司内网后面。只要客户端能主动访问 server,就可以注册设备和暴露自己的 remote sessions。
|
|
316
320
|
|
|
317
|
-
#### server
|
|
321
|
+
#### server app credentials
|
|
318
322
|
|
|
319
|
-
server
|
|
323
|
+
server app credentials 不会自动生成,必须通过环境变量显式设置。认证时网络上传输 appId、timestamp 和 HMAC sign,不传输 appKey:
|
|
320
324
|
|
|
321
|
-
- `
|
|
322
|
-
- `
|
|
325
|
+
- `CCODE_REMOTE_SERVER_ADMIN_APP_ID` / `CCODE_REMOTE_SERVER_ADMIN_APP_KEY`:浏览器 Web UI / `/api/*` / browser WebSocket 使用的管理凭据。
|
|
326
|
+
- `CCODE_REMOTE_SERVER_CLIENT_APP_ID` / `CCODE_REMOTE_SERVER_CLIENT_APP_KEY`:各设备客户端 connector 注册和心跳使用的客户端凭据。
|
|
323
327
|
- `CCODE_REMOTE_SERVER_HOST` / `CCODE_REMOTE_SERVER_PORT`:可选,默认监听 `127.0.0.1:8765`。
|
|
324
328
|
|
|
325
329
|
启动中心 server:
|
|
326
330
|
|
|
327
331
|
```bash
|
|
328
|
-
|
|
329
|
-
|
|
332
|
+
CCODE_REMOTE_SERVER_ADMIN_APP_ID="admin-app" \
|
|
333
|
+
CCODE_REMOTE_SERVER_ADMIN_APP_KEY="admin-secret" \
|
|
334
|
+
CCODE_REMOTE_SERVER_CLIENT_APP_ID="client-app" \
|
|
335
|
+
CCODE_REMOTE_SERVER_CLIENT_APP_KEY="client-secret" \
|
|
330
336
|
ccode-remote-server --host 127.0.0.1 --port 8765
|
|
331
337
|
```
|
|
332
338
|
|
|
333
339
|
如果使用源码开发环境运行:
|
|
334
340
|
|
|
335
341
|
```bash
|
|
336
|
-
|
|
337
|
-
|
|
342
|
+
CCODE_REMOTE_SERVER_ADMIN_APP_ID="admin-app" \
|
|
343
|
+
CCODE_REMOTE_SERVER_ADMIN_APP_KEY="admin-secret" \
|
|
344
|
+
CCODE_REMOTE_SERVER_CLIENT_APP_ID="client-app" \
|
|
345
|
+
CCODE_REMOTE_SERVER_CLIENT_APP_KEY="client-secret" \
|
|
338
346
|
uv run --extra remote-server ccode-remote-server --host 127.0.0.1 --port 8765
|
|
339
347
|
```
|
|
340
348
|
|
|
341
|
-
浏览器访问 server 页面时输入
|
|
349
|
+
浏览器访问 server 页面时输入 admin appId/appKey。前端只保存 24 小时有效的 HMAC 签名票据,不保存 appKey。如果未设置 `CCODE_REMOTE_SERVER_ADMIN_APP_ID` / `CCODE_REMOTE_SERVER_ADMIN_APP_KEY`,管理 API 会返回 401。
|
|
342
350
|
|
|
343
351
|
#### 客户端设备配置
|
|
344
352
|
|
|
@@ -352,7 +360,7 @@ uv tool install 'ccoding[remote-client]'
|
|
|
352
360
|
|
|
353
361
|
- `MODE`:`server`
|
|
354
362
|
- `SERVER URL`:中心 server 地址,例如 `http://127.0.0.1:8765`;如果只填 `127.0.0.1:8765`,会自动按 `http://127.0.0.1:8765` 处理
|
|
355
|
-
- `CLIENT
|
|
363
|
+
- `CLIENT APP ID` / `CLIENT APP KEY`:与 server 的 `CCODE_REMOTE_SERVER_CLIENT_APP_ID` / `CCODE_REMOTE_SERVER_CLIENT_APP_KEY` 一致
|
|
356
364
|
- `DEVICE NAME`:设备显示名,可留空后由配置迁移填充 hostname
|
|
357
365
|
- `DEVICE ID`:稳定设备 ID,可留空后自动生成
|
|
358
366
|
- `AUTO CONNECT`:是否默认自动连接中心 server
|
|
@@ -384,7 +392,7 @@ server 只做 registry/router/relay,不保存 terminal frame data,不保存
|
|
|
384
392
|
|
|
385
393
|
#### Cloudflare Tunnel / 反向代理
|
|
386
394
|
|
|
387
|
-
如果用 Cloudflare Tunnel 暴露中心 server,只需要 tunnel server 这一处入口,多台客户端设备都配置同一个 `SERVER URL` 和 `CLIENT
|
|
395
|
+
如果用 Cloudflare Tunnel 暴露中心 server,只需要 tunnel server 这一处入口,多台客户端设备都配置同一个 `SERVER URL` 和 `CLIENT APP ID` / `CLIENT APP KEY`:
|
|
388
396
|
|
|
389
397
|
```bash
|
|
390
398
|
cloudflared tunnel --url http://127.0.0.1:8765
|
|
@@ -122,8 +122,8 @@ uv sync --extra remote # 同时安装 client/server 依赖
|
|
|
122
122
|
|
|
123
123
|
- `↑ / ↓`:移动字段焦点
|
|
124
124
|
- `Enter / Space`:切换开关项
|
|
125
|
-
- 文本输入:编辑 BASE_URL / API_KEY、remote local/server URL/
|
|
126
|
-
- `g`:在 Remote
|
|
125
|
+
- 文本输入:编辑 BASE_URL / API_KEY、remote local/server URL/appId/appKey/device/prefix 等字段
|
|
126
|
+
- `g`:在 Remote appId 或 appKey 字段重新生成对应值
|
|
127
127
|
- `ESC`:自动保存并返回主界面
|
|
128
128
|
|
|
129
129
|
## 配置文件
|
|
@@ -158,11 +158,13 @@ uv sync --extra remote # 同时安装 client/server 依赖
|
|
|
158
158
|
"local": {
|
|
159
159
|
"host": "127.0.0.1",
|
|
160
160
|
"port": 8765,
|
|
161
|
-
"
|
|
161
|
+
"appId": "",
|
|
162
|
+
"appKey": ""
|
|
162
163
|
},
|
|
163
164
|
"server": {
|
|
164
165
|
"url": "",
|
|
165
|
-
"
|
|
166
|
+
"appId": "",
|
|
167
|
+
"appKey": "",
|
|
166
168
|
"device_name": "",
|
|
167
169
|
"device_id": "",
|
|
168
170
|
"auto_connect": true,
|
|
@@ -181,6 +183,8 @@ Remote/Web 让 Claude Code 运行在 tmux session 中,浏览器通过 WebSocke
|
|
|
181
183
|
| `local` | 直接访问本机 local hub | 当前这台机器 | 单机使用,或把这一台机器通过 tunnel 暴露出去 |
|
|
182
184
|
| `server` | 访问公网中心 `ccode-remote-server` | 各客户端设备本机 | 多设备、NAT 后设备、统一入口管理多个 sessions |
|
|
183
185
|
|
|
186
|
+
Remote/Web 使用 appId/appKey HMAC 认证。网络请求只携带 `appId`、`timestamp` 和 `sign`,其中 `sign` 由 appKey 计算,appKey 不会被发送。浏览器端只保存 24 小时有效的签名票据,过期后需要重新输入 appKey;客户端 connector 会从本地配置读取 appKey 并在重连时自动重新签名。旧版配置中的 `token` 会在读取时迁移为 `appKey`,并自动生成 `appId`。
|
|
187
|
+
|
|
184
188
|
### local 模式
|
|
185
189
|
|
|
186
190
|
local 模式会在本机启动一个共享 FastAPI hub。浏览器直接访问这个 hub,hub 再 attach 到本机 tmux session。
|
|
@@ -211,23 +215,23 @@ claude
|
|
|
211
215
|
- `MODE`:`local`
|
|
212
216
|
- `LOCAL HOST`:默认 `127.0.0.1`
|
|
213
217
|
- `LOCAL PORT`:默认 `8765`
|
|
214
|
-
- `LOCAL
|
|
218
|
+
- `LOCAL APP ID` / `LOCAL APP KEY`:手动输入,或在对应字段按 `g` 生成
|
|
215
219
|
|
|
216
220
|
3. 回到主界面,按 `r` 开启 Remote/Web,再按 `Enter` 启动 remote session。
|
|
217
221
|
|
|
218
|
-
4. 浏览器打开终端输出的本地地址,输入 `LOCAL
|
|
222
|
+
4. 浏览器打开终端输出的本地地址,输入 `LOCAL APP ID` / `LOCAL APP KEY`,选择 session 后进入 terminal。
|
|
219
223
|
|
|
220
|
-
local 模式默认只监听 `127.0.0.1
|
|
224
|
+
local 模式默认只监听 `127.0.0.1`。终端只输出不带认证信息的本地访问地址。浏览器打开页面后需要手动输入 appId/appKey,前端只保存由 appKey 生成的 24 小时 HMAC 签名票据,不保存 appKey;票据过期或关闭浏览器会话后需要重新输入。所有 session list、session API 和 WebSocket 连接仍然需要签名认证。
|
|
221
225
|
|
|
222
|
-
每个启用 Remote/Web 的 `ccode` 进程都会创建一个唯一 tmux session,session 名由配置里的 `session_name` 前缀、时间戳和进程号组成,例如 `ccode-claude-20260509-153022-12345`。本机只启动一个共享 remote hub,多个本地 `ccode` 进程会复用同一个 host/port/
|
|
226
|
+
每个启用 Remote/Web 的 `ccode` 进程都会创建一个唯一 tmux session,session 名由配置里的 `session_name` 前缀、时间戳和进程号组成,例如 `ccode-claude-20260509-153022-12345`。本机只启动一个共享 remote hub,多个本地 `ccode` 进程会复用同一个 host/port/appId/appKey。浏览器认证后会先显示 session 列表,选择某个 session 后进入 terminal,也可以返回列表重新选择。
|
|
223
227
|
|
|
224
|
-
如果通过 Cloudflare Tunnel、Nginx、Caddy 等反向代理暴露 local hub,建议额外叠加 Cloudflare Access、Basic Auth 或 OAuth2 proxy。监听 `0.0.0.0` 会把所有 remote sessions 暴露给网络内可达主机,请保护好访问入口和
|
|
228
|
+
如果通过 Cloudflare Tunnel、Nginx、Caddy 等反向代理暴露 local hub,建议额外叠加 Cloudflare Access、Basic Auth 或 OAuth2 proxy。监听 `0.0.0.0` 会把所有 remote sessions 暴露给网络内可达主机,请保护好访问入口和 app credentials。
|
|
225
229
|
|
|
226
230
|
```bash
|
|
227
231
|
cloudflared tunnel --url http://127.0.0.1:8765
|
|
228
232
|
```
|
|
229
233
|
|
|
230
|
-
local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、session 注册、session API 和 WebSocket 连接状态,不记录
|
|
234
|
+
local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、session 注册、session API 和 WebSocket 连接状态,不记录 appKey。启动 hub 前如果日志超过 5 MiB 会自动轮转,默认保留 3 个历史文件(`.1`、`.2`、`.3`)。
|
|
231
235
|
|
|
232
236
|
### server 模式和 `ccode-remote-server`
|
|
233
237
|
|
|
@@ -238,7 +242,7 @@ local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、sessio
|
|
|
238
242
|
```text
|
|
239
243
|
Browser
|
|
240
244
|
│
|
|
241
|
-
│ HTTPS/WSS + admin
|
|
245
|
+
│ HTTPS/WSS + admin HMAC
|
|
242
246
|
▼
|
|
243
247
|
┌───────────────────────┐
|
|
244
248
|
│ ccode-remote-server │
|
|
@@ -263,31 +267,35 @@ local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、sessio
|
|
|
263
267
|
|
|
264
268
|
server 不反连客户端,所以客户端可以在 NAT、家庭网络或公司内网后面。只要客户端能主动访问 server,就可以注册设备和暴露自己的 remote sessions。
|
|
265
269
|
|
|
266
|
-
#### server
|
|
270
|
+
#### server app credentials
|
|
267
271
|
|
|
268
|
-
server
|
|
272
|
+
server app credentials 不会自动生成,必须通过环境变量显式设置。认证时网络上传输 appId、timestamp 和 HMAC sign,不传输 appKey:
|
|
269
273
|
|
|
270
|
-
- `
|
|
271
|
-
- `
|
|
274
|
+
- `CCODE_REMOTE_SERVER_ADMIN_APP_ID` / `CCODE_REMOTE_SERVER_ADMIN_APP_KEY`:浏览器 Web UI / `/api/*` / browser WebSocket 使用的管理凭据。
|
|
275
|
+
- `CCODE_REMOTE_SERVER_CLIENT_APP_ID` / `CCODE_REMOTE_SERVER_CLIENT_APP_KEY`:各设备客户端 connector 注册和心跳使用的客户端凭据。
|
|
272
276
|
- `CCODE_REMOTE_SERVER_HOST` / `CCODE_REMOTE_SERVER_PORT`:可选,默认监听 `127.0.0.1:8765`。
|
|
273
277
|
|
|
274
278
|
启动中心 server:
|
|
275
279
|
|
|
276
280
|
```bash
|
|
277
|
-
|
|
278
|
-
|
|
281
|
+
CCODE_REMOTE_SERVER_ADMIN_APP_ID="admin-app" \
|
|
282
|
+
CCODE_REMOTE_SERVER_ADMIN_APP_KEY="admin-secret" \
|
|
283
|
+
CCODE_REMOTE_SERVER_CLIENT_APP_ID="client-app" \
|
|
284
|
+
CCODE_REMOTE_SERVER_CLIENT_APP_KEY="client-secret" \
|
|
279
285
|
ccode-remote-server --host 127.0.0.1 --port 8765
|
|
280
286
|
```
|
|
281
287
|
|
|
282
288
|
如果使用源码开发环境运行:
|
|
283
289
|
|
|
284
290
|
```bash
|
|
285
|
-
|
|
286
|
-
|
|
291
|
+
CCODE_REMOTE_SERVER_ADMIN_APP_ID="admin-app" \
|
|
292
|
+
CCODE_REMOTE_SERVER_ADMIN_APP_KEY="admin-secret" \
|
|
293
|
+
CCODE_REMOTE_SERVER_CLIENT_APP_ID="client-app" \
|
|
294
|
+
CCODE_REMOTE_SERVER_CLIENT_APP_KEY="client-secret" \
|
|
287
295
|
uv run --extra remote-server ccode-remote-server --host 127.0.0.1 --port 8765
|
|
288
296
|
```
|
|
289
297
|
|
|
290
|
-
浏览器访问 server 页面时输入
|
|
298
|
+
浏览器访问 server 页面时输入 admin appId/appKey。前端只保存 24 小时有效的 HMAC 签名票据,不保存 appKey。如果未设置 `CCODE_REMOTE_SERVER_ADMIN_APP_ID` / `CCODE_REMOTE_SERVER_ADMIN_APP_KEY`,管理 API 会返回 401。
|
|
291
299
|
|
|
292
300
|
#### 客户端设备配置
|
|
293
301
|
|
|
@@ -301,7 +309,7 @@ uv tool install 'ccoding[remote-client]'
|
|
|
301
309
|
|
|
302
310
|
- `MODE`:`server`
|
|
303
311
|
- `SERVER URL`:中心 server 地址,例如 `http://127.0.0.1:8765`;如果只填 `127.0.0.1:8765`,会自动按 `http://127.0.0.1:8765` 处理
|
|
304
|
-
- `CLIENT
|
|
312
|
+
- `CLIENT APP ID` / `CLIENT APP KEY`:与 server 的 `CCODE_REMOTE_SERVER_CLIENT_APP_ID` / `CCODE_REMOTE_SERVER_CLIENT_APP_KEY` 一致
|
|
305
313
|
- `DEVICE NAME`:设备显示名,可留空后由配置迁移填充 hostname
|
|
306
314
|
- `DEVICE ID`:稳定设备 ID,可留空后自动生成
|
|
307
315
|
- `AUTO CONNECT`:是否默认自动连接中心 server
|
|
@@ -333,7 +341,7 @@ server 只做 registry/router/relay,不保存 terminal frame data,不保存
|
|
|
333
341
|
|
|
334
342
|
#### Cloudflare Tunnel / 反向代理
|
|
335
343
|
|
|
336
|
-
如果用 Cloudflare Tunnel 暴露中心 server,只需要 tunnel server 这一处入口,多台客户端设备都配置同一个 `SERVER URL` 和 `CLIENT
|
|
344
|
+
如果用 Cloudflare Tunnel 暴露中心 server,只需要 tunnel server 这一处入口,多台客户端设备都配置同一个 `SERVER URL` 和 `CLIENT APP ID` / `CLIENT APP KEY`:
|
|
337
345
|
|
|
338
346
|
```bash
|
|
339
347
|
cloudflared tunnel --url http://127.0.0.1:8765
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import copy
|
|
4
4
|
import json
|
|
5
|
+
import secrets
|
|
5
6
|
import socket
|
|
6
7
|
import uuid
|
|
7
8
|
from pathlib import Path
|
|
@@ -32,11 +33,13 @@ DEFAULT_REMOTE: dict[str, Any] = {
|
|
|
32
33
|
"local": {
|
|
33
34
|
"host": "127.0.0.1",
|
|
34
35
|
"port": 8765,
|
|
35
|
-
"
|
|
36
|
+
"appId": "",
|
|
37
|
+
"appKey": "",
|
|
36
38
|
},
|
|
37
39
|
"server": {
|
|
38
40
|
"url": "",
|
|
39
|
-
"
|
|
41
|
+
"appId": "",
|
|
42
|
+
"appKey": "",
|
|
40
43
|
"device_name": "",
|
|
41
44
|
"device_id": "",
|
|
42
45
|
"auto_connect": True,
|
|
@@ -103,6 +106,10 @@ def _new_device_id() -> str:
|
|
|
103
106
|
return f"ccode-{uuid.uuid4().hex}"
|
|
104
107
|
|
|
105
108
|
|
|
109
|
+
def _new_app_id() -> str:
|
|
110
|
+
return f"app_{secrets.token_urlsafe(9)}"
|
|
111
|
+
|
|
112
|
+
|
|
106
113
|
def normalize_remote(remote: Any) -> dict[str, Any]:
|
|
107
114
|
normalized = copy.deepcopy(DEFAULT_REMOTE)
|
|
108
115
|
if not isinstance(remote, dict):
|
|
@@ -132,18 +139,31 @@ def normalize_remote(remote: Any) -> dict[str, Any]:
|
|
|
132
139
|
port = remote.get("port")
|
|
133
140
|
if isinstance(port, int):
|
|
134
141
|
normalized["local"]["port"] = port
|
|
135
|
-
|
|
136
|
-
if
|
|
137
|
-
|
|
138
|
-
if isinstance(
|
|
139
|
-
|
|
142
|
+
local_app_id = local.get("appId") if isinstance(local, dict) else None
|
|
143
|
+
local_app_key = local.get("appKey") if isinstance(local, dict) else None
|
|
144
|
+
local_token = local.get("token") if isinstance(local, dict) else None
|
|
145
|
+
if not isinstance(local_token, str):
|
|
146
|
+
local_token = remote.get("token")
|
|
147
|
+
if isinstance(local_app_id, str):
|
|
148
|
+
normalized["local"]["appId"] = local_app_id
|
|
149
|
+
elif isinstance(local_token, str) and local_token:
|
|
150
|
+
normalized["local"]["appId"] = _new_app_id()
|
|
151
|
+
if isinstance(local_app_key, str):
|
|
152
|
+
normalized["local"]["appKey"] = local_app_key
|
|
153
|
+
elif isinstance(local_token, str):
|
|
154
|
+
normalized["local"]["appKey"] = local_token
|
|
140
155
|
|
|
141
156
|
server = remote.get("server") if isinstance(remote.get("server"), dict) else {}
|
|
142
157
|
if isinstance(server, dict):
|
|
143
|
-
|
|
158
|
+
server_token = server.get("token")
|
|
159
|
+
for key in ("url", "appId", "appKey", "device_name", "device_id"):
|
|
144
160
|
value = server.get(key)
|
|
145
161
|
if isinstance(value, str):
|
|
146
162
|
normalized["server"][key] = value
|
|
163
|
+
if not normalized["server"].get("appId") and isinstance(server_token, str) and server_token:
|
|
164
|
+
normalized["server"]["appId"] = _new_app_id()
|
|
165
|
+
if not normalized["server"].get("appKey") and isinstance(server_token, str):
|
|
166
|
+
normalized["server"]["appKey"] = server_token
|
|
147
167
|
for key in ("auto_connect", "verify_tls"):
|
|
148
168
|
value = server.get(key)
|
|
149
169
|
if isinstance(value, bool):
|
|
@@ -15,20 +15,20 @@ from .remote_common import (
|
|
|
15
15
|
_new_session_id,
|
|
16
16
|
_sanitize_session_name,
|
|
17
17
|
_utc_now,
|
|
18
|
+
AuthResult,
|
|
18
19
|
asset_text,
|
|
19
|
-
auth_token_matches,
|
|
20
20
|
file_lock,
|
|
21
21
|
missing_extra_message,
|
|
22
|
+
remote_local_app_id,
|
|
23
|
+
remote_local_app_key,
|
|
22
24
|
remote_local_host_port,
|
|
23
|
-
remote_local_token,
|
|
24
25
|
remote_local_url,
|
|
25
26
|
remote_mode,
|
|
26
27
|
remote_server_config,
|
|
27
28
|
remote_server_url,
|
|
28
29
|
rotate_log,
|
|
29
|
-
|
|
30
|
+
verify_auth_payload,
|
|
30
31
|
web_asset_text,
|
|
31
|
-
websocket_auth_token,
|
|
32
32
|
)
|
|
33
33
|
|
|
34
34
|
REMOTE_SESSIONS_PATH = CONFIG_DIR / "remote_sessions.json"
|
|
@@ -321,29 +321,33 @@ def ensure_remote_hub(config: dict[str, Any]) -> str | None:
|
|
|
321
321
|
return f"Failed to start remote hub. See {REMOTE_SERVER_LOG_PATH} for details."
|
|
322
322
|
|
|
323
323
|
|
|
324
|
-
|
|
324
|
+
_AUTH_CACHE: tuple[float | None, str, str] = (None, "", "")
|
|
325
325
|
|
|
326
326
|
|
|
327
|
-
def
|
|
328
|
-
global
|
|
327
|
+
def _current_remote_credentials() -> tuple[str, str]:
|
|
328
|
+
global _AUTH_CACHE
|
|
329
329
|
try:
|
|
330
330
|
mtime = CONFIG_PATH.stat().st_mtime
|
|
331
331
|
except FileNotFoundError:
|
|
332
332
|
mtime = None
|
|
333
|
-
cached_mtime,
|
|
333
|
+
cached_mtime, app_id, app_key = _AUTH_CACHE
|
|
334
334
|
if cached_mtime == mtime:
|
|
335
|
-
return
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
335
|
+
return app_id, app_key
|
|
336
|
+
config = load_config()
|
|
337
|
+
app_id = remote_local_app_id(config).strip()
|
|
338
|
+
app_key = remote_local_app_key(config).strip()
|
|
339
|
+
_AUTH_CACHE = (mtime, app_id, app_key)
|
|
340
|
+
return app_id, app_key
|
|
339
341
|
|
|
340
342
|
|
|
341
|
-
def
|
|
342
|
-
|
|
343
|
+
def _valid_auth_payload(payload: dict[str, Any], scope: str) -> AuthResult:
|
|
344
|
+
app_id, app_key = _current_remote_credentials()
|
|
345
|
+
return verify_auth_payload(app_id, app_key, payload, scope)
|
|
343
346
|
|
|
344
347
|
|
|
345
|
-
def
|
|
346
|
-
|
|
348
|
+
def _auth_response(result: AuthResult, response_class: Any) -> Any:
|
|
349
|
+
status_code = 401 if result.error in {"auth_required", "reauth_required"} else 403
|
|
350
|
+
return response_class({"error": result.error or "auth_failed", "message": result.message}, status_code=status_code)
|
|
347
351
|
|
|
348
352
|
|
|
349
353
|
async def _attach_websocket_to_session(websocket: Any, session: dict[str, Any]) -> None:
|
|
@@ -402,11 +406,11 @@ def create_app() -> Any:
|
|
|
402
406
|
|
|
403
407
|
@app.get("/app.js")
|
|
404
408
|
async def app_js() -> Response:
|
|
405
|
-
return Response(asset_text("app.js"), media_type="application/javascript")
|
|
409
|
+
return Response(asset_text("app.js"), media_type="application/javascript", headers={"Cache-Control": "no-store"})
|
|
406
410
|
|
|
407
411
|
@app.get("/style.css")
|
|
408
412
|
async def style_css() -> Response:
|
|
409
|
-
return Response(asset_text("style.css"), media_type="text/css")
|
|
413
|
+
return Response(asset_text("style.css"), media_type="text/css", headers={"Cache-Control": "no-store"})
|
|
410
414
|
|
|
411
415
|
@app.get("/assets/{asset_name}")
|
|
412
416
|
async def web_asset(asset_name: str) -> Response:
|
|
@@ -418,20 +422,22 @@ def create_app() -> Any:
|
|
|
418
422
|
media_type = media_types.get(asset_name)
|
|
419
423
|
if media_type is None:
|
|
420
424
|
return Response(status_code=404)
|
|
421
|
-
return Response(web_asset_text(asset_name), media_type=media_type)
|
|
425
|
+
return Response(web_asset_text(asset_name), media_type=media_type, headers={"Cache-Control": "no-store"})
|
|
422
426
|
|
|
423
427
|
@app.get("/health")
|
|
424
428
|
async def health() -> dict[str, Any]:
|
|
425
|
-
return {"ok": True, "name": REMOTE_HUB_NAME, "multi_session": True}
|
|
429
|
+
return {"ok": True, "name": REMOTE_HUB_NAME, "multi_session": True, "remote_kind": "local"}
|
|
426
430
|
|
|
427
431
|
@app.get("/api/sessions")
|
|
428
432
|
async def sessions(
|
|
429
|
-
|
|
430
|
-
|
|
433
|
+
x_ccode_app_id: str | None = Header(default=None),
|
|
434
|
+
x_ccode_timestamp: str | None = Header(default=None),
|
|
435
|
+
x_ccode_sign: str | None = Header(default=None),
|
|
431
436
|
) -> Response:
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
437
|
+
auth_result = _valid_auth_payload({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "local.api")
|
|
438
|
+
if not auth_result.ok:
|
|
439
|
+
_log_event(f"api sessions unauthorized error={auth_result.error}")
|
|
440
|
+
return _auth_response(auth_result, JSONResponse)
|
|
435
441
|
sessions = list_remote_sessions()
|
|
436
442
|
_log_event(f"api sessions returned count={len(sessions)}")
|
|
437
443
|
return JSONResponse({"sessions": sessions})
|
|
@@ -439,12 +445,14 @@ def create_app() -> Any:
|
|
|
439
445
|
@app.get("/api/sessions/{session_id}")
|
|
440
446
|
async def session_detail(
|
|
441
447
|
session_id: str,
|
|
442
|
-
|
|
443
|
-
|
|
448
|
+
x_ccode_app_id: str | None = Header(default=None),
|
|
449
|
+
x_ccode_timestamp: str | None = Header(default=None),
|
|
450
|
+
x_ccode_sign: str | None = Header(default=None),
|
|
444
451
|
) -> Response:
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
452
|
+
auth_result = _valid_auth_payload({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "local.api")
|
|
453
|
+
if not auth_result.ok:
|
|
454
|
+
_log_event(f"api session detail unauthorized session_id={session_id} error={auth_result.error}")
|
|
455
|
+
return _auth_response(auth_result, JSONResponse)
|
|
448
456
|
session = get_remote_session(session_id)
|
|
449
457
|
if session is None or not _session_is_running(session):
|
|
450
458
|
_log_event(f"api session detail not_found session_id={session_id}")
|
|
@@ -454,12 +462,14 @@ def create_app() -> Any:
|
|
|
454
462
|
|
|
455
463
|
@app.get("/api/session")
|
|
456
464
|
async def legacy_session(
|
|
457
|
-
|
|
458
|
-
|
|
465
|
+
x_ccode_app_id: str | None = Header(default=None),
|
|
466
|
+
x_ccode_timestamp: str | None = Header(default=None),
|
|
467
|
+
x_ccode_sign: str | None = Header(default=None),
|
|
459
468
|
) -> Response:
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
469
|
+
auth_result = _valid_auth_payload({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "local.api")
|
|
470
|
+
if not auth_result.ok:
|
|
471
|
+
_log_event(f"legacy api session unauthorized error={auth_result.error}")
|
|
472
|
+
return _auth_response(auth_result, JSONResponse)
|
|
463
473
|
sessions = list_remote_sessions()
|
|
464
474
|
_log_event(f"legacy api session requested running_count={len(sessions)}")
|
|
465
475
|
if len(sessions) == 1:
|
|
@@ -471,9 +481,10 @@ def create_app() -> Any:
|
|
|
471
481
|
async def websocket_session(websocket: FastAPIWebSocket, session_id: str) -> None:
|
|
472
482
|
await websocket.accept()
|
|
473
483
|
_log_event(f"websocket accepted session_id={session_id}")
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
484
|
+
auth_result = _valid_auth_payload(dict(websocket.query_params), "local.ws")
|
|
485
|
+
if not auth_result.ok:
|
|
486
|
+
_log_event(f"websocket unauthorized session_id={session_id} error={auth_result.error}")
|
|
487
|
+
await websocket.send_json({"type": "error", "error": auth_result.error, "message": auth_result.message})
|
|
477
488
|
await websocket.close(code=1008)
|
|
478
489
|
return
|
|
479
490
|
session = get_remote_session(session_id)
|
|
@@ -488,9 +499,10 @@ def create_app() -> Any:
|
|
|
488
499
|
async def legacy_websocket(websocket: FastAPIWebSocket) -> None:
|
|
489
500
|
await websocket.accept()
|
|
490
501
|
_log_event("legacy websocket accepted")
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
502
|
+
auth_result = _valid_auth_payload(dict(websocket.query_params), "local.ws")
|
|
503
|
+
if not auth_result.ok:
|
|
504
|
+
_log_event(f"legacy websocket unauthorized error={auth_result.error}")
|
|
505
|
+
await websocket.send_json({"type": "error", "error": auth_result.error, "message": auth_result.message})
|
|
494
506
|
await websocket.close(code=1008)
|
|
495
507
|
return
|
|
496
508
|
sessions = list_remote_sessions()
|
|
@@ -530,11 +542,11 @@ def run_remote_hub_forever(config: dict[str, Any]) -> str | None:
|
|
|
530
542
|
|
|
531
543
|
def run_remote_session(config: dict[str, Any], args: list[str]) -> str | None:
|
|
532
544
|
mode = remote_mode(config)
|
|
533
|
-
if mode == "local" and not
|
|
534
|
-
return "Remote
|
|
545
|
+
if mode == "local" and (not remote_local_app_id(config).strip() or not remote_local_app_key(config).strip()):
|
|
546
|
+
return "Remote app ID and app key are required. Set them in the config screen or press g on the APP ID / APP KEY fields to generate them."
|
|
535
547
|
server = remote_server_config(config)
|
|
536
|
-
if mode == "server" and (not remote_server_url(config) or not str(server.get("
|
|
537
|
-
return "Remote server URL and client
|
|
548
|
+
if mode == "server" and (not remote_server_url(config) or not str(server.get("appId") or "").strip() or not str(server.get("appKey") or "").strip()):
|
|
549
|
+
return "Remote server URL, client app ID, and client app key are required in server mode."
|
|
538
550
|
error = _check_runtime()
|
|
539
551
|
if error is not None:
|
|
540
552
|
return error
|
|
@@ -604,7 +616,7 @@ def run_remote_session(config: dict[str, Any], args: list[str]) -> str | None:
|
|
|
604
616
|
print(f" {session_name}\n")
|
|
605
617
|
if host == "0.0.0.0":
|
|
606
618
|
print("Security warning:")
|
|
607
|
-
print(" Listening on 0.0.0.0 exposes all remote sessions to your network. Protect the access URL and
|
|
619
|
+
print(" Listening on 0.0.0.0 exposes all remote sessions to your network. Protect the access URL and app credentials.\n")
|
|
608
620
|
print("This terminal will attach to this tmux session. The web hub stays running independently.")
|
|
609
621
|
print("Detach with Ctrl+B then D. Browser users can choose this session from the session list.\n")
|
|
610
622
|
|
|
@@ -8,7 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
10
|
from .config import load_config, mask_secret
|
|
11
|
-
from .remote_common import pid_from_path, remote_display_url,
|
|
11
|
+
from .remote_common import pid_from_path, remote_display_url, remote_local_app_id, remote_local_app_key, remote_local_host_port, remote_mode, remote_server_config, remote_server_url
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def _format_bytes(value: int) -> str:
|
|
@@ -90,12 +90,14 @@ def print_status(kind: str | None = None) -> int:
|
|
|
90
90
|
server = remote_server_config(config)
|
|
91
91
|
print(f" device name: {server.get('device_name') or '<unset>'}")
|
|
92
92
|
print(f" device id: {server.get('device_id') or '<unset>'}")
|
|
93
|
-
print(f" client
|
|
93
|
+
print(f" client app id: {server.get('appId') or '<unset>'}")
|
|
94
|
+
print(f" client app key: {mask_secret(str(server.get('appKey') or ''))}")
|
|
94
95
|
else:
|
|
95
96
|
host, port = remote_local_host_port(config)
|
|
96
97
|
print(f" host: {host}")
|
|
97
98
|
print(f" port: {port}")
|
|
98
|
-
print(f"
|
|
99
|
+
print(f" app id: {remote_local_app_id(config) or '<unset>'}")
|
|
100
|
+
print(f" app key: {mask_secret(remote_local_app_key(config))}")
|
|
99
101
|
print()
|
|
100
102
|
|
|
101
103
|
if effective == "client":
|