Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 195 additions & 20 deletions freevideo_engine/access_proxy.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Token-protected access to a ComfyUI that listens on this computer only.
"""Authenticated access to a ComfyUI that listens on this computer only.

The launcher's LAN switch or `freevideo server --listen ADDRESS` runs this in front of ComfyUI. The access
link carries the token once; the page then keeps it in a cookie, and API
Expand All @@ -9,17 +9,93 @@
import argparse
import asyncio
import hmac
import hashlib
import html
import math
import time
import ipaddress
import os
import re
from pathlib import Path
import ssl
from urllib.parse import urlencode
from urllib.parse import urlencode, urlsplit

from aiohttp import ClientSession, ClientTimeout, DummyCookieJar, TCPConnector, WSMsgType, client_exceptions, web
from yarl import URL
from . import proxy_identity
from . import proxy_identity, lan_password
from .lan_auth import PasswordAuth, DEVICE_COOKIE, REMEMBER_SECONDS, LOGIN_DELAY

PHONE_WEB = Path(__file__).resolve().parents[1] / 'web'
PHONE_FILE = re.compile(r'[A-Za-z0-9_][A-Za-z0-9_.-]*\.(?:js|css)')
PHONE_ASSETS = ('assets/freevideo.svg', 'assets/freevideo-light.svg')
PHONE_TYPES = {'.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml'}
# Inline like the password page: installed packages only carry .js/.css/.svg under web/.
PHONE_PAGE = (
'<!doctype html>'
'<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="referrer" content="same-origin"><title>FreeVideo</title><link rel="stylesheet" href="./theme.css"><link rel="stylesheet" href="./phone.css"></head><body class="fv-phone"><div id="phone"></div><script type="module" src="./phone.js"></script></body></html>')

COOKIE = 'freevideo_access'
DAYS = 30
LOGIN_PATH = '/m/login'
# Temporary English copy; keep all password-page text here for Iris.
PASSWORD_COPY = dict(title='FreeVideo', password='Password', remember='Keep me signed in',
submit='Sign in', incorrect='Incorrect password', retry='Try again later ({minutes} minutes).',
phone='FreeVideo mobile')


def password_page(*, error='', retry_after=0, status=401):
copy = PASSWORD_COPY
message = copy['retry'].format(minutes=math.ceil(retry_after / 60)) if retry_after else error
response = web.Response(status=status, content_type='text/html', text=(
'<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" '
'content="width=device-width,initial-scale=1"><title>{title}</title></head><body>'
'<h1>{title}</h1><p data-retry-after="{retry_after}">{message}</p><form method="post" action="{action}">'
'<label>{password}<input type="password" name="password" autocomplete="current-password" required></label>'
'<label><input type="checkbox" name="remember" value="1" checked>{remember}</label>'
'<button type="submit">{submit}</button></form></body></html>').format(
**{k: html.escape(v) for k, v in copy.items()}, message=html.escape(message), retry_after=retry_after, action=LOGIN_PATH))
response.headers['Cache-Control'] = 'no-store'
# Chromium sends Origin: null from a no-referrer page, which the login would refuse.
response.headers['Referrer-Policy'] = 'same-origin'
response.headers['Content-Security-Policy'] = "default-src 'none'; form-action 'self'; frame-ancestors 'none'"
if retry_after:
response.headers['Retry-After'] = str(retry_after)
response.headers['X-FreeVideo-Retry-After'] = str(retry_after)
return response


def phone_file(path, root=None):
"""The page file /m/<name> stands for: .js/.css directly inside web/ and the two wordmark SVGs (/m/ itself is PHONE_PAGE)."""
root = (root or PHONE_WEB).resolve()
name = path[len('/m/'):]
if not (PHONE_FILE.fullmatch(name) or name in PHONE_ASSETS):
return None
try:
found = (root / name).resolve(strict=True)
except (OSError, RuntimeError):
return None
return found if found.parent in (root, root / 'assets') and found.is_file() else None


def same_origin(request):
try:
if len(request.headers.getall('Host', [])) != 1 or len(request.headers.getall('Origin', [])) != 1:
return False
host = urlsplit(request.scheme + '://' + request.host)
origin = urlsplit(request.headers['Origin'])
socket = request.transport.get_extra_info('sockname')
allowed = {socket[0].split('%')[0]}
if ipaddress.ip_address(socket[0].split('%')[0]).is_loopback:
allowed.update(('localhost', '127.0.0.1', '::1'))
own_port = host.port or (443 if request.scheme == 'https' else 80)
return (host.hostname in allowed and own_port == socket[1] and origin.hostname == host.hostname
and (origin.port or (443 if origin.scheme == 'https' else 80)) == own_port
and origin.scheme == request.scheme and not any((origin.path, origin.query, origin.fragment,
origin.username, origin.password)))
except (ValueError, TypeError, IndexError, KeyError):
return False


# Hop-by-hop headers belong to one connection; Content-Length follows the body actually sent.
HOP_BY_HOP = frozenset(('connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'proxy-connection',
'te', 'trailer', 'transfer-encoding', 'upgrade', 'content-length'))
Expand Down Expand Up @@ -56,26 +132,88 @@ def forwarded(request):
headers = []
for name, value in request.headers.items():
lower = name.lower()
if lower in HOP_BY_HOP or lower in ('authorization', proxy_identity.HEADER.lower(),
proxy_identity.PEER.lower(), proxy_identity.ORIGIN.lower()):
if lower in HOP_BY_HOP or lower == 'authorization' or lower.startswith('x-freevideo-proxy-'):
continue
if lower == 'cookie':
value = '; '.join(part for part in value.split(';') if part.strip() and
part.split('=', 1)[0].strip() != COOKIE)
part.split('=', 1)[0].strip() not in (COOKIE, DEVICE_COOKIE))
if not value:
continue
headers.append((name, value))
return headers


def create(upstream, token):
def create(upstream, token, engine=None):
upstream = upstream.rstrip('/')
sockets = 'ws' + upstream[len('http'):]
client = []
hop_secret = os.environ.pop(proxy_identity.ENV, '')
auth = PasswordAuth(engine)
login_lock = asyncio.Lock()

def browser_headers(response, request):
device = request.get('lan_device')
if device and request.path in ('/m/', '/m/csrf'):
response.headers['X-FreeVideo-CSRF'] = proxy_identity.csrf(hop_secret, device)
session = request.get('lan_session')
if session and session['remember']:
response.set_cookie(DEVICE_COOKIE, request.cookies[DEVICE_COOKIE], max_age=REMEMBER_SECONDS,
httponly=True, samesite='Lax', secure=request.secure, path='/')
return response

async def login(request):
if not same_origin(request):
return web.Response(status=403)
# The rest of the proxy streams unlimited uploads. Only login is bounded.
body = bytearray()
async for chunk in request.content.iter_chunked(4096):
body.extend(chunk)
if len(body) > 16384:
return web.Response(status=413)
from urllib.parse import parse_qs
try:
if request.content_type != 'application/x-www-form-urlencoded':
return web.Response(status=415)
fields = parse_qs(body.decode('utf-8'), keep_blank_values=True, max_num_fields=8)
texts = fields.get('password', [''])
text = texts[0] if len(texts) == 1 else ''
remember = fields.get('remember') == ['1']
except (ValueError, UnicodeError):
return web.Response(status=400)
ip = request.remote or '' # Never trust X-Forwarded-For for attempt limits.
async with login_lock:
started = time.monotonic()
auth.reload()
record = auth.record
correct = await asyncio.to_thread(lan_password.verify_record, record, text)
await asyncio.sleep(max(0, LOGIN_DELAY - (time.monotonic() - started)))
auth.reload()
if not auth.enabled or auth.record != record:
return password_page(error=PASSWORD_COPY['incorrect'])
now = time.monotonic()
remaining = auth.attempts.remaining(ip, now)
if remaining:
auth.log(ip, 'locked')
return password_page(retry_after=remaining, status=429)
if not correct:
remaining = auth.attempts.failed(ip, now)
if remaining:
auth.log(ip, 'locked')
return password_page(error=PASSWORD_COPY['incorrect'], retry_after=remaining,
status=429 if remaining else 401)
value = auth.issue(remember, time.time())
auth.attempts.success(ip)
auth.log(ip, 'success')
response = web.Response(status=303, headers={'Location': '/m/', 'Cache-Control': 'no-store'})
response.set_cookie(DEVICE_COOKIE, value, max_age=REMEMBER_SECONDS if remember else None,
httponly=True, samesite='Lax', secure=request.secure, path='/')
return response

def forwarding_headers(request):
return forwarded(request) + proxy_identity.headers(request, hop_secret)
headers = forwarded(request) + proxy_identity.headers(request, hop_secret)
if request.get('lan_device'):
headers.append((proxy_identity.DEVICE, request['lan_device']))
return headers

async def startup(app):
# No total timeout: downloads and the progress socket last as long as they need.
Expand All @@ -96,6 +234,7 @@ async def forward(request):
response.headers.add(name, value)
if reply.content_length is not None and request.method != 'HEAD':
response.content_length = reply.content_length
browser_headers(response, request)
await response.prepare(request)
async for chunk in reply.content.iter_chunked(1 << 16):
await response.write(chunk)
Expand All @@ -105,7 +244,7 @@ async def forward(request):
return web.json_response(dict(error='ComfyUI is not answering yet.'), status=502)

async def websocket(request):
browser = web.WebSocketResponse(max_msg_size=0)
browser = browser_headers(web.WebSocketResponse(max_msg_size=0), request)
await browser.prepare(request)
headers = [(k, v) for k, v in forwarding_headers(request) if k.lower() not in (
'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-extensions', 'sec-websocket-protocol')]
Expand All @@ -131,18 +270,54 @@ async def pump(source, target):
return browser

async def handle(request):
offered = request.rel_url.query.get('token')
if offered is not None:
if not matches(offered, token):
auth.reload()
if auth.enabled:
if request.path == LOGIN_PATH and request.method == 'POST':
return await login(request)
session = auth.authenticate(request.cookies.get(DEVICE_COOKIE), time.time())
if session:
request['lan_device'] = session['hash']
request['lan_session'] = session
else:
header = request.headers.get('Authorization', '')
if not (header[:7].lower() == 'bearer ' and matches(header[7:].strip(), token)):
remaining = auth.attempts.remaining(request.remote or '', time.monotonic())
return password_page(retry_after=remaining, status=429 if remaining else 401)
else:
offered = request.rel_url.query.get('token')
if offered is not None:
if not matches(offered, token):
return denied(request)
# Preserve the existing one-time link exchange and cookie behavior.
rest = [(k, v) for k, v in request.rel_url.query.items() if k != 'token']
response = web.Response(status=303, headers={'Location': request.rel_url.path + ('?' + urlencode(rest) if rest else '')})
response.set_cookie(COOKIE, token, max_age=DAYS * 86400, httponly=True, samesite='Lax',
secure=request.secure, path='/')
return response
if not matches(supplied(request), token):
return denied(request)
# The access link: keep the token in a cookie and show the address without it.
if matches(request.cookies.get(COOKIE), token):
request['lan_device'] = hashlib.sha256(token.encode()).hexdigest()
# A query token in password mode neither authenticates nor reaches ComfyUI.
if auth.enabled and 'token' in request.query:
rest = [(k, v) for k, v in request.rel_url.query.items() if k != 'token']
response = web.Response(status=303, headers={'Location': request.rel_url.path + ('?' + urlencode(rest) if rest else '')})
response.set_cookie(COOKIE, token, max_age=DAYS * 86400, httponly=True, samesite='Lax',
secure=request.secure, path='/')
return response
if not matches(supplied(request), token):
return denied(request)
location = request.path + ('?' + urlencode(rest) if rest else '')
return browser_headers(web.Response(status=303, headers={'Location': location}), request)
if request.path.startswith('/m/') and request.path != LOGIN_PATH and request.method in ('GET', 'HEAD'):
if not request.get('lan_device'):
return web.Response(status=403)
if request.path == '/m/csrf':
response = web.json_response(dict(csrf=proxy_identity.csrf(hop_secret, request['lan_device'])))
elif request.path == '/m/':
response = web.Response(text=PHONE_PAGE if request.method == 'GET' else '', content_type='text/html', charset='utf-8')
else:
found = phone_file(request.path)
if found is None:
return web.Response(status=404, headers={'Cache-Control': 'no-store'})
response = web.Response(body=found.read_bytes() if request.method == 'GET' else b'',
content_type=PHONE_TYPES[found.suffix], charset='utf-8')
response.headers['Cache-Control'] = 'no-store'
return browser_headers(response, request)
if request.headers.get('Upgrade', '').lower() == 'websocket':
return await websocket(request)
return await forward(request)
Expand Down Expand Up @@ -172,7 +347,7 @@ def main(argv=None):
if args.tls_cert:
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
context.load_cert_chain(args.tls_cert, args.tls_key)
web.run_app(create(args.upstream, token), host=args.listen, port=args.port, ssl_context=context,
web.run_app(create(args.upstream, token, args.token_file.parent.parent), host=args.listen, port=args.port, ssl_context=context,
access_log=None, print=None)


Expand Down
9 changes: 5 additions & 4 deletions freevideo_engine/agent/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ def lan_enabled():
return False


def check(request, *, local=False):
def check(request, *, local=False, lan=False):
address = peer_address(request)
proxied = proxy_identity.HEADER in request.headers
# Even a proxy client on loopback is never a local control-plane client.
if (proxied and (not proxy_identity.trusted(request) or local or not lan_enabled())
or address is None or not address.is_loopback and (local or not address.is_private or not lan_enabled())):
# Only explicitly listed browser routes may use a verified proxy device.
local_only = local and not (proxied and lan and bool(proxy_identity.device(request)))
if (proxied and (not proxy_identity.trusted(request) or local_only or not lan_enabled())
or address is None or not address.is_loopback and (local_only or not address.is_private or not lan_enabled())):
raise JobError('local_only', 'Peer is not permitted')
socket = request.transport.get_extra_info('sockname') if request.transport else None
try:
Expand Down
2 changes: 2 additions & 0 deletions freevideo_engine/agent/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
ERRORS = {
'forbidden': (403, False, 'This caller cannot write this session event.', 'Only the user may stop or continue a session or answer a confirmation; steps are written by the server.'),
'session_full': (409, False, 'This session has reached its event limit.', 'Create a new session with POST /freevideo/agent/v1/sessions.'),
'lan_unavailable': (409, False, 'LAN settings require a launcher installation.', 'Complete installation in the FreeVideo launcher on this computer. 请在本机 FreeVideo 启动器中完成安装。'),
'page_session_required': (403, False, 'A valid page session and CSRF header are required.', 'Reload the page served by this FreeVideo instance and repeat the action.'),
'redirect_forbidden': (403, False, 'A credentialed request cannot follow a cross-origin redirect.', 'Connect directly to the intended FreeVideo instance using its verified HTTP or HTTPS endpoint.'),
'method_not_allowed': (405, False, 'This method is not registered for the requested endpoint.', 'Read GET /freevideo/agent/v1/openapi.json and use the documented method.'),
Expand Down Expand Up @@ -252,6 +253,7 @@ def safe_details(value):
# One public sentence shared by conversation cards, HTTP and MCP. Backend text
# is classified, never echoed: it can contain prompts, filenames or credentials.
FAILURE_REASONS_ZH = {
'lan_unavailable': '局域网设置需要启动器安装记录',
'out_of_memory': '生成时显存或内存不足',
'insufficient_memory': '可用显存或内存不够生成这段内容',
'no_memory': '可用显存或内存不够生成这张图片',
Expand Down
Loading