diff --git a/AGENTS.md b/AGENTS.md index bfdf125..869df52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,7 @@ ComfyUI-Manager 收录审核检查的就是这几类口子(2026-10 审 Anomalo 1. **不执行下载来的或用户文件里的代码。** 数据不用 `pickle`、`eval`、`exec`、动态导入读;配置文件当数据解析。模型文件只读 safetensors / GGUF 的文件头,`.ckpt` / `.pt` / `.pth` / `.bin` 不打开,界面提醒它们可能带代码。 2. **下载要能核对。** 只从允许的 https 站点下载,先写 `.part`,核对 SHA-256 后才改名放入;没有可核对的指纹时在界面上说明。下载的东西只当数据用。 3. **接口不接受任意本机路径。** 只收“类型 + 根目录序号 + 相对路径”,在后端用 `resolve_within` 校验;需要指向本机某个文件时,由服务端自己找到它,只给页面一个令牌。模型文件本身可以是链接(云服务器上模型文件夹里全是指向平台模型盘的链接):文件夹部分照样用 `resolve_within`,文件用 `model_file_within` / `model_reference_within` 按名字取,不跟着链接走出去,封面、信息和改动都在链接旁边。 -4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机)。用 `--listen` 开放到局域网时,别的设备只能查看。由 `api/local_guard.py` 的中间件统一把关:`/anomalous/` 下 GET 以外的请求默认只接受本机,确实只读的 POST 列进 `OPEN_POSTS`,不只是读的 GET(备份文件、联网、本机文件夹)列进 `LOCAL_GETS`;新接口要想清楚属于哪一类,不要为了方便放进 `OPEN_POSTS`。唯一的例外是云服务器用户在服务器上的插件文件夹里放了 `allow_remote_changes` 文件(启动时读一次,网页建不了也删不掉):这时 `local_guard.may_change` 也放行别的设备;接口里要自己判断时用 `may_change`,只有“浏览器就在这台电脑上”才成立的事(比如在本机的下载文件夹里找原文件)才用 `is_local`。MCP 始终只接受本机。 +4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机;ComfyUI 只监听局域网地址时,本机用这个地址打开也算本机)。用 `--listen` 开放到局域网时,别的设备只能查看。由 `api/local_guard.py` 的中间件统一把关:`/anomalous/` 下 GET 以外的请求默认只接受本机,确实只读的 POST 列进 `OPEN_POSTS`,不只是读的 GET(备份文件、联网、本机文件夹)列进 `LOCAL_GETS`;新接口要想清楚属于哪一类,不要为了方便放进 `OPEN_POSTS`。唯一的例外是云服务器用户在服务器上的插件文件夹里放了 `allow_remote_changes` 文件(启动时读一次,网页建不了也删不掉):这时 `local_guard.may_change` 也放行别的设备;接口里要自己判断时用 `may_change`,只有“浏览器就在这台电脑上”才成立的事(比如在本机的下载文件夹里找原文件)才用 `is_local`。MCP 始终只接受本机。 5. **用户的文件删除一律进回收站,没有回收站就拒绝;从不覆盖**(同名另存为“名字 (2)”或拒绝)。插件自己产生的临时文件(`.part`、暂存区)可以直接删。 6. **只读的接口也按第 3 条来。** “只是读”不是例外;能按名字、编号取的,就不收路径,由服务端在自己登记的范围里找到文件。 7. **读用户文件要有上限。** 限制读取的大小和条数;读不了或超大的文件跳过并在结果里说明,不让一个坏文件拖垮整个请求。读到的内容进页面只当纯文本(本节第二条)。 diff --git a/CHANGELOG.md b/CHANGELOG.md index ad47bec..d317566 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -210,6 +210,13 @@ - Colouring a line drawing uses Anima's lineart LLLite on an Anima model. - Each way is now a data file of its own (api/blocks/): its nodes, the packs and models it needs, what the AI is told and the panel's words in both languages, so a way collected later is one more file. +## v1.62.4 (Fix: opened at the computer's LAN address, changes were refused) — unreleased + +- ComfyUI 只监听局域网地址(比如 `--listen 192.168.1.5`)时,在运行 ComfyUI 的这台电脑上用这个地址打开,现在也算本机:扫描、改设置、填代理都能用了。以前它被当成别的设备,只能浏览。 +- 设置页的代理和“测试网络”从别的设备打开时会说清原因:只能在运行 ComfyUI 的电脑上改。以前代理总说“格式写错了”,测试说“连不上 Civitai”,其实格式和网络都没问题。被拒绝的提示也会指向窗口顶上的“仅浏览”:点开有从别的设备也能用的办法(服务器上的 `allow_remote_changes` 文件)。 +- With ComfyUI listening on its LAN address only (say `--listen 192.168.1.5`), opening it at that address on the computer it runs on now counts as this computer: scans, settings and the proxy work. Before, it was taken for another device and could only look. +- Opened from another device, the settings page's proxy and network test now say why nothing happened: only the computer ComfyUI runs on can do that. Before, the proxy always said the format was wrong and the test said Civitai could not be reached, though neither was the case. Refusals also point to the View only chip at the top of the window, which says how to use the plugin from other devices too (the `allow_remote_changes` file on the server). + ## v1.62.3 (Fix: ComfyUI-Manager took other packs' nodes for AMB's) — 2026-11-05 - ComfyUI-Manager 不再把别的插件的节点算到 AMB 头上:AMB 没有节点,是测试文件里的假节点表让它误以为有,于是缺 Power Lora Loader 等节点时会推荐装 AMB。Manager 重新扫描后生效。AMB 本身用起来没有变化。 diff --git a/api/local_guard.py b/api/local_guard.py index cc63992..167b9e8 100644 --- a/api/local_guard.py +++ b/api/local_guard.py @@ -6,7 +6,9 @@ version switches, saved settings) answers only requests from the computer ComfyU GET is local-only unless it is a read sent as POST (OPEN_POSTS); a GET is open unless it is not a plain read (LOCAL_GETS). A request is local when it comes from a loopback address, through no proxy, naming a loopback host; checking the Host also keeps out a page that reached this server -under another name (DNS rebinding). +under another name (DNS rebinding). A browser on this computer that opens ComfyUI at one of its own +addresses (ComfyUI listening on its LAN address only) is local too: the request then comes from +the very address it reached, which no other device can have, and names it. ComfyUI on a rented cloud server is only ever opened from another device. An empty file named `allow_remote_changes` in the plugin's folder, made on that server, lets every device that can @@ -47,8 +49,9 @@ REMOTE_CHANGES = os.path.isfile(REMOTE_FILE) if REMOTE_CHANGES: print("[Anomalous Browser] allow_remote_changes 文件存在:任何能打开页面的设备都能扫描、移动、删除模型和改设置。" " The allow_remote_changes file is there: every device that can open the page may change things.", flush=True) -MESSAGE = ("只能在运行 ComfyUI 的电脑上操作,别的设备只能浏览。" - " Only the computer ComfyUI runs on can do this; other devices can only look.") +MESSAGE = ("只能在运行 ComfyUI 的电脑上操作,别的设备只能浏览;窗口顶上的「仅浏览」里有远程也能用的办法。" + " Only the computer ComfyUI runs on can do this; other devices can only look." + " The View only chip at the top of the window says how to use it remotely too.") def loopback_host(name): @@ -63,13 +66,32 @@ def loopback_host(name): return False +def _address(name): + """An IP address from a peer, socket or Host name (IPv4 inside IPv6 as IPv4), or None.""" + try: + address = ipaddress.ip_address(str(name or "").strip("[]").split("%")[0]) + except ValueError: + return None + return getattr(address, "ipv4_mapped", None) or address + + +def _own_address(request): + """This server's end of the request's connection: the address the browser reached.""" + transport = request.transport + sockname = transport.get_extra_info("sockname") if transport else None + return _address(sockname[0]) if isinstance(sockname, (tuple, list)) and sockname else None + + def is_local(request): - """The request comes from this computer, directly, and names a loopback host.""" - if not loopback_host(request.remote or ""): - return False + """The request comes from this computer, directly, and names a loopback host, or else this + computer's own address that it came from and reached.""" if any(header in request.headers for header in PROXY_HEADERS): return False - return loopback_host(urllib.parse.urlsplit("//" + request.headers.get("Host", "")).hostname) + host = urllib.parse.urlsplit("//" + request.headers.get("Host", "")).hostname + if loopback_host(request.remote or ""): + return loopback_host(host) + own = _own_address(request) + return own is not None and _address(request.remote) == own and _address(host) == own def may_change(request): diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index 7ee904c..91af176 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -65,8 +65,12 @@ unless its path is in `LOCAL_GETS` (`backup/file`, `version/releases`, `import/f every model file in a few words for the canvas's quick search. `/anomalous/mcp` answers other devices itself. Local means: a loopback peer, no proxy headers (`Forwarded`, `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Host`) and a loopback `Host`, so a -page that reached the server under another name (DNS rebinding) is refused too. A refusal is -HTTP 403 with `code: local_only` and a bilingual message. `GET /anomalous/access` tells the page +page that reached the server under another name (DNS rebinding) is refused too. ComfyUI listening +on its LAN address only is opened on its own computer at that address: a request whose peer is the +server's own end of the connection (`sockname`, which no other device can have) and whose `Host` +is that same IP is local as well. A refusal is HTTP 403 with `code: local_only` and a bilingual +message that points to the "view only" chip; the settings page's proxy and network test say so +instead of "wrong format" / "Civitai cannot be reached". `GET /anomalous/access` tells the page whether it is local; `remote_view.js` then shows a "view only" chip in the header. ComfyUI on a rented cloud server is only ever opened from another device. An empty diff --git a/docs/guides/cloud-server.md b/docs/guides/cloud-server.md index 22da17a..8ffe1cb 100644 --- a/docs/guides/cloud-server.md +++ b/docs/guides/cloud-server.md @@ -12,6 +12,8 @@ ComfyUI 装在租来的云服务器上(AutoDL、RunPod 之类),你在自己电脑的浏览器里打开它时,对插件来说页面是从“别的设备”打开的。为了不让别人远程改动你的文件,插件默认只让这种页面浏览:标题旁会显示 **仅浏览**,扫描、下载、移动、删除、保存这类改动会被拒绝。 +ComfyUI 在家里或公司的另一台电脑上,用局域网地址或 Tailscale 之类从别的设备打开,也是一样:下面的办法同样适用。在运行 ComfyUI 的那台电脑上打开不受影响,哪怕 ComfyUI 只监听局域网地址、你用这个地址打开(1.62.4 起)。 + 想在云服务器上照常使用,有两个办法,选一个就行。 ## 办法一:用 SSH 隧道打开(推荐,什么都不用改) @@ -77,4 +79,4 @@ You are helping someone use Anomalous Model Browser (a ComfyUI plugin) on a rent How far it goes: on an **overseas** cloud server (RunPod…) everything works, Civitai included. On a server in **mainland China** (AutoDL…) it works only in part: browsing, offline scans and tidying work, but Civitai is mostly out of reach, so lookups are unreliable and covers rarely download. -When civitai.com cannot be reached (often so from mainland China), the plugin asks Civitai's other address, civitai.red, which gives names and trigger words; the covers may not download. On a rented cloud server the page is always opened from another device, so the plugin is **view only** there by default. Either open ComfyUI through an **SSH tunnel** (the browser address starts with `127.0.0.1`; counts as this computer, nothing to change), or create an empty file named `allow_remote_changes` in the plugin folder on the server and restart ComfyUI (press **View only**: its dialog has the line to copy into the server's terminal, with the real path filled in). With that file, anyone who can open the address may scan, move, delete and save, and use your Civitai key, so only do it while the address stays private; delete the file and restart to go back. The page cannot create or remove the file, and the MCP endpoint stays local-only either way. +When civitai.com cannot be reached (often so from mainland China), the plugin asks Civitai's other address, civitai.red, which gives names and trigger words; the covers may not download. The same goes for ComfyUI on another computer at home or work opened over the LAN or Tailscale; on the computer ComfyUI runs on, its own LAN address counts as this computer (from 1.62.4). On a rented cloud server the page is always opened from another device, so the plugin is **view only** there by default. Either open ComfyUI through an **SSH tunnel** (the browser address starts with `127.0.0.1`; counts as this computer, nothing to change), or create an empty file named `allow_remote_changes` in the plugin folder on the server and restart ComfyUI (press **View only**: its dialog has the line to copy into the server's terminal, with the real path filled in). With that file, anyone who can open the address may scan, move, delete and save, and use your Civitai key, so only do it while the address stays private; delete the file and restart to go back. The page cannot create or remove the file, and the MCP endpoint stays local-only either way. diff --git a/tests/test_local_guard.py b/tests/test_local_guard.py index dcac0e2..9a9cbb8 100644 --- a/tests/test_local_guard.py +++ b/tests/test_local_guard.py @@ -18,33 +18,46 @@ sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])] from api import local_guard -def ask(method, path, remote="127.0.0.1", host="127.0.0.1:8188", **headers): - """Runs the middleware for one request; the handler answers 200 "done".""" +def ask(method, path, remote="127.0.0.1", host="127.0.0.1:8188", own=None, **headers): + """Runs the middleware for one request (`own`: the server's address it reached); the handler + answers 200 "done".""" async def handler(request): return web.Response(text="done") request = make_mocked_request(method, path, headers={"Host": host, **headers}, - transport=_Transport(remote)) + transport=_Transport(remote, own or remote)) response = asyncio.run(local_guard.local_only_middleware(request, handler)) return response.status, response.text class _Transport: - def __init__(self, remote): - self.remote = remote + def __init__(self, remote, own=None): + self.remote, self.own = remote, own or "127.0.0.1" def get_extra_info(self, name, default=None): - return (self.remote, 50000) if name == "peername" else default + return {"peername": (self.remote, 50000), "sockname": (self.own, 8188)}.get(name, default) class LocalGuardTests(unittest.TestCase): def test_changes_from_another_device_are_refused(self): for path in ("/anomalous/delete_model", "/anomalous/backup/export", "/anomalous/translate", "/anomalous/save_config", "/anomalous/version/switch", "/anomalous/download/start"): - status, text = ask("POST", path, remote="192.168.1.20", host="192.168.1.5:8188") + status, text = ask("POST", path, remote="192.168.1.20", host="192.168.1.5:8188", own="192.168.1.5") self.assertEqual(status, 403, path) self.assertEqual(json.loads(text)["code"], "local_only") + def test_this_computer_at_its_own_lan_address_may_change_things(self): + # ComfyUI listening on 192.168.1.5 only: this computer's browser comes from that very address. + mine = {"remote": "192.168.1.5", "host": "192.168.1.5:8188", "own": "192.168.1.5"} + self.assertEqual(ask("POST", "/anomalous/save_config", **mine)[0], 200) + self.assertEqual(ask("POST", "/anomalous/save_config", remote="::ffff:192.168.1.5", host="192.168.1.5:8188", + own="::ffff:192.168.1.5")[0], 200) # a dual-stack socket + self.assertEqual(ask("POST", "/anomalous/save_config", remote="fe80::5%12", host="[fe80::5]:8188", + own="fe80::5%12")[0], 200) + self.assertEqual(ask("POST", "/anomalous/save_config", **{**mine, "host": "evil.example:8188"})[0], 403) + self.assertEqual(ask("POST", "/anomalous/save_config", **{**mine, "X-Forwarded-For": "8.8.8.8"})[0], 403) + self.assertEqual(ask("POST", "/anomalous/save_config", **{**mine, "remote": "192.168.1.20"})[0], 403) + def test_the_lan_address_counts_as_another_device_even_from_this_computer(self): self.assertEqual(ask("POST", "/anomalous/delete_model", remote="127.0.0.1", host="192.168.1.5:8188")[0], 403) self.assertEqual(ask("POST", "/anomalous/delete_model", host="evil.example:8188")[0], 403) # DNS rebinding @@ -55,7 +68,7 @@ class LocalGuardTests(unittest.TestCase): self.assertEqual(ask("POST", "/anomalous/delete_model", host=host)[0], 200, host) def test_other_devices_may_look(self): - lan = {"remote": "192.168.1.20", "host": "192.168.1.5:8188"} + lan = {"remote": "192.168.1.20", "host": "192.168.1.5:8188", "own": "192.168.1.5"} for path in ("/anomalous/models", "/anomalous/image", "/anomalous/gallery_images", "/anomalous/recipes"): self.assertEqual(ask("GET", path, **lan)[0], 200, path) for path in local_guard.OPEN_POSTS: diff --git a/web/modules/locales.js b/web/modules/locales.js index f9ddfdb..e4b7020 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -2273,6 +2273,8 @@ export const i18n = { settingsProxyPlaceholder: '留空 = 系统代理', settingsProxyBad: '没有保存:格式写成 127.0.0.1:7890 或 http://127.0.0.1:7890。', settingsProxySocks: '没有保存:插件用不了 SOCKS 代理,请填代理软件的 HTTP 端口(在代理软件的设置里,常见 7890、7897、10809)。', + settingsProxyLocalOnly: '没有保存:这个窗口是从别的设备打开的,设置只能在运行 ComfyUI 的电脑上改。窗口顶上的「仅浏览」里有远程也能改的办法。', + settingsNetworkLocalOnly: '没有测试:这个窗口是从别的设备打开的,只有运行 ComfyUI 的电脑能替你联网。窗口顶上的「仅浏览」里有远程也能用的办法。', settingsNetworkTest: '测试连接', settingsNetworkTestHelp: '现在能不能连上 Civitai(用上面的代理)。', settingsNetworkTesting: '正在连接 Civitai…', @@ -5928,6 +5930,8 @@ export const i18n = { settingsProxyPlaceholder: 'Empty = system proxy', settingsProxyBad: 'Not saved: write it as 127.0.0.1:7890 or http://127.0.0.1:7890.', settingsProxySocks: "Not saved: the plugin cannot use a SOCKS proxy; give your VPN app's HTTP port (in its settings, often 7890, 7897 or 10809).", + settingsProxyLocalOnly: 'Not saved: this window was opened from another device, and settings can only be changed on the computer ComfyUI runs on. The View only chip at the top of the window says how to change them remotely too.', + settingsNetworkLocalOnly: 'Not tested: this window was opened from another device, and only the computer ComfyUI runs on goes online for you. The View only chip at the top of the window says how to use it remotely too.', settingsNetworkTest: 'Test connection', settingsNetworkTestHelp: 'Whether Civitai answers now (through the proxy above).', settingsNetworkTesting: 'Connecting to Civitai…', diff --git a/web/modules/ui_settings_page.js b/web/modules/ui_settings_page.js index f2adc8c..d7ce3a7 100644 --- a/web/modules/ui_settings_page.js +++ b/web/modules/ui_settings_page.js @@ -286,7 +286,9 @@ function networkGroup() { } catch (_) { /* shown below as not saved */ } const saved = data?.status === 'ok'; input.classList.toggle('is-bad', !saved); - proxyHelp.textContent = saved ? t('settingsProxyHelp') : t(data?.code === 'socks' ? 'settingsProxySocks' : 'settingsProxyBad'); + // Opened from another device, the server refuses the save (local_guard.py): not the address's fault. + const why = { socks: 'settingsProxySocks', local_only: 'settingsProxyLocalOnly' }[data?.code] || 'settingsProxyBad'; + proxyHelp.textContent = saved ? t('settingsProxyHelp') : t(why); if (saved) input.value = (await fetch('/anomalous/config').then(res => res.json()).catch(() => ({}))).proxy ?? input.value; }; @@ -295,7 +297,8 @@ function networkGroup() { testHelp.textContent = t('settingsNetworkTesting'); try { const data = await (await fetch('/anomalous/civitai_check')).json(); - testHelp.textContent = !data.reachable ? t('settingsNetworkFail', { why: failureWords(data.failure) }) + testHelp.textContent = data.code === 'local_only' ? t('settingsNetworkLocalOnly') + : !data.reachable ? t('settingsNetworkFail', { why: failureWords(data.failure) }) : data.key === 'refused' ? t('settingsNetworkKeyRefused') : t(data.host === 'civitai.red' ? 'settingsNetworkOkRed' : 'settingsNetworkOk', { ms: data.ms }); } catch (error) {