Two related diagnostics from production:
1) "Connection reset" instead of the custom block_message screen.
Root cause: writer.close() returned before the kernel flushed the
Login Disconnect packet, and the OS sent RST instead of FIN. Fix:
write_eof() + await wait_closed() so the FIN goes out after the
payload and the client has time to read the chat component.
2) Log entries showing reason "handshake error:" with an empty tail.
Root cause: bare OSError() / ConnectionResetError() have empty
str(), so the f-string interpolated to nothing. Fix: prepend the
exception class name so the reason is always informative.
Each allowed_domains entry can now carry its own backend {host, port}.
That lets one proxy on port 25565 serve multiple MC servers, picking
the upstream from the domain the client typed.
- proxy/main.py: ProxyState.backend_for(domain) → tuple|None,
honors per-domain backend first, falls back to top-level backend.
handle_client uses backend_for(); blocked / disabled domains
return None (and still get a Login Disconnect on join attempts).
- api/routes/{config,domains}.py: DomainBackend model + optional
backend field on create/patch. PATCH supports clear_backend=true
to drop a per-domain override and revert to default.
- frontend/Domains.jsx: full rewrite — new-domain form has host/port
inputs, table shows each row's effective backend, inline edit +
reset button per row.
- frontend/Settings.jsx: backend section relabeled "기본 백엔드 (fallback)"
- README updated with multi-server example config.
Previously a blocked join just dropped the socket, so the MC client
showed 'Internal Exception: SocketException: Connection reset'.
Now when next_state=2 (login), the proxy sends a proper Login
Disconnect (0x00) packet containing a JSON chat component, and the
client displays the message on its disconnect screen.
- block_message added to config (default Korean message); editable
in Settings UI as a textarea
- build_login_disconnect() encodes (varint length)+(0x00)+(JSON str)
- Status/ping (next_state=1) still silently dropped so the proxy
presence is not announced to scanners
- Backward-compat: load_config() backfills block_message on old files
Reviewer concerns:
1. POST /api/proxy/restart only saved config (reload), did not restart
the listener. Now it touches data/restart.signal; proxy watcher
polls that file separately and force-restarts the listener even
when config is unchanged.
2. Editing proxy.listen_port via UI could break Docker port mapping
(compose publishes 25565:25565 only). UI now shows it read-only;
README documents how to change it together with compose.
- proxy/main.py: ProxyState.check_restart_signal() + watcher uses it
- api/config_io.py: touch_restart_signal() helper
- api/routes/status.py: /api/proxy/restart -> touch_restart_signal()
- frontend Settings: disabled listen_port input + 프록시 재시작 button
- README + .gitignore updated