141 lines
6.3 KiB
Python
141 lines
6.3 KiB
Python
"""What only this computer may do. ComfyUI started with --listen lets other devices on the
|
|
network open the page: they may look (lists, searches, pictures, covers), but every change
|
|
under /anomalous/ (writing, moving and deleting files, downloads, scans, translations, backups,
|
|
version switches, saved settings) answers only requests from the computer ComfyUI runs on
|
|
(AGENTS.md 安全底线, rule 4). One middleware decides it for all routes: a request other than
|
|
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). 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
|
|
open the page change things too (may_change); it is read once, when ComfyUI starts, and the
|
|
page cannot make or remove it. MCP keeps to this computer either way."""
|
|
|
|
import ipaddress
|
|
import os
|
|
import shlex
|
|
import urllib.parse
|
|
|
|
from aiohttp import web
|
|
|
|
PREFIX = "/anomalous/"
|
|
PROXY_HEADERS = ("Forwarded", "X-Forwarded-For", "X-Real-IP", "X-Forwarded-Host")
|
|
LOCAL_NAMES = ("localhost",)
|
|
# Reads sent as POST (their lists are long): open to other devices too. They write nothing.
|
|
OPEN_POSTS = frozenset({
|
|
"/anomalous/resolve_paths_to_previews",
|
|
"/anomalous/resolve_hash_batch",
|
|
"/anomalous/inspect_image_material",
|
|
})
|
|
# GETs that are more than a read: a backup of all your data, a call to GitHub, your folders.
|
|
LOCAL_GETS = frozenset({
|
|
"/anomalous/backup/file",
|
|
"/anomalous/version/releases",
|
|
"/anomalous/tts_version/releases",
|
|
"/anomalous/import/folders",
|
|
"/anomalous/civitai_check",
|
|
"/anomalous/download/versions", # asks Civitai
|
|
"/anomalous/usage/check", # reads every output and saved workflow
|
|
})
|
|
# Routes that answer other devices themselves (MCP replies in JSON-RPC).
|
|
OWN_CHECK = frozenset({"/anomalous/mcp"})
|
|
READS = ("GET", "HEAD", "OPTIONS")
|
|
REMOTE_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "allow_remote_changes")
|
|
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."
|
|
" The View only chip at the top of the window says how to use it remotely too.")
|
|
|
|
|
|
def loopback_host(name):
|
|
if not name:
|
|
return False
|
|
name = name.strip("[]").lower()
|
|
if name in LOCAL_NAMES:
|
|
return True
|
|
try:
|
|
return ipaddress.ip_address(name).is_loopback
|
|
except ValueError:
|
|
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, 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
|
|
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):
|
|
"""The request may change things: it comes from this computer, or remote changes are allowed."""
|
|
return REMOTE_CHANGES or is_local(request)
|
|
|
|
|
|
def needs_local(method, path):
|
|
"""Whether a request to `path` with `method` must come from this computer."""
|
|
if not path.startswith(PREFIX) or path in OWN_CHECK:
|
|
return False
|
|
if method.upper() in READS:
|
|
return path in LOCAL_GETS
|
|
return path not in OPEN_POSTS
|
|
|
|
|
|
def refusal():
|
|
return web.json_response({"status": "error", "success": False, "error": "local_only", "code": "local_only",
|
|
"message": MESSAGE}, status=403)
|
|
|
|
|
|
@web.middleware
|
|
async def local_only_middleware(request, handler):
|
|
if needs_local(request.method, request.path) and not may_change(request):
|
|
return refusal()
|
|
return await handler(request)
|
|
|
|
|
|
def switch_command():
|
|
"""The line to paste into the server's terminal that makes the allow_remote_changes file, or
|
|
removes it when it is there. On Windows `echo off >` makes the file in cmd and PowerShell
|
|
alike (only that it exists counts)."""
|
|
if os.name == "nt":
|
|
return f'del "{REMOTE_FILE}"' if REMOTE_CHANGES else f'echo off > "{REMOTE_FILE}"'
|
|
return ("rm -f " if REMOTE_CHANGES else "touch ") + shlex.quote(REMOTE_FILE)
|
|
|
|
|
|
async def api_access(request):
|
|
"""GET /anomalous/access - whether this page is on this computer and whether it may change
|
|
things: {"local": bool, "changes": bool}; for another device also "command", the line that
|
|
turns remote changes on or off (it only runs where someone types it, on the server)."""
|
|
local = is_local(request)
|
|
answer = {"local": local, "changes": may_change(request)}
|
|
if not local:
|
|
answer["command"] = switch_command()
|
|
return web.json_response(answer)
|