fix: real listener restart + remove listen_port editing from UI
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
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ data/config.json
|
|||||||
data/logs.db
|
data/logs.db
|
||||||
data/logs.db-wal
|
data/logs.db-wal
|
||||||
data/logs.db-shm
|
data/logs.db-shm
|
||||||
|
data/restart.signal
|
||||||
|
|
||||||
# 노드/파이썬 빌드 산출물
|
# 노드/파이썬 빌드 산출물
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -90,7 +90,10 @@ mc-filter-proxy 컨테이너 (25565)
|
|||||||
|
|
||||||
- API 가 `data/config.json` 을 atomic rename (`tempfile + os.replace`) 으로 갱신
|
- API 가 `data/config.json` 을 atomic rename (`tempfile + os.replace`) 으로 갱신
|
||||||
- 프록시가 2초 간격으로 mtime 폴링, 변경 감지 시 메모리 캐시 재로드
|
- 프록시가 2초 간격으로 mtime 폴링, 변경 감지 시 메모리 캐시 재로드
|
||||||
- `listen_port` 나 `proxy.enabled` 가 바뀌면 리스너 자체를 재시작, 그 외(도메인/백엔드)는 다음 연결부터 즉시 적용
|
- `proxy.enabled` 가 바뀌면 리스너를 재시작 (켜기/끄기), 그 외(도메인/백엔드)는 다음 연결부터 즉시 적용
|
||||||
|
- `POST /api/proxy/restart` 는 별도 신호 파일(`data/restart.signal`)을 touch 해서 프록시가 listener 를 강제로 stop → start 한다 (config 변경이 없어도 동작)
|
||||||
|
|
||||||
|
> **리스닝 포트 변경:** `proxy.listen_port` 는 docker-compose 의 `ports` 매핑과 짝이라 대시보드에서 편집할 수 없습니다. 바꾸려면 `data/config.json` 과 `docker-compose.yml` 의 `25565:25565` 매핑을 함께 수정한 뒤 `docker compose up -d --force-recreate proxy` 하세요.
|
||||||
|
|
||||||
## 보안 권장
|
## 보안 권장
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/data/config.json"))
|
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/data/config.json"))
|
||||||
LOG_DB = Path(os.environ.get("MC_LOG_DB", "/data/logs.db"))
|
LOG_DB = Path(os.environ.get("MC_LOG_DB", "/data/logs.db"))
|
||||||
|
RESTART_SIGNAL = Path(os.environ.get("MC_RESTART_SIGNAL", "/data/restart.signal"))
|
||||||
|
|
||||||
DEFAULT_CONFIG = {
|
DEFAULT_CONFIG = {
|
||||||
"proxy": {"listen_port": 25565, "enabled": True},
|
"proxy": {"listen_port": 25565, "enabled": True},
|
||||||
@@ -41,3 +43,17 @@ def save_config(cfg: dict) -> None:
|
|||||||
with tmp.open("w", encoding="utf-8") as f:
|
with tmp.open("w", encoding="utf-8") as f:
|
||||||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||||||
os.replace(tmp, CONFIG_PATH)
|
os.replace(tmp, CONFIG_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def touch_restart_signal() -> float:
|
||||||
|
"""프록시에 강제 listener restart 를 요청하는 신호 파일 갱신.
|
||||||
|
|
||||||
|
config 가 바뀌지 않은 상태에서도 listener 를 stop→start 시키고 싶을 때 사용.
|
||||||
|
"""
|
||||||
|
RESTART_SIGNAL.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
ts = time.time()
|
||||||
|
with _lock:
|
||||||
|
# touch: 파일이 없으면 만들고, 있으면 mtime 만 갱신
|
||||||
|
RESTART_SIGNAL.touch(exist_ok=True)
|
||||||
|
os.utime(RESTART_SIGNAL, (ts, ts))
|
||||||
|
return ts
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import time
|
|||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from config_io import LOG_DB, load_config, save_config
|
from config_io import LOG_DB, load_config, touch_restart_signal
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -53,7 +53,10 @@ def status() -> dict:
|
|||||||
|
|
||||||
@router.post("/proxy/restart")
|
@router.post("/proxy/restart")
|
||||||
def restart_proxy() -> dict:
|
def restart_proxy() -> dict:
|
||||||
"""config 파일 mtime 을 갱신해서 프록시 watcher 가 재로드하게 한다."""
|
"""프록시 listener 를 강제로 stop → start 시킨다.
|
||||||
cfg = load_config()
|
|
||||||
save_config(cfg)
|
config 가 바뀌지 않았어도 listener 자체를 재시작하기 때문에 단순
|
||||||
return {"ok": True, "note": "config touched; proxy will reload"}
|
config reload 와는 다르다. (proxy watcher 가 별도 신호 파일을 폴링)
|
||||||
|
"""
|
||||||
|
ts = touch_restart_signal()
|
||||||
|
return {"ok": True, "restart_signal_ts": ts}
|
||||||
|
|||||||
@@ -31,16 +31,11 @@ export default function Settings() {
|
|||||||
<h2>프록시</h2>
|
<h2>프록시</h2>
|
||||||
<label>
|
<label>
|
||||||
리스닝 포트
|
리스닝 포트
|
||||||
<input
|
<input type="number" value={cfg.proxy.listen_port} disabled />
|
||||||
type="number"
|
<span className="muted">
|
||||||
value={cfg.proxy.listen_port}
|
컨테이너 외부와 매핑된 포트라 docker-compose 의 ports 매핑과 함께 바꿔야 합니다.
|
||||||
onChange={(e) =>
|
대시보드에서는 표시만 합니다.
|
||||||
setCfg({
|
</span>
|
||||||
...cfg,
|
|
||||||
proxy: { ...cfg.proxy, listen_port: +e.target.value },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="inline">
|
<label className="inline">
|
||||||
<input
|
<input
|
||||||
@@ -83,6 +78,21 @@ export default function Settings() {
|
|||||||
</section>
|
</section>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button onClick={save}>저장</button>
|
<button onClick={save}>저장</button>
|
||||||
|
<button
|
||||||
|
className="ghost"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await api.restart()
|
||||||
|
setMsg('프록시 listener 재시작 신호 전송')
|
||||||
|
setError(null)
|
||||||
|
setTimeout(() => setMsg(null), 2500)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
프록시 재시작
|
||||||
|
</button>
|
||||||
{msg && <span className="ok">{msg}</span>}
|
{msg && <span className="ok">{msg}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -149,7 +149,14 @@ button {
|
|||||||
}
|
}
|
||||||
button:hover { filter: brightness(1.1); }
|
button:hover { filter: brightness(1.1); }
|
||||||
button.danger { background: var(--danger); }
|
button.danger { background: var(--danger); }
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
button.ghost:hover { background: var(--panel-2); filter: none; }
|
||||||
button.mt { margin-top: 12px; }
|
button.mt { margin-top: 12px; }
|
||||||
|
input:disabled { opacity: 0.7; cursor: not-allowed; }
|
||||||
|
|
||||||
input,
|
input,
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import config as cfg_mod
|
|||||||
from handshake import HandshakeError, parse_handshake, read_handshake_bytes
|
from handshake import HandshakeError, parse_handshake, read_handshake_bytes
|
||||||
|
|
||||||
LOG_DB = Path(os.environ.get("MC_LOG_DB", "/data/logs.db"))
|
LOG_DB = Path(os.environ.get("MC_LOG_DB", "/data/logs.db"))
|
||||||
|
RESTART_SIGNAL = Path(os.environ.get("MC_RESTART_SIGNAL", "/data/restart.signal"))
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -76,10 +77,18 @@ def log_event(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Runtime state
|
# Runtime state
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
def _signal_mtime() -> float:
|
||||||
|
try:
|
||||||
|
return RESTART_SIGNAL.stat().st_mtime
|
||||||
|
except FileNotFoundError:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
class ProxyState:
|
class ProxyState:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.cfg = cfg_mod.load()
|
self.cfg = cfg_mod.load()
|
||||||
self.cfg_mtime = cfg_mod.mtime()
|
self.cfg_mtime = cfg_mod.mtime()
|
||||||
|
self.signal_mtime = _signal_mtime()
|
||||||
self.listen_port: int = int(self.cfg["proxy"]["listen_port"])
|
self.listen_port: int = int(self.cfg["proxy"]["listen_port"])
|
||||||
|
|
||||||
def allowed(self) -> set[str]:
|
def allowed(self) -> set[str]:
|
||||||
@@ -110,6 +119,15 @@ class ProxyState:
|
|||||||
log.warning("config reload failed: %s", exc)
|
log.warning("config reload failed: %s", exc)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def check_restart_signal(self) -> bool:
|
||||||
|
"""`POST /api/proxy/restart` 가 touch 한 신호 파일 변경 여부."""
|
||||||
|
m = _signal_mtime()
|
||||||
|
if m == self.signal_mtime:
|
||||||
|
return False
|
||||||
|
self.signal_mtime = m
|
||||||
|
log.info("restart signal received")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# TCP tunneling
|
# TCP tunneling
|
||||||
@@ -249,11 +267,16 @@ async def config_watcher(state: ProxyState, listener: Listener) -> None:
|
|||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
old_port = state.listen_port
|
old_port = state.listen_port
|
||||||
old_enabled = state.enabled()
|
old_enabled = state.enabled()
|
||||||
if not state.reload_if_changed():
|
config_changed = state.reload_if_changed()
|
||||||
continue
|
signal_changed = state.check_restart_signal()
|
||||||
|
|
||||||
new_port = int(state.cfg["proxy"]["listen_port"])
|
new_port = int(state.cfg["proxy"]["listen_port"])
|
||||||
new_enabled = state.enabled()
|
new_enabled = state.enabled()
|
||||||
if new_port != old_port or new_enabled != old_enabled:
|
|
||||||
|
port_or_enabled_changed = (
|
||||||
|
config_changed and (new_port != old_port or new_enabled != old_enabled)
|
||||||
|
)
|
||||||
|
if signal_changed or port_or_enabled_changed:
|
||||||
state.listen_port = new_port
|
state.listen_port = new_port
|
||||||
await listener.restart()
|
await listener.restart()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user