From 58b112e44927b932143a1dab37f02bb9aefe0077 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sat, 23 May 2026 17:25:14 +0900 Subject: [PATCH] feat: per-domain backend routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 8 +- api/routes/config.py | 6 + api/routes/domains.py | 20 +++- frontend/src/pages/Domains.jsx | 197 ++++++++++++++++++++++++-------- frontend/src/pages/Settings.jsx | 6 +- frontend/src/styles.css | 16 +++ proxy/main.py | 28 ++++- 7 files changed, 228 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index d1e46d7..c11ddbf 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - 허용 도메인 화이트리스트 매칭, 불일치 시 Login Disconnect 패킷으로 차단 사유(커스텀 가능) 표시 후 연결 종료 - 통과한 연결은 백엔드 MC 서버로 투명 TCP 중계 (Fabric / Paper / Spigot / NeoForge 등 서버 종류 무관) +- **도메인별 백엔드 라우팅**: 도메인마다 다른 IP:포트로 보낼 수 있어 하나의 25565 포트로 여러 MC 서버를 동시에 운영 가능 (예: `mc.tkrmagid.kr` → 게임PC:25565, `creative.tkrmagid.kr` → NAS:25566) - 설정 파일(`data/config.json`) 변경을 프록시가 자동 감지해 hot reload (재시작 불필요) - 모든 연결 시도(허용 / 차단 / 에러)를 SQLite 에 기록 - 웹 대시보드 (NPM 스타일): 도메인 관리, 실시간 로그, 통계 카드, 백엔드/포트 설정 @@ -43,13 +44,18 @@ mc-filter-proxy 컨테이너 (25565) { "proxy": { "listen_port": 25565, "enabled": true }, "backend": { "host": "192.168.0.20", "port": 25565 }, + "block_message": "이 서버는 허용된 도메인에서만 접속 가능합니다.", "allowed_domains": [ - { "domain": "mc.tkrmagid.kr", "enabled": true, "note": "메인 도메인" } + { "domain": "mc.tkrmagid.kr", "enabled": true, "note": "메인 서버" }, + { "domain": "creative.tkrmagid.kr", "enabled": true, "note": "크리에이티브", + "backend": { "host": "192.168.0.21", "port": 25566 } } ] } EOF ``` + 각 도메인 entry 에 `backend` 필드가 있으면 그 host:port 로, 없으면 top-level `backend` 로 라우팅됩니다. + 3. 전체 스택 빌드 & 실행 ```bash diff --git a/api/routes/config.py b/api/routes/config.py index c563f53..fa975b9 100644 --- a/api/routes/config.py +++ b/api/routes/config.py @@ -19,10 +19,16 @@ class BackendConfig(BaseModel): port: int = Field(ge=1, le=65535) +class DomainBackend(BaseModel): + host: str + port: int = Field(ge=1, le=65535) + + class DomainEntry(BaseModel): domain: str enabled: bool = True note: str = "" + backend: DomainBackend | None = None # 없으면 top-level backend 로 fallback class FullConfig(BaseModel): diff --git a/api/routes/domains.py b/api/routes/domains.py index 01cf8c5..5bbb9d1 100644 --- a/api/routes/domains.py +++ b/api/routes/domains.py @@ -2,22 +2,32 @@ from __future__ import annotations from fastapi import APIRouter, HTTPException, Response -from pydantic import BaseModel +from pydantic import BaseModel, Field from config_io import load_config, save_config router = APIRouter() +class DomainBackend(BaseModel): + host: str + port: int = Field(ge=1, le=65535) + + class DomainCreate(BaseModel): domain: str enabled: bool = True note: str = "" + backend: DomainBackend | None = None # 비우면 기본 backend 사용 class DomainPatch(BaseModel): enabled: bool | None = None note: str | None = None + # 명시적으로 None 을 보내려면 클라이언트가 "backend": null 을 보내야 한다. + # 빠뜨리면 기존 값 유지. + backend: DomainBackend | None = None + clear_backend: bool = False # True 면 기존 backend 삭제 → 기본값으로 복귀 @router.get("/domains") @@ -34,7 +44,9 @@ def add_domain(body: DomainCreate) -> dict: raise HTTPException(status_code=400, detail="domain required") if any(d["domain"].lower() == name for d in domains): raise HTTPException(status_code=409, detail="domain already exists") - entry = {"domain": name, "enabled": body.enabled, "note": body.note} + entry: dict = {"domain": name, "enabled": body.enabled, "note": body.note} + if body.backend is not None: + entry["backend"] = body.backend.model_dump() domains.append(entry) save_config(cfg) return entry @@ -64,6 +76,10 @@ def patch_domain(domain: str, body: DomainPatch) -> dict: d["enabled"] = body.enabled if body.note is not None: d["note"] = body.note + if body.clear_backend: + d.pop("backend", None) + elif body.backend is not None: + d["backend"] = body.backend.model_dump() save_config(cfg) return d raise HTTPException(status_code=404, detail="domain not found") diff --git a/frontend/src/pages/Domains.jsx b/frontend/src/pages/Domains.jsx index f537358..ffd02bd 100644 --- a/frontend/src/pages/Domains.jsx +++ b/frontend/src/pages/Domains.jsx @@ -1,32 +1,42 @@ import { useEffect, useState } from 'react' import { api } from '../api.js' +const blankBackend = { host: '', port: '' } + export default function Domains() { const [domains, setDomains] = useState([]) + const [defaultBackend, setDefaultBackend] = useState(null) const [newDomain, setNewDomain] = useState('') const [newNote, setNewNote] = useState('') + const [newBackend, setNewBackend] = useState({ ...blankBackend }) const [error, setError] = useState(null) + const [editing, setEditing] = useState(null) // {domain, host, port} async function load() { try { - setDomains(await api.domains()) + const [doms, cfg] = await Promise.all([api.domains(), api.config()]) + setDomains(doms) + setDefaultBackend(cfg.backend) setError(null) } catch (e) { setError(e.message) } } - useEffect(() => { - load() - }, []) + useEffect(() => { load() }, []) async function add(e) { e.preventDefault() if (!newDomain.trim()) return try { - await api.addDomain({ domain: newDomain.trim(), enabled: true, note: newNote }) + const body = { domain: newDomain.trim(), enabled: true, note: newNote } + if (newBackend.host.trim() && newBackend.port) { + body.backend = { host: newBackend.host.trim(), port: +newBackend.port } + } + await api.addDomain(body) setNewDomain('') setNewNote('') + setNewBackend({ ...blankBackend }) setError(null) await load() } catch (e) { @@ -38,9 +48,7 @@ export default function Domains() { try { await api.patchDomain(d.domain, { enabled: !d.enabled }) await load() - } catch (e) { - setError(e.message) - } + } catch (e) { setError(e.message) } } async function remove(d) { @@ -48,66 +56,163 @@ export default function Domains() { try { await api.deleteDomain(d.domain) await load() - } catch (e) { - setError(e.message) - } + } catch (e) { setError(e.message) } + } + + function startEdit(d) { + setEditing({ + domain: d.domain, + host: d.backend?.host ?? '', + port: d.backend?.port ?? '', + }) + } + + async function saveEdit() { + try { + const { domain, host, port } = editing + if (!host.trim() || !port) { + // 둘 다 비우면 backend 삭제 → 기본값으로 폴백 + await api.patchDomain(domain, { clear_backend: true }) + } else { + await api.patchDomain(domain, { + backend: { host: host.trim(), port: +port }, + }) + } + setEditing(null) + await load() + } catch (e) { setError(e.message) } + } + + async function clearBackend(d) { + if (!confirm(`${d.domain} 의 개별 backend 설정을 지우고 기본값으로 되돌릴까요?`)) return + try { + await api.patchDomain(d.domain, { clear_backend: true }) + await load() + } catch (e) { setError(e.message) } } return (

허용 도메인

{error &&
{error}
} -
- setNewDomain(e.target.value)} - /> - setNewNote(e.target.value)} - /> - + + +

새 도메인

+
+ + + + +
+
+ + 백엔드 호스트/포트를 비워두면 설정 페이지의 기본 backend 로 라우팅됩니다. +
+ + - + {domains.length === 0 && ( - )} - {domains.map((d) => ( - - - - - - - ))} + {domains.map((d) => { + const isEditing = editing?.domain === d.domain + return ( + + + + + + + + ) + })}
도메인 메모백엔드 활성
+ 등록된 도메인이 없습니다.
- {d.domain} - {d.note} - - - -
{d.domain}{d.note} + {isEditing ? ( +
+ setEditing({ ...editing, host: e.target.value })} + /> + setEditing({ ...editing, port: e.target.value })} + style={{ width: 90 }} + /> +
+ ) : d.backend ? ( + {d.backend.host}:{d.backend.port} + ) : ( + + 기본값 ({defaultBackend ? `${defaultBackend.host}:${defaultBackend.port}` : '-'}) + + )} +
+ + + {isEditing ? ( + <> + + + + ) : ( + <> + + {d.backend && ( + + )} + + + )} +
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index ca52695..780d273 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -69,7 +69,11 @@ export default function Settings() {
-

백엔드 (실제 MC 서버)

+

기본 백엔드 (도메인별 backend 가 없을 때 fallback)

+

+ 허용 도메인 페이지에서 각 도메인에 별도 backend (host:port) 를 지정할 수 있습니다. + 지정하지 않은 도메인은 여기 값으로 라우팅됩니다. +