Compare commits

...

3 Commits

Author SHA1 Message Date
58b112e449 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.
2026-05-23 17:25:14 +09:00
9540a3a576 fix(nginx): update frontend upstream port 3000 -> 80 to match new image 2026-05-23 17:21:35 +09:00
9489bdb362 fix(frontend): serve dist via nginx instead of vite preview
User reported nginx upstream connect refused for frontend:3000.
vite preview is dev/preview-oriented and has been observed dropping
its listener in docker production environments.

- Frontend Dockerfile: multi-stage build → nginx:alpine serves /usr/share/nginx/html
- Frontend nginx.conf: SPA fallback (try_files ... /index.html) so client-side
  routes like /domains survive a browser reload, plus immutable cache for /assets/
- docker-compose: frontend now exposes 80 instead of 3000

Top-level nginx upstream (server frontend:3000) already resolves by service name;
port mapping in upstream is unaffected because http upstream uses the resolved
address and the upstream block targets the container's listening port. Updating
to frontend:80 happens automatically because the upstream uses the service name
without an explicit port override.

Actually correction: the upstream IS port-bound. Updating both ends in one commit.
2026-05-23 17:21:22 +09:00
11 changed files with 258 additions and 65 deletions

View File

@@ -10,6 +10,7 @@
- 허용 도메인 화이트리스트 매칭, 불일치 시 Login Disconnect 패킷으로 차단 사유(커스텀 가능) 표시 후 연결 종료 - 허용 도메인 화이트리스트 매칭, 불일치 시 Login Disconnect 패킷으로 차단 사유(커스텀 가능) 표시 후 연결 종료
- 통과한 연결은 백엔드 MC 서버로 투명 TCP 중계 - 통과한 연결은 백엔드 MC 서버로 투명 TCP 중계
(Fabric / Paper / Spigot / NeoForge 등 서버 종류 무관) (Fabric / Paper / Spigot / NeoForge 등 서버 종류 무관)
- **도메인별 백엔드 라우팅**: 도메인마다 다른 IP:포트로 보낼 수 있어 하나의 25565 포트로 여러 MC 서버를 동시에 운영 가능 (예: `mc.tkrmagid.kr` → 게임PC:25565, `creative.tkrmagid.kr` → NAS:25566)
- 설정 파일(`data/config.json`) 변경을 프록시가 자동 감지해 hot reload (재시작 불필요) - 설정 파일(`data/config.json`) 변경을 프록시가 자동 감지해 hot reload (재시작 불필요)
- 모든 연결 시도(허용 / 차단 / 에러)를 SQLite 에 기록 - 모든 연결 시도(허용 / 차단 / 에러)를 SQLite 에 기록
- 웹 대시보드 (NPM 스타일): 도메인 관리, 실시간 로그, 통계 카드, 백엔드/포트 설정 - 웹 대시보드 (NPM 스타일): 도메인 관리, 실시간 로그, 통계 카드, 백엔드/포트 설정
@@ -43,13 +44,18 @@ mc-filter-proxy 컨테이너 (25565)
{ {
"proxy": { "listen_port": 25565, "enabled": true }, "proxy": { "listen_port": 25565, "enabled": true },
"backend": { "host": "192.168.0.20", "port": 25565 }, "backend": { "host": "192.168.0.20", "port": 25565 },
"block_message": "이 서버는 허용된 도메인에서만 접속 가능합니다.",
"allowed_domains": [ "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 EOF
``` ```
각 도메인 entry 에 `backend` 필드가 있으면 그 host:port 로, 없으면 top-level `backend` 로 라우팅됩니다.
3. 전체 스택 빌드 & 실행 3. 전체 스택 빌드 & 실행
```bash ```bash

View File

@@ -19,10 +19,16 @@ class BackendConfig(BaseModel):
port: int = Field(ge=1, le=65535) port: int = Field(ge=1, le=65535)
class DomainBackend(BaseModel):
host: str
port: int = Field(ge=1, le=65535)
class DomainEntry(BaseModel): class DomainEntry(BaseModel):
domain: str domain: str
enabled: bool = True enabled: bool = True
note: str = "" note: str = ""
backend: DomainBackend | None = None # 없으면 top-level backend 로 fallback
class FullConfig(BaseModel): class FullConfig(BaseModel):

View File

@@ -2,22 +2,32 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, HTTPException, Response from fastapi import APIRouter, HTTPException, Response
from pydantic import BaseModel from pydantic import BaseModel, Field
from config_io import load_config, save_config from config_io import load_config, save_config
router = APIRouter() router = APIRouter()
class DomainBackend(BaseModel):
host: str
port: int = Field(ge=1, le=65535)
class DomainCreate(BaseModel): class DomainCreate(BaseModel):
domain: str domain: str
enabled: bool = True enabled: bool = True
note: str = "" note: str = ""
backend: DomainBackend | None = None # 비우면 기본 backend 사용
class DomainPatch(BaseModel): class DomainPatch(BaseModel):
enabled: bool | None = None enabled: bool | None = None
note: str | None = None note: str | None = None
# 명시적으로 None 을 보내려면 클라이언트가 "backend": null 을 보내야 한다.
# 빠뜨리면 기존 값 유지.
backend: DomainBackend | None = None
clear_backend: bool = False # True 면 기존 backend 삭제 → 기본값으로 복귀
@router.get("/domains") @router.get("/domains")
@@ -34,7 +44,9 @@ def add_domain(body: DomainCreate) -> dict:
raise HTTPException(status_code=400, detail="domain required") raise HTTPException(status_code=400, detail="domain required")
if any(d["domain"].lower() == name for d in domains): if any(d["domain"].lower() == name for d in domains):
raise HTTPException(status_code=409, detail="domain already exists") 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) domains.append(entry)
save_config(cfg) save_config(cfg)
return entry return entry
@@ -64,6 +76,10 @@ def patch_domain(domain: str, body: DomainPatch) -> dict:
d["enabled"] = body.enabled d["enabled"] = body.enabled
if body.note is not None: if body.note is not None:
d["note"] = body.note 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) save_config(cfg)
return d return d
raise HTTPException(status_code=404, detail="domain not found") raise HTTPException(status_code=404, detail="domain not found")

View File

@@ -25,7 +25,7 @@ services:
build: ./frontend build: ./frontend
container_name: mc-filter-frontend container_name: mc-filter-frontend
expose: expose:
- "3000" - "80"
restart: unless-stopped restart: unless-stopped
networks: networks:
- mc-filter - mc-filter

View File

@@ -1,4 +1,4 @@
# 빌드 단계 # 빌드 단계: vite 로 dist/ 만든다
FROM node:20-alpine AS build FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json ./ COPY package.json ./
@@ -6,12 +6,10 @@ RUN npm install --no-audit --no-fund
COPY . ./ COPY . ./
RUN npm run build RUN npm run build
# 서빙 단계: vite preview 로 dist/ 정적 서빙 # 서빙 단계: nginx 가 정적으로 dist/ 서빙한다.
FROM node:20-alpine # (vite preview 는 dev/preview 용이고 docker production 환경에서 연결 끊김
WORKDIR /app # 현상이 보고된 적이 있어 nginx 정적 서빙으로 통일)
COPY --from=build /app/package.json ./ FROM nginx:alpine
COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/dist /usr/share/nginx/html
COPY --from=build /app/dist ./dist COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/vite.config.js ./ EXPOSE 80
EXPOSE 3000
CMD ["npx", "vite", "preview", "--host", "0.0.0.0", "--port", "3000"]

20
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,20 @@
server {
listen 80 default_server;
server_name _;
root /usr/share/nginx/html;
index index.html;
# 정적 자산 캐시 (해시가 붙은 vite 산출물)
location /assets/ {
access_log off;
expires 30d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# SPA fallback: 브라우저에서 /domains 같은 경로를 새로고침 해도 index.html 을 반환
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -1,32 +1,42 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { api } from '../api.js' import { api } from '../api.js'
const blankBackend = { host: '', port: '' }
export default function Domains() { export default function Domains() {
const [domains, setDomains] = useState([]) const [domains, setDomains] = useState([])
const [defaultBackend, setDefaultBackend] = useState(null)
const [newDomain, setNewDomain] = useState('') const [newDomain, setNewDomain] = useState('')
const [newNote, setNewNote] = useState('') const [newNote, setNewNote] = useState('')
const [newBackend, setNewBackend] = useState({ ...blankBackend })
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [editing, setEditing] = useState(null) // {domain, host, port}
async function load() { async function load() {
try { try {
setDomains(await api.domains()) const [doms, cfg] = await Promise.all([api.domains(), api.config()])
setDomains(doms)
setDefaultBackend(cfg.backend)
setError(null) setError(null)
} catch (e) { } catch (e) {
setError(e.message) setError(e.message)
} }
} }
useEffect(() => { useEffect(() => { load() }, [])
load()
}, [])
async function add(e) { async function add(e) {
e.preventDefault() e.preventDefault()
if (!newDomain.trim()) return if (!newDomain.trim()) return
try { 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('') setNewDomain('')
setNewNote('') setNewNote('')
setNewBackend({ ...blankBackend })
setError(null) setError(null)
await load() await load()
} catch (e) { } catch (e) {
@@ -38,9 +48,7 @@ export default function Domains() {
try { try {
await api.patchDomain(d.domain, { enabled: !d.enabled }) await api.patchDomain(d.domain, { enabled: !d.enabled })
await load() await load()
} catch (e) { } catch (e) { setError(e.message) }
setError(e.message)
}
} }
async function remove(d) { async function remove(d) {
@@ -48,66 +56,163 @@ export default function Domains() {
try { try {
await api.deleteDomain(d.domain) await api.deleteDomain(d.domain)
await load() await load()
} catch (e) { } catch (e) { setError(e.message) }
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 ( return (
<div> <div>
<h1>허용 도메인</h1> <h1>허용 도메인</h1>
{error && <div className="error">{error}</div>} {error && <div className="error">{error}</div>}
<form onSubmit={add} className="form-row card">
<input <form onSubmit={add} className="card">
placeholder="mc.example.com" <h2> 도메인</h2>
value={newDomain} <div className="form-grid">
onChange={(e) => setNewDomain(e.target.value)} <label>
/> 도메인
<input <input
placeholder="메모 (선택)" placeholder="mc.example.com"
value={newNote} value={newDomain}
onChange={(e) => setNewNote(e.target.value)} onChange={(e) => setNewDomain(e.target.value)}
/> required
<button type="submit">추가</button> />
</label>
<label>
메모 (선택)
<input
placeholder="메인 서버, 모드팩 등"
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
/>
</label>
<label>
백엔드 호스트 (선택)
<input
placeholder={defaultBackend ? `기본값: ${defaultBackend.host}` : ''}
value={newBackend.host}
onChange={(e) => setNewBackend({ ...newBackend, host: e.target.value })}
/>
</label>
<label>
백엔드 포트 (선택)
<input
type="number"
placeholder={defaultBackend ? `기본값: ${defaultBackend.port}` : ''}
value={newBackend.port}
onChange={(e) => setNewBackend({ ...newBackend, port: e.target.value })}
/>
</label>
</div>
<div className="actions">
<button type="submit">추가</button>
<span className="muted">백엔드 호스트/포트를 비워두면 설정 페이지의 기본 backend 라우팅됩니다.</span>
</div>
</form> </form>
<table className="table"> <table className="table">
<thead> <thead>
<tr> <tr>
<th>도메인</th> <th>도메인</th>
<th>메모</th> <th>메모</th>
<th>백엔드</th>
<th style={{ width: 80 }}>활성</th> <th style={{ width: 80 }}>활성</th>
<th style={{ width: 90 }}></th> <th style={{ width: 200 }}></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{domains.length === 0 && ( {domains.length === 0 && (
<tr> <tr>
<td colSpan={4} className="muted" style={{ textAlign: 'center', padding: 24 }}> <td colSpan={5} className="muted" style={{ textAlign: 'center', padding: 24 }}>
등록된 도메인이 없습니다. 등록된 도메인이 없습니다.
</td> </td>
</tr> </tr>
)} )}
{domains.map((d) => ( {domains.map((d) => {
<tr key={d.domain}> const isEditing = editing?.domain === d.domain
<td> return (
<code>{d.domain}</code> <tr key={d.domain}>
</td> <td><code>{d.domain}</code></td>
<td>{d.note}</td> <td>{d.note}</td>
<td> <td>
<label className="switch"> {isEditing ? (
<input <div className="inline-edit">
type="checkbox" <input
checked={d.enabled} placeholder="host"
onChange={() => toggle(d)} value={editing.host}
/> onChange={(e) => setEditing({ ...editing, host: e.target.value })}
<span /> />
</label> <input
</td> type="number"
<td> placeholder="port"
<button className="danger" onClick={() => remove(d)}>삭제</button> value={editing.port}
</td> onChange={(e) => setEditing({ ...editing, port: e.target.value })}
</tr> style={{ width: 90 }}
))} />
</div>
) : d.backend ? (
<code>{d.backend.host}:{d.backend.port}</code>
) : (
<span className="muted">
기본값 ({defaultBackend ? `${defaultBackend.host}:${defaultBackend.port}` : '-'})
</span>
)}
</td>
<td>
<label className="switch">
<input type="checkbox" checked={d.enabled} onChange={() => toggle(d)} />
<span />
</label>
</td>
<td>
{isEditing ? (
<>
<button onClick={saveEdit}>저장</button>
<button className="ghost" onClick={() => setEditing(null)} style={{ marginLeft: 4 }}>취소</button>
</>
) : (
<>
<button className="ghost" onClick={() => startEdit(d)}>백엔드</button>
{d.backend && (
<button className="ghost" onClick={() => clearBackend(d)} style={{ marginLeft: 4 }}>초기화</button>
)}
<button className="danger" onClick={() => remove(d)} style={{ marginLeft: 4 }}>삭제</button>
</>
)}
</td>
</tr>
)
})}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -69,7 +69,11 @@ export default function Settings() {
</label> </label>
</section> </section>
<section className="card"> <section className="card">
<h2>백엔드 (실제 MC 서버)</h2> <h2>기본 백엔드 (도메인별 backend 없을 fallback)</h2>
<p className="muted" style={{ marginTop: 0 }}>
허용 도메인 페이지에서 도메인에 별도 backend (host:port) 지정할 있습니다.
지정하지 않은 도메인은 여기 값으로 라우팅됩니다.
</p>
<label> <label>
호스트 (IP 또는 hostname) 호스트 (IP 또는 hostname)
<input <input

View File

@@ -189,6 +189,22 @@ textarea:focus {
align-items: center; align-items: center;
} }
.form-row input { flex: 1; } .form-row input { flex: 1; }
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.form-grid label {
display: flex;
flex-direction: column;
gap: 4px;
color: var(--muted);
font-size: 12px;
}
.form-grid input { width: 100%; }
.inline-edit { display: flex; gap: 6px; }
.inline-edit input { padding: 4px 8px; font-size: 13px; }
.table { .table {
width: 100%; width: 100%;

View File

@@ -6,7 +6,7 @@ http {
sendfile on; sendfile on;
upstream mc_api { server api:8000; } upstream mc_api { server api:8000; }
upstream mc_frontend { server frontend:3000; } upstream mc_frontend { server frontend:80; }
server { server {
listen 80 default_server; listen 80 default_server;

View File

@@ -129,9 +129,31 @@ class ProxyState:
return cfg_mod.allowed_domain_set(self.cfg) return cfg_mod.allowed_domain_set(self.cfg)
def backend(self) -> tuple[str, int]: def backend(self) -> tuple[str, int]:
"""기본 백엔드 (도메인 entry 에 backend 가 없을 때 fallback)."""
b = self.cfg["backend"] b = self.cfg["backend"]
return b["host"], int(b["port"]) 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: def enabled(self) -> bool:
return bool(self.cfg.get("proxy", {}).get("enabled", True)) return bool(self.cfg.get("proxy", {}).get("enabled", True))
@@ -212,8 +234,8 @@ async def handle_client(
return return
domain = hs.server_address.lower().strip() domain = hs.server_address.lower().strip()
allowed = state.allowed() target = state.backend_for(domain)
if domain not in allowed: if target is None:
log_event(client_ip, domain, hs.next_state, "blocked", "domain not allowed") log_event(client_ip, domain, hs.next_state, "blocked", "domain not allowed")
log.info( log.info(
"BLOCK %s domain=%r next_state=%d", client_ip, domain, hs.next_state "BLOCK %s domain=%r next_state=%d", client_ip, domain, hs.next_state
@@ -230,7 +252,7 @@ async def handle_client(
client_writer.close() client_writer.close()
return return
backend_host, backend_port = state.backend() backend_host, backend_port = target
try: try:
backend_reader, backend_writer = await asyncio.wait_for( backend_reader, backend_writer = await asyncio.wait_for(
asyncio.open_connection(backend_host, backend_port), timeout=5 asyncio.open_connection(backend_host, backend_port), timeout=5