Compare commits

..

6 Commits

Author SHA1 Message Date
7362b45846 feat(logs): date filter + clear log endpoints
- API GET /api/logs now accepts from_ts / to_ts (unix epoch, half-open
  [from, to)) so callers can scope by arbitrary time range.
- API DELETE /api/logs added. Same from_ts / to_ts semantics. No params
  = wipe everything and reset the AUTOINCREMENT counter.
- Dashboard Logs page: date picker that scopes both the view and the
  delete button to the selected day in the user's local timezone. The
  clear button is red and confirms before deleting; label switches
  between "전체 로그 초기화" and "<날짜> 하루치 삭제".
2026-05-23 17:55:58 +09:00
8312cfe861 fix(proxy): flush disconnect packet cleanly + label handshake errors
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.
2026-05-23 17:46:12 +09:00
d9a1ee1a69 fix(nginx): bake nginx.conf into the image instead of bind-mounting
User reported persistent 502 with upstream "frontend:3000" after the
previous fix that changed the upstream to "frontend:80". Symptom is
a stale conf still being served by the nginx container - the host
volume mount was keeping an old file in play (cached image, missing
git pull, or the conf simply not being re-read).

Make this class of bug impossible: ship the conf inside the nginx
service's image. A fresh build now guarantees the conf in the
container matches the conf in the repo.

- nginx/Dockerfile added (FROM nginx:alpine + COPY nginx.conf)
- docker-compose nginx service: image -> build ./nginx; remove
  the conf bind mount entirely.

Deploy:  git pull && docker compose build nginx frontend && docker compose up -d --force-recreate
2026-05-23 17:38:10 +09:00
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
15 changed files with 403 additions and 77 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
@@ -82,7 +88,8 @@ mc-filter-proxy 컨테이너 (25565)
| POST | `/api/domains` | 도메인 추가 | | POST | `/api/domains` | 도메인 추가 |
| PATCH | `/api/domains/{domain}` | 활성/메모 변경 | | PATCH | `/api/domains/{domain}` | 활성/메모 변경 |
| DELETE | `/api/domains/{domain}` | 도메인 삭제 | | DELETE | `/api/domains/{domain}` | 도메인 삭제 |
| GET | `/api/logs?limit&offset&action` | 접속 로그 (페이지네이션) | | GET | `/api/logs?limit&offset&action&from_ts&to_ts` | 접속 로그 (페이지네이션, 날짜 필터) |
| DELETE | `/api/logs?from_ts&to_ts` | 접속 로그 삭제 (범위 미지정 시 전체) |
| GET | `/api/status` | 프록시 상태 + 통계 | | GET | `/api/status` | 프록시 상태 + 통계 |
| POST | `/api/proxy/restart` | config 파일 touch (프록시 재로드 트리거) | | POST | `/api/proxy/restart` | config 파일 touch (프록시 재로드 트리거) |

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

@@ -1,4 +1,4 @@
"""접속 로그 조회.""" """접속 로그 조회 및 초기화."""
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
@@ -15,16 +15,26 @@ def list_logs(
limit: int = Query(50, ge=1, le=500), limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
action: str | None = Query(None), action: str | None = Query(None),
from_ts: float | None = Query(None, description="unix epoch seconds, inclusive"),
to_ts: float | None = Query(None, description="unix epoch seconds, exclusive"),
) -> dict: ) -> dict:
if not LOG_DB.exists(): if not LOG_DB.exists():
return {"total": 0, "items": []} return {"total": 0, "items": []}
con = sqlite3.connect(LOG_DB) con = sqlite3.connect(LOG_DB)
try: try:
con.row_factory = sqlite3.Row con.row_factory = sqlite3.Row
where, params = "", [] conds: list[str] = []
params: list = []
if action: if action:
where = "WHERE action = ?" conds.append("action = ?")
params.append(action) params.append(action)
if from_ts is not None:
conds.append("ts >= ?")
params.append(from_ts)
if to_ts is not None:
conds.append("ts < ?")
params.append(to_ts)
where = ("WHERE " + " AND ".join(conds)) if conds else ""
total = con.execute( total = con.execute(
f"SELECT COUNT(*) FROM connections {where}", params f"SELECT COUNT(*) FROM connections {where}", params
).fetchone()[0] ).fetchone()[0]
@@ -36,3 +46,40 @@ def list_logs(
finally: finally:
con.close() con.close()
return {"total": total, "items": [dict(r) for r in rows]} return {"total": total, "items": [dict(r) for r in rows]}
@router.delete("/logs")
def clear_logs(
from_ts: float | None = Query(None, description="ts >= from_ts 만 삭제"),
to_ts: float | None = Query(None, description="ts < to_ts 만 삭제"),
) -> dict:
"""접속 로그를 삭제한다.
- from_ts/to_ts 둘 다 없으면 전체 삭제 (AUTOINCREMENT 카운터까지 리셋)
- 둘 중 하나만 있으면 그 조건만 적용
- 둘 다 있으면 [from_ts, to_ts) 범위 삭제 — 날짜 선택 삭제 용도
"""
if not LOG_DB.exists():
return {"deleted": 0}
con = sqlite3.connect(LOG_DB, timeout=5)
try:
conds: list[str] = []
params: list = []
if from_ts is not None:
conds.append("ts >= ?")
params.append(from_ts)
if to_ts is not None:
conds.append("ts < ?")
params.append(to_ts)
if conds:
where = "WHERE " + " AND ".join(conds)
cur = con.execute(f"DELETE FROM connections {where}", params)
deleted = cur.rowcount
else:
cur = con.execute("DELETE FROM connections")
deleted = cur.rowcount
con.execute("DELETE FROM sqlite_sequence WHERE name='connections'")
con.commit()
finally:
con.close()
return {"deleted": deleted}

View File

@@ -25,18 +25,16 @@ 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
nginx: nginx:
image: nginx:alpine build: ./nginx
container_name: mc-filter-nginx container_name: mc-filter-nginx
ports: ports:
- "8080:80" # 대시보드 접근 포트 (외부 포트포워딩 금지 권장) - "8080:80" # 대시보드 접근 포트 (외부 포트포워딩 금지 권장)
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on: depends_on:
- api - api
- frontend - frontend

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

@@ -32,5 +32,11 @@ export const api = {
).toString() ).toString()
return req('/logs' + (q ? `?${q}` : '')) return req('/logs' + (q ? `?${q}` : ''))
}, },
clearLogs: (params = {}) => {
const q = new URLSearchParams(
Object.fromEntries(Object.entries(params).filter(([, v]) => v !== '' && v != null))
).toString()
return req('/logs' + (q ? `?${q}` : ''), { method: 'DELETE' })
},
restart: () => req('/proxy/restart', { method: 'POST' }), restart: () => req('/proxy/restart', { method: 'POST' }),
} }

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

@@ -5,16 +5,33 @@ function fmtTime(ts) {
return new Date(ts * 1000).toLocaleString('ko-KR') return new Date(ts * 1000).toLocaleString('ko-KR')
} }
// 'YYYY-MM-DD' 문자열을 로컬 자정 unix epoch (초) 로 바꾼다.
function dayStart(dateStr) {
if (!dateStr) return null
const [y, m, d] = dateStr.split('-').map((s) => parseInt(s, 10))
return new Date(y, m - 1, d, 0, 0, 0, 0).getTime() / 1000
}
function dayEnd(dateStr) {
const start = dayStart(dateStr)
return start == null ? null : start + 86400
}
export default function Logs() { export default function Logs() {
const [data, setData] = useState({ total: 0, items: [] }) const [data, setData] = useState({ total: 0, items: [] })
const [filter, setFilter] = useState('') const [filter, setFilter] = useState('')
const [date, setDate] = useState('')
const [auto, setAuto] = useState(true) const [auto, setAuto] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [msg, setMsg] = useState(null)
async function load() { async function load() {
try { try {
const params = { limit: 100 } const params = { limit: 100 }
if (filter) params.action = filter if (filter) params.action = filter
if (date) {
params.from_ts = dayStart(date)
params.to_ts = dayEnd(date)
}
setData(await api.logs(params)) setData(await api.logs(params))
setError(null) setError(null)
} catch (e) { } catch (e) {
@@ -27,7 +44,30 @@ export default function Logs() {
if (!auto) return if (!auto) return
const id = setInterval(load, 3000) const id = setInterval(load, 3000)
return () => clearInterval(id) return () => clearInterval(id)
}, [filter, auto]) }, [filter, date, auto])
async function clearLogs() {
const scope = date ? `${date} 하루치` : '전체'
if (!window.confirm(`${scope} 접속 로그를 정말 삭제할까요? (되돌릴 수 없음)`)) {
return
}
try {
const params = {}
if (date) {
params.from_ts = dayStart(date)
params.to_ts = dayEnd(date)
}
const res = await api.clearLogs(params)
setMsg(`${res.deleted.toLocaleString()}건 삭제됨`)
setError(null)
setTimeout(() => setMsg(null), 2500)
load()
} catch (e) {
setError(e.message)
}
}
const clearLabel = date ? `${date} 하루치 삭제` : '전체 로그 초기화'
return ( return (
<div> <div>
@@ -40,6 +80,19 @@ export default function Logs() {
<option value="blocked">차단</option> <option value="blocked">차단</option>
<option value="error">에러</option> <option value="error">에러</option>
</select> </select>
<label className="inline">
날짜
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</label>
{date && (
<button className="ghost" onClick={() => setDate('')}>
오늘 해제
</button>
)}
<label className="inline"> <label className="inline">
<input <input
type="checkbox" type="checkbox"
@@ -49,6 +102,10 @@ export default function Logs() {
자동 갱신 (3) 자동 갱신 (3)
</label> </label>
<span className="muted"> {data.total.toLocaleString()}</span> <span className="muted"> {data.total.toLocaleString()}</span>
<button className="danger" onClick={clearLogs}>
{clearLabel}
</button>
{msg && <span className="ok">{msg}</span>}
</div> </div>
<table className="table"> <table className="table">
<thead> <thead>

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%;

6
nginx/Dockerfile Normal file
View File

@@ -0,0 +1,6 @@
# nginx.conf 를 image 에 굽는다.
# (volume mount 로 운영하다 보면 호스트 측 conf 가 stale 일 때 영문도 모르고
# 502 가 나는 경우가 있어 image 안에 직접 포함시킨다.)
FROM nginx:alpine
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80

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))
@@ -206,14 +228,21 @@ async def handle_client(
) )
hs = parse_handshake(hs_bytes) hs = parse_handshake(hs_bytes)
except (HandshakeError, asyncio.TimeoutError, asyncio.IncompleteReadError, OSError) as exc: except (HandshakeError, asyncio.TimeoutError, asyncio.IncompleteReadError, OSError) as exc:
log_event(client_ip, None, None, "blocked", f"handshake error: {exc}") # str(exc) 가 빈 문자열인 예외들(OSError(), ConnectionResetError())
log.info("BLOCK %s reason=handshake_error (%s)", client_ip, exc) # 도 있어서 class 이름을 함께 남긴다 — 빈 reason 로 보이는 문제 회피.
client_writer.close() reason = f"handshake error: {type(exc).__name__}: {exc}".rstrip(": ")
log_event(client_ip, None, None, "blocked", reason)
log.info("BLOCK %s reason=%s", client_ip, reason)
try:
client_writer.close()
await client_writer.wait_closed()
except Exception: # noqa: BLE001
pass
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
@@ -225,12 +254,23 @@ async def handle_client(
msg = state.cfg.get("block_message") or DEFAULT_BLOCK_MESSAGE msg = state.cfg.get("block_message") or DEFAULT_BLOCK_MESSAGE
client_writer.write(build_login_disconnect(msg)) client_writer.write(build_login_disconnect(msg))
await client_writer.drain() await client_writer.drain()
# FIN 으로 마무리해서 클라이언트가 disconnect 패킷을 다 읽기 전에
# RST 가 가는 (그러면 "Connection reset" 으로 보인다) 일을 막는다.
try:
if client_writer.can_write_eof():
client_writer.write_eof()
except (OSError, NotImplementedError):
pass
except (OSError, ConnectionResetError, BrokenPipeError): except (OSError, ConnectionResetError, BrokenPipeError):
pass pass
client_writer.close() try:
client_writer.close()
await client_writer.wait_closed()
except Exception: # noqa: BLE001
pass
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