codebee 0.1.6 → 0.1.8
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/CHANGELOG.md +18 -0
- package/README.md +415 -421
- package/app/core/automation.py +30 -5
- package/app/core/catalog.py +45 -3
- package/app/core/errorlog.py +179 -0
- package/app/core/flows.py +6 -2
- package/app/core/gitmod.py +45 -1
- package/app/core/health.py +55 -61
- package/app/core/jobs.py +104 -9
- package/app/core/manager.py +101 -11
- package/app/core/modelhub.py +2952 -2896
- package/app/core/paths.py +1 -0
- package/app/core/pipeline.py +425 -99
- package/app/core/remote.py +310 -303
- package/app/core/runner.py +148 -13
- package/app/core/selfupdate.py +47 -16
- package/app/core/settings.py +9 -2
- package/app/core/step_runner.py +28 -4
- package/app/core/store.py +160 -28
- package/app/core/telemetry.py +291 -0
- package/app/core/token_meter.py +18 -0
- package/app/core/usage.py +51 -1
- package/app/main.py +379 -37
- package/app/pick_dialog.py +78 -0
- package/app/ui/app.js +1006 -266
- package/app/ui/i18n.js +107 -7
- package/app/ui/index.html +148 -55
- package/app/ui/style.css +1816 -2
- package/package.json +1 -1
package/app/core/remote.py
CHANGED
|
@@ -1,303 +1,310 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""手机/远程访问:访问令牌 + 多端控制权锁 + 服务地址探测。
|
|
3
|
-
|
|
4
|
-
令牌:首次启动生成,落盘 data/remote.json。本机(127.0.0.1)请求豁免,
|
|
5
|
-
非 loopback 请求必须带令牌(?token= 或 X-CodeBee-Token 头)。
|
|
6
|
-
控制权:同一时刻只有一台设备能执行写操作。空闲自动接管、45s 无心跳自动释放,
|
|
7
|
-
可强制抢夺。纯内存状态,重启即清空。
|
|
8
|
-
"""
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import json
|
|
12
|
-
import re
|
|
13
|
-
import secrets
|
|
14
|
-
import shutil
|
|
15
|
-
import subprocess
|
|
16
|
-
import
|
|
17
|
-
import
|
|
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
|
-
p.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
_TRUST_PROXY
|
|
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
|
-
def
|
|
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
|
-
return control_view(client_id)
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
urls.append({"label": "
|
|
302
|
-
|
|
303
|
-
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""手机/远程访问:访问令牌 + 多端控制权锁 + 服务地址探测。
|
|
3
|
+
|
|
4
|
+
令牌:首次启动生成,落盘 data/remote.json。本机(127.0.0.1)请求豁免,
|
|
5
|
+
非 loopback 请求必须带令牌(?token= 或 X-CodeBee-Token 头)。
|
|
6
|
+
控制权:同一时刻只有一台设备能执行写操作。空闲自动接管、45s 无心跳自动释放,
|
|
7
|
+
可强制抢夺。纯内存状态,重启即清空。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import secrets
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
from . import paths
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------- 访问令牌
|
|
23
|
+
_TOK_LOCK = threading.Lock()
|
|
24
|
+
_TOKEN = ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def token() -> str:
|
|
28
|
+
"""读取(必要时生成)访问令牌。"""
|
|
29
|
+
global _TOKEN
|
|
30
|
+
if _TOKEN:
|
|
31
|
+
return _TOKEN
|
|
32
|
+
with _TOK_LOCK:
|
|
33
|
+
if _TOKEN:
|
|
34
|
+
return _TOKEN
|
|
35
|
+
p = paths.DATA_DIR / "remote.json"
|
|
36
|
+
try:
|
|
37
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
38
|
+
_TOKEN = str(data.get("token") or "")
|
|
39
|
+
except Exception:
|
|
40
|
+
_TOKEN = ""
|
|
41
|
+
if not _TOKEN:
|
|
42
|
+
_TOKEN = secrets.token_hex(4) # 8 位十六进制,手机好输
|
|
43
|
+
try:
|
|
44
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
p.write_text(json.dumps(
|
|
46
|
+
{"token": _TOKEN, "created": time.strftime("%Y-%m-%d %H:%M:%S")},
|
|
47
|
+
ensure_ascii=False, indent=2), encoding="utf-8")
|
|
48
|
+
except Exception:
|
|
49
|
+
pass # 落盘失败也照常工作(内存令牌,重启更换)
|
|
50
|
+
return _TOKEN
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------- 反向代理感知
|
|
54
|
+
# Cloudflare Tunnel / frp 等都从本机回源:若不感知代理,公网请求一律被当成
|
|
55
|
+
# 127.0.0.1 而豁免令牌——等于把控制台裸奔到公网。--trusted-proxy / TUTTI_TRUST_PROXY
|
|
56
|
+
# 开启后:带转发头(CF-Connecting-IP / X-Forwarded-For)的请求视为经代理进来的
|
|
57
|
+
# 远程请求,必须带令牌;不带转发头的 loopback 仍是真本机(浏览器直接开 localhost)。
|
|
58
|
+
# 安全性:Cloudflare 边缘强制注入/覆盖 CF-Connecting-IP,外部无法伪造透传;
|
|
59
|
+
# 而能直连本机端口的攻击者伪造转发头只会让自己从"本机豁免"变成"必须带令牌"。
|
|
60
|
+
_TRUST_PROXY = False
|
|
61
|
+
PUBLIC_URL = "" # --public-url / 快速隧道:扫码弹框优先展示的公网地址
|
|
62
|
+
PUBLIC_URL_KIND = "" # quick(trycloudflare 临时,重启变)| fixed(自有域名)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def set_trusted_proxy(on: bool, public_url: str = ""):
|
|
66
|
+
global _TRUST_PROXY, PUBLIC_URL
|
|
67
|
+
_TRUST_PROXY = bool(on)
|
|
68
|
+
if public_url:
|
|
69
|
+
PUBLIC_URL = str(public_url).rstrip("/")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------- 快速隧道(trycloudflare)
|
|
73
|
+
# 安装即公网:本机装了 cloudflared 就自动开一条 Cloudflare 快速隧道,
|
|
74
|
+
# 拿到随机 *.trycloudflare.com 地址——零账号、零域名、零配置。
|
|
75
|
+
# 安全强绑定:开隧道必然同时开启 trusted-proxy(否则回源 loopback 豁免=裸奔)。
|
|
76
|
+
# 代价:每次重启地址会变(手机重新扫码即可);要固定域名见 README 公网章节。
|
|
77
|
+
_QUICK_PROC = None # cloudflared 子进程,随主服务退出
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cloudflared_exe() -> str:
|
|
81
|
+
import os
|
|
82
|
+
exe = shutil.which("cloudflared")
|
|
83
|
+
if exe:
|
|
84
|
+
return exe
|
|
85
|
+
if sys.platform == "darwin":
|
|
86
|
+
# Homebrew(Apple Silicon /usr/local 旧装)不在 PATH 时的常见落点
|
|
87
|
+
for guess in ("/opt/homebrew/bin/cloudflared", "/usr/local/bin/cloudflared"):
|
|
88
|
+
if os.path.isfile(guess):
|
|
89
|
+
return guess
|
|
90
|
+
return ""
|
|
91
|
+
guess = os.path.join(os.environ.get("ProgramFiles(x86)", ""), "cloudflared", "cloudflared.exe")
|
|
92
|
+
return guess if os.path.isfile(guess) else ""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def has_local_creds() -> bool:
|
|
96
|
+
"""~/.cloudflared 下有隧道凭据(用户已玩过 named tunnel)。
|
|
97
|
+
|
|
98
|
+
实测(2026-09):本机存在凭据时 cloudflared 会把 quick tunnel 降级为
|
|
99
|
+
named 模式运行,随机域名恒 404;此时应走固定域名路径而不是 quick。
|
|
100
|
+
"""
|
|
101
|
+
import os
|
|
102
|
+
d = os.path.join(os.path.expanduser("~"), ".cloudflared")
|
|
103
|
+
try:
|
|
104
|
+
return any(f.endswith(".json") for f in os.listdir(d))
|
|
105
|
+
except Exception:
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def start_quick_tunnel(port: int, on_url) -> bool:
|
|
110
|
+
"""后台起快速隧道,解析到随机 URL 后回调 on_url(url)(如在主线程打印+推送)。
|
|
111
|
+
|
|
112
|
+
返回 False 表示没装 cloudflared 或启动失败,调用方给出提示。
|
|
113
|
+
"""
|
|
114
|
+
global _QUICK_PROC
|
|
115
|
+
exe = cloudflared_exe()
|
|
116
|
+
if not exe or not port:
|
|
117
|
+
return False
|
|
118
|
+
import subprocess
|
|
119
|
+
flags = 0x08000000 if hasattr(subprocess, "CREATE_NO_WINDOW") else 0 # CREATE_NO_WINDOW
|
|
120
|
+
try:
|
|
121
|
+
_QUICK_PROC = subprocess.Popen(
|
|
122
|
+
[exe, "tunnel", "--url", "http://localhost:%d" % port, "--no-autoupdate"],
|
|
123
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
124
|
+
creationflags=flags, text=True, encoding="utf-8", errors="replace")
|
|
125
|
+
except Exception:
|
|
126
|
+
_QUICK_PROC = None
|
|
127
|
+
return False
|
|
128
|
+
import threading
|
|
129
|
+
|
|
130
|
+
def _watch():
|
|
131
|
+
global PUBLIC_URL, PUBLIC_URL_KIND
|
|
132
|
+
deadline = time.time() + 25
|
|
133
|
+
found = False
|
|
134
|
+
try:
|
|
135
|
+
for line in _QUICK_PROC.stdout:
|
|
136
|
+
if not found:
|
|
137
|
+
m = re.search(r"https://[a-z0-9-]+\.trycloudflare\.com", line or "")
|
|
138
|
+
if m:
|
|
139
|
+
set_trusted_proxy(True) # 公网暴露 ⇆ 强制反代感知,防回源豁免
|
|
140
|
+
PUBLIC_URL = m.group(0)
|
|
141
|
+
PUBLIC_URL_KIND = "quick"
|
|
142
|
+
on_url(PUBLIC_URL)
|
|
143
|
+
found = True
|
|
144
|
+
# 不 break:必须继续消费日志,否则 cloudflared 写满管道
|
|
145
|
+
# 缓冲后会整体阻塞,边缘连接注册无法完成(实测 530)
|
|
146
|
+
if time.time() > deadline and not found:
|
|
147
|
+
break
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
threading.Thread(target=_watch, daemon=True).start()
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def stop_quick_tunnel():
|
|
156
|
+
global _QUICK_PROC
|
|
157
|
+
if _QUICK_PROC:
|
|
158
|
+
try:
|
|
159
|
+
_QUICK_PROC.terminate()
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
_QUICK_PROC = None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def effective_ip(socket_ip: str, forwarded: str) -> str:
|
|
166
|
+
"""信任代理时从转发头取真实客户端 IP(第一跳),否则用 socket 地址。"""
|
|
167
|
+
if _TRUST_PROXY and forwarded:
|
|
168
|
+
first = forwarded.split(",")[0].strip()
|
|
169
|
+
if first:
|
|
170
|
+
return first
|
|
171
|
+
return socket_ip
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def request_authed(client_ip: str, forwarded: str, query_token: str, header_token: str) -> bool:
|
|
175
|
+
"""本机豁免;远程(或经代理回源)请求必须带正确令牌。"""
|
|
176
|
+
loopback = client_ip in ("127.0.0.1", "::1")
|
|
177
|
+
if loopback and not (_TRUST_PROXY and forwarded):
|
|
178
|
+
return True # 真本机(trust_proxy 下不带转发头的 loopback 也算)
|
|
179
|
+
tok = token()
|
|
180
|
+
if not tok:
|
|
181
|
+
return True
|
|
182
|
+
return secrets.compare_digest(str(query_token or ""), tok) or \
|
|
183
|
+
secrets.compare_digest(str(header_token or ""), tok)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------- 控制权锁
|
|
187
|
+
CTRL_TTL = 45.0 # 秒;持锁设备停止心跳后自动释放
|
|
188
|
+
_CTRL = {"client_id": "", "name": "", "expires_at": 0.0}
|
|
189
|
+
# RLock:acquire/release 持锁期间会调 control_view,它也要拿同一把锁
|
|
190
|
+
_CTRL_LOCK = threading.RLock()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _live_ctrl():
|
|
194
|
+
"""返回未过期的持锁信息;过期则视为空闲(惰性过期,无需定时线程)。"""
|
|
195
|
+
if _CTRL["client_id"] and time.time() > _CTRL["expires_at"]:
|
|
196
|
+
_CTRL.update({"client_id": "", "name": "", "expires_at": 0.0})
|
|
197
|
+
return _CTRL
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def control_view(client_id: str = "") -> dict:
|
|
201
|
+
"""对外的控制权状态:free / held(mine 标记是否是请求方自己持有)。
|
|
202
|
+
|
|
203
|
+
expires_in 按 10s 桶化:SSE 靠对比该 dict 判断"控制权是否变了",
|
|
204
|
+
精确到秒的倒计时会造成每秒一次假变化 → 全量推送。
|
|
205
|
+
"""
|
|
206
|
+
with _CTRL_LOCK:
|
|
207
|
+
c = _live_ctrl()
|
|
208
|
+
if not c["client_id"]:
|
|
209
|
+
return {"mode": "free", "mine": False}
|
|
210
|
+
return {"mode": "held", "mine": bool(client_id) and c["client_id"] == client_id,
|
|
211
|
+
"holder": c["name"] or "其他设备",
|
|
212
|
+
"expires_in": max(10, int(c["expires_at"] - time.time()) // 10 * 10)}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def acquire(client_id: str, name: str, force: bool = False):
|
|
216
|
+
"""接管控制权。已持有则顺延心跳;空闲直接拿到;他人持有时除非 force 否则拒绝。
|
|
217
|
+
|
|
218
|
+
返回 (ok, control_view)。
|
|
219
|
+
"""
|
|
220
|
+
client_id = str(client_id or "")
|
|
221
|
+
if not client_id:
|
|
222
|
+
client_id = "anon-" + secrets.token_hex(4)
|
|
223
|
+
with _CTRL_LOCK:
|
|
224
|
+
c = _live_ctrl()
|
|
225
|
+
if c["client_id"] == client_id:
|
|
226
|
+
c["expires_at"] = time.time() + CTRL_TTL
|
|
227
|
+
return True, control_view(client_id)
|
|
228
|
+
if c["client_id"] and not force:
|
|
229
|
+
return False, control_view(client_id)
|
|
230
|
+
_CTRL.update({"client_id": client_id,
|
|
231
|
+
"name": _safe_name(name) or "其他设备",
|
|
232
|
+
"expires_at": time.time() + CTRL_TTL})
|
|
233
|
+
return True, control_view(client_id)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def release(client_id: str) -> dict:
|
|
237
|
+
with _CTRL_LOCK:
|
|
238
|
+
if client_id and _CTRL["client_id"] == client_id:
|
|
239
|
+
_CTRL.update({"client_id": "", "name": "", "expires_at": 0.0})
|
|
240
|
+
return control_view(client_id)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def heartbeat(client_id: str):
|
|
244
|
+
"""持锁续期。返回 (是否仍持有, control_view)。"""
|
|
245
|
+
ok, view = acquire(client_id, "")
|
|
246
|
+
return ok, view
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _safe_name(s) -> str:
|
|
250
|
+
return re.sub(r"\s+", " ", str(s or "")).strip()[:24]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ---------------------------------------------------------------- 地址探测
|
|
254
|
+
_TS_CACHE = {"ip": "", "ts": 0.0} # tailscale CLI 调用有开销,30s 缓存
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def lan_ip() -> str:
|
|
258
|
+
"""本机局域网 IP(UDP connect 只选路由不发包)。"""
|
|
259
|
+
import socket
|
|
260
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
261
|
+
try:
|
|
262
|
+
s.connect(("223.5.5.5", 80))
|
|
263
|
+
return s.getsockname()[0]
|
|
264
|
+
except Exception:
|
|
265
|
+
return ""
|
|
266
|
+
finally:
|
|
267
|
+
s.close()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def tailscale_ip(max_age: float = 30.0) -> str:
|
|
271
|
+
"""Tailscale 虚拟网 IP;未安装/未启动返回空。结果缓存 30s。"""
|
|
272
|
+
if max_age > 0 and time.time() - _TS_CACHE["ts"] < max_age:
|
|
273
|
+
return _TS_CACHE["ip"]
|
|
274
|
+
ip = _tailscale_ip_once()
|
|
275
|
+
_TS_CACHE.update({"ip": ip, "ts": time.time()})
|
|
276
|
+
return ip
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _tailscale_ip_once() -> str:
|
|
280
|
+
exe = shutil.which("tailscale")
|
|
281
|
+
if not exe:
|
|
282
|
+
return ""
|
|
283
|
+
try:
|
|
284
|
+
out = subprocess.run([exe, "ip", "-4"], capture_output=True,
|
|
285
|
+
text=True, timeout=5)
|
|
286
|
+
for line in (out.stdout or "").splitlines():
|
|
287
|
+
ip = line.strip()
|
|
288
|
+
if re.match(r"^\d+\.\d+\.\d+\.\d+$", ip):
|
|
289
|
+
return ip
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
return ""
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def build_connect_urls(port: int) -> list:
|
|
296
|
+
"""手机扫码可用的连接地址,按优先级排序:公网域名 > Tailscale > 局域网。"""
|
|
297
|
+
urls = []
|
|
298
|
+
if PUBLIC_URL:
|
|
299
|
+
label = ("公网 · 任何网络(重启会变,连不上重新扫码)" if PUBLIC_URL_KIND == "quick"
|
|
300
|
+
else "公网 · 任何网络")
|
|
301
|
+
urls.append({"label": label, "url": "%s/?token=%s" % (PUBLIC_URL, token())})
|
|
302
|
+
ts = tailscale_ip()
|
|
303
|
+
if ts:
|
|
304
|
+
urls.append({"label": "Tailscale · 外网随时随地",
|
|
305
|
+
"url": "http://%s:%d/?token=%s" % (ts, port, token())})
|
|
306
|
+
lan = lan_ip()
|
|
307
|
+
if lan:
|
|
308
|
+
urls.append({"label": "局域网 · 同一 WiFi",
|
|
309
|
+
"url": "http://%s:%d/?token=%s" % (lan, port, token())})
|
|
310
|
+
return urls
|