You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

384 lines
21 KiB

"""상시 감시·자가복구 (spec §8). 판정 기준 = network_config.json (적용본).
틱 30s(튜너블 10-300), 히스테리시스 2, cooldown 5분, 시간당 4회 한도.
억제: apply busy·CONFIRM_WAIT/seed·apply.service active → 전체 스킵, country-pending →
apply.service 에스컬레이션"" 금지 (전면 정지 금지 — 자가복구 목표 약화). engine.tick() 은
무조건 호출. 정상 틱은 디스크 무기록(RAM 카운터 + 1h heartbeat).
gateway ping 은 2틱마다 (interval×2) WARN 로그만."""
import hashlib, json, os, subprocess, threading, time
GRACE_S = 120
COOLDOWN_S = 300
HOURLY_MAX = 4
HEARTBEAT_S = 3600
APPLY_SERVICE = "dpworld-network-apply.service"
COMPAT_DPWORLDAPP_MD5 = ("a66c515be8de867e276aa7683c5582f1",) # §9 렌더 계약 검증된 빌드
# 복구 사다리: 체크 이름 → [단계1, 단계2, ...], 각 단계 = argv 목록 (§8 표)
_LADDERS = {
"wlan_module": [[["systemctl", "start", "dpworld-net-recover.service"]]],
"wpa_state": [[["wpa_cli", "-i", "wlan0", "reconfigure"]],
[["systemctl", "restart", "wpa_supplicant@wlan0.service"]]],
# §3.3-5: apply.service 단독은 gateway/metric-only 변경 침묵 누락 → reload 동반 필수
"addr_route": [[["networkctl", "reconfigure", "wlan0"]],
[["systemctl", "start", APPLY_SERVICE], ["networkctl", "reload"]]],
}
def _addrs_in(ip_brief_out):
"""M2: `ip -br addr show` 출력 → 정확한 주소 목록 (prefix 제거) — substring 오탐 차단
(예: 설정 192.168.55.5 가 라이브 192.168.55.54/24 에 substring 매칭되던 결함)."""
addrs = []
for ln in ip_brief_out.splitlines():
parts = ln.split()
addrs.extend(t.split("/")[0] for t in parts[2:])
return addrs
def _ladder_for(name):
if name in ("eth0", "eth1"):
return [[["networkctl", "reconfigure", name]]] # 실패한 iface 그 자체 (§8)
return _LADDERS.get(name, [[["systemctl", "start", "dpworld-net-recover.service"]]])
class NetworkWatchdog:
def __init__(self, db, engine, journal, net_dir, clock=time.monotonic, runner=None):
self.db = db; self.engine = engine; self.journal = journal
self.net_dir = net_dir; self.clock = clock
self.runner = runner or (lambda argv, t=20: _run(argv, t))
self._fail_streak = {} # check name → 연속 실패 수
self._last_recover = {} # check name → monotonic
self._recover_times = [] # 최근 1h 발동 시각
self._critical = False
self._grace_until = None # start() 시 설정
self._last_heartbeat = 0.0
self._last_results = {}
self._tick_count = 0
self._carrier_state = {} # iface → bool (transition WARN 용)
self._ping_state = {} # iface → bool (transition WARN 용)
self._down_drift_state = {} # M1: iface → bool (DOWN+주소drift transition WARN 용)
self._wpa_query_state = None # C3: wpa_cli 쿼리 성공 여부 (transition WARN 용)
self._last_apply_id = None # I1b: 마지막으로 본 apply_id — 변경 시 streak 리셋
self._thread = None
# Fix #32: guard _critical / _recover_times — reset_critical() (config route
# thread) races the watchdog loop thread that mutates the recovery rate-limit.
self._lock = threading.Lock()
# ── config (net_config DB key, log_config 전례) ─────────
def _cfg(self):
cfg = {}
try:
cfg = self.db.get_config("net_config") or {}
except Exception: # noqa: BLE001
pass
cfg = cfg if isinstance(cfg, dict) else {} # I2: list 등 이형 → 기본값 (thread 사망 금지)
try:
interval = max(10, min(300, int(cfg.get("watchdog_interval_s", 30))))
except (TypeError, ValueError):
interval = 30
return {"enabled": cfg.get("watchdog_enabled", "on") == "on",
"auto_recover": cfg.get("watchdog_auto_recover", "on") == "on",
"interval_s": interval} # §8 튜너블 3종
# ── 판정 입력 ────────────────────────────────────────────
def _intent(self):
try:
from network import renderer
with open(os.path.join(self.net_dir, "network_config.json"), encoding="utf-8") as f:
return renderer.intent_from_persist(json.load(f))
except (OSError, ValueError):
return None
def _note_carrier(self, ifc, up):
prev = self._carrier_state.get(ifc)
self._carrier_state[ifc] = up
if prev is not False and not up: # transition → WARN 1회 (§8: 복구 대상 아님)
self.journal.event("watchdog", phase="detect", action=f"{ifc}_no_carrier",
result="warn", detail={"note": "케이블 미연결 — 복구 안 함"})
def _note_wpa_query(self, ok):
"""C3: wpa_cli 쿼리 실패 transition WARN 1회 — _note_carrier 패턴."""
prev = self._wpa_query_state
self._wpa_query_state = ok
if prev is not False and not ok:
self.journal.event("watchdog", phase="detect", action="wpa_query_failed",
result="warn",
detail={"note": "wpa_cli 무응답 — UNKNOWN 처리, 복구 안 함 (C3)"})
def _collect_checks(self):
"""name → bool(healthy). 이름 = wlan_module/wpa_state/addr_route/eth0/eth1/uplink_routes.
테스트에서 통째 주입."""
out = {}
# uplink_routes: 선택 iface 감시 — network_config.json 비의존(Codex #9).
from network import netmodel, uplink as _uplink
try:
uc = self.db.get_config("uplink_config")
dev = self.db.get_config("device_config") or {}
except Exception: # noqa: BLE001
uc, dev = None, {}
up_iface = _uplink.selected_iface(uc if isinstance(uc, dict) else {})
rc_u, routes_u = self.runner(["ip", "route", "show"], 5)
if rc_u == 0: # rc!=0 = UNKNOWN → 키 생략(거짓 unhealthy 금지, C3 패턴)
if up_iface != "wlan0":
it2 = netmodel.intent_from_device(dev)
_uplink.attach(it2, dev, {"telemetry_iface": up_iface},
live=_uplink.live_subnets(self.runner)) # codex C4: U3 4번째 호출 site — DHCP on-link skip
plan, _sk = _uplink.plan_routes(it2)
if plan:
out["uplink_routes"] = all(
(got := _uplink._installed_route(routes_u, r["dest"]))
and got["dev"] == up_iface and got["gateway"] == r["gateway"]
for r in plan)
else: # wlan0 선택: eth0/eth1 stale /32 가 없어야 healthy (Codex #5)
out["uplink_routes"] = not _uplink.stale_routes(
_uplink.telemetry_targets(dev), routes_u)
it = self._intent()
if it is None:
return out # ★ {} 아님 — uplink 체크 보존(early-return 앞에서 채움)
wifi_configured = bool(netmodel.effective_profiles(it))
if wifi_configured:
out["wlan_module"] = os.path.isdir("/sys/module/wlan") and \
os.path.exists("/sys/class/net/wlan0")
rc, txt = self.runner(["wpa_cli", "-i", "wlan0", "status"], 5)
if rc == 0:
self._note_wpa_query(True)
out["wpa_state"] = "wpa_state=COMPLETED" in txt
else:
# C3: 쿼리 실패 = UNKNOWN — 키 생략 (unhealthy 오판 → production wpa flap 금지.
# 부팅 순서 race: wpa_supplicant 제어 소켓 준비 전 watchdog 선기동)
self._note_wpa_query(False)
if it["wlan0"]["mode"] == "static":
rc2, addrs = self.runner(["ip", "-br", "addr", "show", "wlan0"], 5)
if rc2 != 0:
# M-D: transient ip(8) failure → UNKNOWN, omit key (mirror wpa_state C3 pattern)
pass
else:
ok = it["wlan0"]["ip"] in _addrs_in(addrs) # M2: 정확 일치
if ok and netmodel.gateway_set(it["wlan0"]): # §8: IP + 기본라우트 일치
# Fix #13: gate on rc — a transient `ip route show` failure
# must not register as route-missing (mirror C3 rc-omit at
# ~125). On rc!=0 leave `ok` as the address-only result.
rc_rt, routes = self.runner(["ip", "route", "show"], 5)
if rc_rt == 0:
ok = f"default via {it['wlan0']['gateway']} dev wlan0" in routes
out["addr_route"] = ok
for ifc in ("eth0", "eth1"):
if it[ifc]["mode"] == "static" and it[ifc]["ip"]:
rc3, addrs3 = self.runner(["ip", "-br", "addr", "show", ifc], 5)
# 케이블 미연결/링크 다운은 복구 대상 아님 (§8). .56 실측: 케이블 없는 eth0 은
# NO-CARRIER 가 아니라 state=DOWN 으로 나옴 — 가드 누락 시 5분마다 무의미
# reconfigure → 시간당 4회 한도 도달 → watchdog CRITICAL 자멸.
tokens3 = addrs3.split()
state3 = tokens3[1] if len(tokens3) >= 2 else ""
if "NO-CARRIER" in addrs3 or state3 == "DOWN":
self._note_carrier(ifc, False)
# v1.6.0 codex M1: 복구는 여전히 보류(자멸 방지)하되, DOWN iface 가 stale/wrong
# IP 를 들고 있으면(intent IP 부재) 가시성만 추가 — transition-only WARN.
present = _addrs_in(addrs3)
drift = bool(present) and it[ifc]["ip"] not in present
prev = self._down_drift_state.get(ifc)
self._down_drift_state[ifc] = drift
if drift and not prev:
self.journal.event("watchdog", phase="detect",
action=f"{ifc}_down_addr_drift", result="warn",
detail={"expected": it[ifc]["ip"], "actual": present,
"state": state3,
"note": "link DOWN + 주소 불일치 — 복구 보류(케이블/링크 점검)"})
self._fail_streak[ifc] = 0 # M3: carrier 부재 — streak 동결 아닌 리셋
continue
self._note_carrier(ifc, True)
out[ifc] = it[ifc]["ip"] in _addrs_in(addrs3) # per-iface 키 + M2 정확 일치
return out
def _ping_targets(self):
"""gateway ping 대상 — 테스트 주입점."""
it = self._intent()
if it is None:
return []
from network import netmodel
return [(ifc, it[ifc]["gateway"]) for ifc in ("wlan0", "eth0", "eth1")
if it[ifc]["mode"] == "static" and netmodel.gateway_set(it[ifc])]
def _gateway_ping_warn(self):
"""§8 체크 4: 2틱마다 (interval×2) ping — WARN 로그만, 자동복구 절대 금지 (auth-loop 이력 교훈)."""
for ifc, gw in self._ping_targets():
rc, _ = self.runner(["ping", "-c", "1", "-W", "2", "-I", ifc, gw], 5)
ok = rc == 0
prev = self._ping_state.get(ifc)
self._ping_state[ifc] = ok
if prev is not False and not ok: # transition 시에만 기록 (스팸 방지)
self.journal.event("watchdog", phase="detect", action="gw_ping", result="warn",
detail={"iface": ifc, "gateway": gw, "note": "자동복구 없음 (§8)"})
def _service_active(self, unit):
rc, _ = self.runner(["systemctl", "is-active", "--quiet", unit], 5)
return rc == 0
def _do_recover(self, name, ladder_idx):
if name == "uplink_routes":
from network import uplink as _uplink
# review U4: country 가 reboot-deferred 보류 중이면 uplink self-heal 건너뜀 — apply_uplink
# 가 wpa conf 를 NEW country 로 재렌더 + apply.service/wpa reconfigure 로 보류된 regdomain
# 을 라이브 누출하는 것 차단(정상 래더의 country_pending 억제와 대칭).
if self.engine.country_pending():
self.journal.event("watchdog", phase="recover", action="skip_apply_uplink",
result="warn", detail={"check": name, "reason": "country_pending (§8)"})
return
uc = self.db.get_config("uplink_config")
up_iface = _uplink.selected_iface(uc if isinstance(uc, dict) else {})
try:
res = self.engine.apply_uplink(up_iface)
state = (res or {}).get("state", "")
if state == "COMMITTED":
result = "ok"
elif state in ("ROLLED_BACK", "BUSY"):
result = "warn"
else: # FAILED_CRITICAL, ABORTED, FAILED_VALIDATION, UNKNOWN
result = "fail"
self.journal.event("watchdog", phase="recover", action="apply_uplink",
result=result, detail={"iface": up_iface, "state": state})
except Exception as e: # noqa: BLE001 — 복구 실패가 watchdog thread 죽이면 안 됨
self.journal.event("watchdog", phase="recover", action="apply_uplink",
result="fail", detail={"error": str(e)})
return
ladder = _ladder_for(name)
step = ladder[min(ladder_idx, len(ladder) - 1)]
for argv in step:
# §8 억제 5: country deferred 보류 중엔 apply.service 에스컬레이션만 금지
if self.engine.country_pending() and APPLY_SERVICE in argv:
self.journal.event("watchdog", phase="recover", action="skip_apply_service",
result="warn", detail={"check": name, "reason": "country_pending (§8)"})
continue
rc, out = self.runner(argv, 30)
self.journal.event("watchdog", phase="recover", action=" ".join(argv),
result="ok" if rc == 0 else "fail", detail={"check": name, "rc": rc})
# ── 틱 ──────────────────────────────────────────────────
def reset_critical(self):
"""#6: 수동 재무장 — #2 config 라우트의 reset_watchdog_critical 가 호출.
critical 잠금 + 1h 발동 이력을 비워 자가복구를 즉시 되살린다."""
with self._lock: # Fix #32: serialize with the watchdog loop's tick mutations
self._critical = False
self._recover_times = []
self.journal.event("watchdog", phase="reset", action="critical_reset_manual",
result="ok", detail={})
def tick_once(self):
self.engine.tick() # confirm TTL 안전망 — 억제조건·enabled 와 무관하게 항상 (§6.1)
cfg = self._cfg()
now = self.clock()
# #6: 자동 재무장 — 시간당 한도(HOURLY_MAX)로 _critical 잠긴 뒤 1h 창이 비면 스스로 해제.
# early-return 전에 평가해야 critical 잠금 상태에서도 재무장이 동작한다.
with self._lock: # Fix #32: serialize rate-limit state with reset_critical()
self._recover_times = [t for t in self._recover_times if now - t < 3600]
rearmed = self._critical and not self._recover_times
if rearmed:
self._critical = False
if rearmed:
self.journal.event("watchdog", phase="rate_limit", action="critical_auto_rearmed",
result="ok", detail={})
if not cfg["enabled"] or self._critical:
return
if self._grace_until is None:
self._grace_until = now + GRACE_S
if now < self._grace_until:
return
# I1a: CONFIRM_WAIT 포함 억제 — eth1 확인 대기 중 복구 사다리 침범 금지
# (is_busy_or_confirming 부재 FakeEngine 호환: is_busy 폴백)
if getattr(self.engine, "is_busy_or_confirming", self.engine.is_busy)():
return # apply 진행/확인 대기 중 일시정지 (§8)
if self._service_active(APPLY_SERVICE) or \
self._service_active("dpworld-network-seed.service"):
return # dpworldapp 재시작 직후 정당한 재적용과 경합 금지 (§3.3-4)
# I1b: 새 apply 가 지나가면 구 구성 기준의 streak/transition 상태는 무효 — 리셋
status_fn = getattr(self.engine, "status", None)
if status_fn is not None:
try:
aid = status_fn().get("apply_id")
except Exception: # noqa: BLE001 — status 실패가 watchdog 틱을 막으면 안 됨
aid = self._last_apply_id
if aid != self._last_apply_id:
self._last_apply_id = aid
self._fail_streak.clear()
self._ping_state.clear()
self._carrier_state.clear()
self._down_drift_state.clear() # Fix #18: else a stale DOWN+drift
# warning is silenced for the watchdog's lifetime (transition gate)
self._tick_count += 1
results = self._collect_checks()
self._last_results = results
for name, healthy in results.items():
if healthy:
self._fail_streak[name] = 0
continue
self._fail_streak[name] = self._fail_streak.get(name, 0) + 1
if self._fail_streak[name] < 2: # 히스테리시스 (§8)
continue
if not cfg["auto_recover"]:
self.journal.event("watchdog", phase="detect", action=name, result="warn",
detail={"auto_recover": False})
continue
if now - self._last_recover.get(name, -1e9) < COOLDOWN_S:
continue
with self._lock: # Fix #32: serialize rate-limit state with reset_critical()
self._recover_times = [t for t in self._recover_times if now - t < 3600]
tripped = len(self._recover_times) >= HOURLY_MAX
if tripped:
self._critical = True
recoveries = len(self._recover_times)
if tripped:
self.journal.event("watchdog", phase="rate_limit", action="critical_stop",
result="fail", detail={"recoveries_last_hour": recoveries})
return
ladder_idx = max(0, self._fail_streak[name] - 2)
self._do_recover(name, ladder_idx)
self._last_recover[name] = now
with self._lock: # Fix #32: serialize rate-limit state with reset_critical()
self._recover_times.append(now)
if self._tick_count % 2 == 0:
self._gateway_ping_warn() # 2틱마다 = interval×2 (§8 체크 4)
if now - self._last_heartbeat >= HEARTBEAT_S:
self._last_heartbeat = now
self.journal.event("watchdog", phase="heartbeat", action="tick", result="ok",
detail={"checks": {k: bool(v) for k, v in results.items()}})
def _check_fw_md5(self):
"""§9 펌웨어 drift 감시 — 시작 시 1회: 렌더 계약 검증된 dpworldapp 빌드인지.
I6: 1MB 청크 read — MemoryMax=48M 하에서 수십 MB 바이너리 전체 read 금지."""
try:
h = hashlib.md5()
with open("/usr/bin/dpworldapp", "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
md5 = h.hexdigest()
except OSError:
return
if md5 not in COMPAT_DPWORLDAPP_MD5:
self.journal.event("watchdog", phase="startup", action="fw_md5_drift", result="warn",
detail={"md5": md5, "note": "렌더 계약 미검증 펌웨어 — golden 재캡처 필요 (§9)"})
def snapshot(self):
return {"enabled": self._cfg()["enabled"], "critical": self._critical,
"fail_streak": dict(self._fail_streak), "last_results": dict(self._last_results)}
def start(self):
if self._thread:
return
self._grace_until = self.clock() + GRACE_S
self._check_fw_md5()
def loop():
while True:
try:
self.tick_once()
time.sleep(self._cfg()["interval_s"])
except Exception: # noqa: BLE001 — watchdog 은 절대 죽지 않는다
time.sleep(30) # I2: sleep 도 try 안 — _cfg 예외로 thread 사망/tight-loop 금지
self._thread = threading.Thread(target=loop, daemon=True, name="net-watchdog")
self._thread.start()
def _run(argv, timeout):
try:
p = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
return p.returncode, p.stdout or ""
except (subprocess.SubprocessError, OSError) as e:
return 1, str(e)