feat: per-domain backend routing

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.
This commit is contained in:
2026-05-23 17:25:14 +09:00
parent 9540a3a576
commit 58b112e449
7 changed files with 228 additions and 53 deletions

View File

@@ -129,9 +129,31 @@ class ProxyState:
return cfg_mod.allowed_domain_set(self.cfg)
def backend(self) -> tuple[str, int]:
"""기본 백엔드 (도메인 entry 에 backend 가 없을 때 fallback)."""
b = self.cfg["backend"]
return b["host"], int(b["port"])
def backend_for(self, domain: str) -> tuple[str, int] | None:
"""주어진 도메인이 활성 화이트리스트에 있으면 라우팅 대상을 돌려준다.
도메인 entry 에 `backend.host`/`backend.port` 가 있으면 그 값을 우선,
없으면 top-level `backend` 로 fallback. 도메인이 비활성이거나 없으면
None.
"""
d = domain.lower().strip()
for entry in self.cfg.get("allowed_domains", []):
if entry["domain"].lower().strip() != d:
continue
if not entry.get("enabled", True):
return None
be = entry.get("backend") or {}
host = (be.get("host") or "").strip()
port = be.get("port")
if host and port:
return host, int(port)
return self.backend()
return None
def enabled(self) -> bool:
return bool(self.cfg.get("proxy", {}).get("enabled", True))
@@ -212,8 +234,8 @@ async def handle_client(
return
domain = hs.server_address.lower().strip()
allowed = state.allowed()
if domain not in allowed:
target = state.backend_for(domain)
if target is None:
log_event(client_ip, domain, hs.next_state, "blocked", "domain not allowed")
log.info(
"BLOCK %s domain=%r next_state=%d", client_ip, domain, hs.next_state
@@ -230,7 +252,7 @@ async def handle_client(
client_writer.close()
return
backend_host, backend_port = state.backend()
backend_host, backend_port = target
try:
backend_reader, backend_writer = await asyncio.wait_for(
asyncio.open_connection(backend_host, backend_port), timeout=5