메인 콘텐츠로 이동하기
  1. Server-Logs/

휴고서버 관리 대시보드 코드 공개 (hugodash.py)

·41 분

더 편하게 읽는 방법 (초보자 추천)

아래 코드는 1,400줄이 넘어서 이 페이지에서 쭉 훑어보기엔 불편할 수 있습니다. 함수마다 색깔로 구분되고, 우측 목차에서 원하는 함수를 클릭하면 바로 그 자리로 이동하는 정리된 버전을 따로 만들어뒀습니다. 코드를 직접 뜯어보고 싶으시다면 이쪽을 먼저 열어보시길 추천합니다.

→ 함수별로 점프 가능한 정리 버전 보기 (hugodash-preview.html)

이 글은 제가 VMware + 우분투 위에서 Hugo 블로그를 돌리는 서버를 웹 화면에서 살펴보고 관리하기 위해 만든 대시보드 프로그램(hugodash.py) 코드를 공개하는 글입니다.

프레임워크도 PHP도 없이 파이썬 표준 라이브러리(http.server)만으로, 화면(HTML)과 처리 기능을 한 파일에서 모두 만들었습니다. 코딩을 잘 몰라도 흐름을 따라올 수 있도록 함수마다 한글 주석을 달았습니다. 다만 실제 운영 서버의 보안과 직결되는 값들(접근 차단 경로 목록 등)은 ***로 가리고 계정 이름은 myuser로 바꿨으니, 그대로 복붙해서 쓰기보다는 참고용으로 봐주세요.

이 대시보드의 보안 설계는 이렇습니다.

  • 로그인 화면이 없습니다. 대신 서버 자기 자신(127.0.0.1)에서만 접속을 받아서, SSH 터널을 열 수 있는 사람(= SSH 키를 가진 사람)만 화면을 볼 수 있습니다. 인터넷에서는 아예 접속이 안 됩니다.
  • 화면에서 버튼을 숨기는 것만 믿지 않습니다. 경로·IP·권한 숫자 같은 값은 요청이 올 때마다 서버에서 다시 검사합니다. (막힌 경로 판정, 블로그 폴더 밖 저장 거부, IP 형식 검사 등)
  • 바꾸는 동작은 최소한으로 만들었습니다. 파일 수정은 블로그 폴더 안에서만 되고, 저장할 때마다 원본을 .bak-시각 파일로 먼저 백업합니다. 권한 변경은 읽기·쓰기·실행 숫자만 바꾸고, 주인·그룹은 바꾸지 않습니다.
  • 작업 기록: 저장, 권한 변경, 차단 해제는 전부 기록 파일에 한 줄씩 남깁니다.
  • 다른 사이트가 내 브라우저를 시켜 몰래 요청을 보내는 것(CSRF)과, 다른 이름으로 접속을 속이는 것 (DNS 리바인딩)도 막는 장치가 들어 있습니다.

전체 코드는 아래와 같습니다.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
휴고서버 대시보드 (터널 전용)

- 127.0.0.1 에만 바인딩한다. SSH 터널(ssh -L 8081:127.0.0.1:8081 ...)을 열어야만 접속 가능.
- 로그인/OTP 없음. "터널을 열 수 있는 사람 = SSH 키를 가진 사람" 이 곧 인증이다.
- 화면(HTML)과 API 를 이 파일 하나가 모두 처리한다. PHP/nginx 를 쓰지 않는다.
- 파일 탐색기: 서버 전체(/)가 보이고, 막을 곳(BLOCKED_PATHS)만 🔒 처리한다.
    쓰기 가능 : WRITE_ROOTS (블로그 폴더) 뿐
    읽기 전용 : 막은 곳을 뺀 나머지 전부
    접근 불가 : BLOCKED_PATHS (목록에는 보이되 열 수 없음). 서버 쪽에서 직접 검사한다.
- 목록에 소유(주인:그룹)와 권한(rwx)을 보여 준다. 쓰기 가능 영역(블로그 폴더)의 것은 눌러서 '권한'만 바꿀 수 있다(최소 접근 원칙).
    주인·그룹은 바꾸지 않는다. 이 프로그램이 일반 계정으로 직접 하며 새 sudo 규칙이 없다.
    주인이 myuser 가 아닌 파일은 바꿀 수 없다.
"""

import grp  # 사용자 그룹 이름 조회 (그룹 번호 → 이름)
import html  # 화면에 찍을 글자를 안전하게 바꾸기(html.escape)
import json  # 작업 기록을 JSON 한 줄로 저장할 때
import os  # 경로·파일·환경변수 등 운영체제 기능
import pwd  # 사용자 이름 조회 (사용자 번호 → 이름)
import re  # 정규식(패턴 검사): 로그 줄 분석, 입력값 검사
import shutil  # 파일 복사(백업), 디스크 용량 조회
import stat  # 파일 종류·권한 비트 해석
import subprocess  # systemctl, fail2ban-client 같은 리눅스 명령 실행
import sys  # 프로그램 종료·오류 출력
import time  # 시간 관련 기능 (표준 라이브러리)
from datetime import datetime  # 현재 시각 계산·기록
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer  # 웹서버의 핵심 부품 (표준 라이브러리라 따로 설치할 것이 없음)
from urllib.parse import urlparse, parse_qs, urlencode, quote  # 주소·폼 값 분해, 주소 만들기/인코딩

# =====================================================================
# 이 코드는 무엇인가? (초보자를 위한 개요)
# =====================================================================
# 블로그를 돌리는 서버(nginx + Hugo)를 웹 화면(대시보드)에서 살펴보고
# 관리할 수 있게 해주는 파이썬 프로그램 하나(hugodash.py)입니다.
#
# - 프레임워크(Django, Flask 같은 것)도, PHP도, 별도 웹서버 설정도 없이
#   파이썬 표준 라이브러리의 http.server 만으로 화면(HTML)과 처리 기능을
#   한 파일에서 모두 만들었습니다.
# - 로그인 화면이 없습니다. 대신 이 프로그램은 127.0.0.1(서버 자기 자신)에서만
#   접속을 받기 때문에, SSH 터널(ssh -L)을 열 수 있는 사람만 화면을 볼 수 있습니다.
#   "SSH 키를 가진 사람 = 관리자" 라는 뜻입니다.
# - 화면은 탭 4개: 대시보드 / 파일 탐색기 / 서버 로그 / 보안 점검.
# - 파일 탐색기는 서버 전체를 보여주되, 위험한 곳(계정정보·SSH·인증서 개인키 등)은
#   목록에는 보여도 열 수 없게(🔒) 서버 쪽에서 막습니다. 수정은 블로그 폴더 안에서만
#   되고, 저장할 때마다 원본을 .bak-시각 파일로 먼저 백업합니다.
# - 바꾸는 동작(저장, 권한 변경, 차단 해제)은 전부 작업 기록 파일에 남깁니다.
# - 바깥에서 오는 값(경로, IP, 권한 숫자)은 항상 서버에서 다시 검사합니다.
#   화면에서 버튼을 숨기는 것만 믿지 않습니다.
# - do_GET(화면 보기) / do_POST(바꾸는 동작) 두 함수가 요청을 받아 알맞은
#   함수로 넘겨주는 교통정리 역할을 합니다.
#
# ※ 이 글은 학습/공유 목적으로 일부 보안에 민감한 값(***)을 가리고 계정 이름을
#    myuser 로 바꿔서 공개합니다. 실제 운영 시에는 이 코드를 그대로 복붙하지 말고,
#    본인 환경에 맞게 검토 후 사용하세요.
# =====================================================================

# ----------------------------------------------------------------------------
# 설정
# ----------------------------------------------------------------------------
# 이 서버가 "어느 주소로 오는 접속을 받을지" 정하는 값입니다.
# 127.0.0.1 은 "이 컴퓨터 자기 자신"이라는 뜻이라, 인터넷 쪽에서는 이 대시보드에 접속할 수 없습니다.
# (그래서 내 PC에서 SSH 터널을 열어야만 화면이 보입니다. 이 프로그램은 로그인 화면이 없습니다.)
BIND_HOST = "127.0.0.1"          # 절대 바꾸지 말 것 (0.0.0.0 으로 바꾸면 외부에 공개됨)
# 접속 포트 번호. 환경변수 HUGODASH_PORT 가 있으면 그 값, 없으면 8081 을 씁니다.
BIND_PORT = int(os.environ.get("HUGODASH_PORT", "8081"))

# --- 파일 탐색기 권한 (막을 곳만 지정) ---
# "저장(수정)이 허용되는 폴더" 목록입니다. 블로그 폴더 하나뿐이고, 여기 밖의 파일은 볼 수만 있습니다.
WRITE_ROOTS = ["/home/myuser/blog.snsoz.com"]      # 저장(수정)은 여기서만 가능
# [보안 마스킹]
# 파일탐색기가 "접근 금지"로 취급하는 실제 경로 목록입니다.
# 이 값을 그대로 공개하면 "여기만 피하면 뚫린다"는 지도를 넘겨주는 셈이라
# 블로그 공개용 코드에서는 실제 값 대신 자리표시자(***)로 가려두었습니다.
# 실제로 운영할 때는 시스템 계정 정보(/etc/shadow, /etc/passwd 등), SSH 설정,
# 크론, systemd, PAM/보안 설정, /root, 인증서 개인키, 로그인 열쇠(.ssh) 등
# "건드리면 서버 전체가 위험해지는 경로"를 최대한 넉넉하게 등록해두세요.
BLOCKED_PATHS = [                                  # 목록에는 보이되 열람·수정 모두 차단
    "***", "***", "***", "***", "***",           # 예: 계정정보 / SSH / 크론 / systemd / 보안설정 등 (실제 값은 비공개)
    "***", "***", "***",                          # 예: 인증서 개인키, 로그인 열쇠 등 (실제 값은 비공개)
]
# 파일 탐색기 위쪽에 나오는 바로가기 버튼들 (표시할 이름과 이동할 경로).
QUICKLINKS = [
    {"path": "/", "label": "전체 (/)"},
    {"path": "/home/myuser/blog.snsoz.com", "label": "블로그 (수정 가능)"},
    {"path": "/home/myuser", "label": "홈 (myuser)"},
    {"path": "/etc/nginx", "label": "Nginx 설정"},
    {"path": "/opt/hugodash", "label": "대시보드"},
    {"path": "/usr/local/bin", "label": "스크립트"},
    {"path": "/var/log", "label": "로그"},
]
# 15MB 를 넘는 파일은 화면에서 열지 않습니다 (브라우저가 멈추는 것 방지).
MAX_VIEW_BYTES = 15 * 1024 * 1024
# 3MB 를 넘는 파일은 볼 수는 있어도 수정·저장은 거부합니다.
MAX_EDIT_BYTES = 3 * 1024 * 1024

# --- 권한 변경 (최소 접근 원칙) ---
# 바꿀 수 있는 곳: WRITE_ROOTS(블로그 폴더) 안의, 주인이 myuser 인 일반 파일·폴더 하나씩. 폴더 안까지 한꺼번에 바꾸는 기능은 없다.
# 바꾸는 것은 읽기·쓰기·실행 권한뿐이다(주인·그룹 변경 없음, 새 sudo 규칙 없음).

# --- 로그 목록 ---
# "서버 로그" 탭에 버튼으로 나오는 로그 목록입니다. 한 줄이 로그 하나입니다.
# 종류가 file 이면 로그 파일의 끝부분을 읽고, journal 이면 journalctl 로 서비스 기록을 읽습니다.
LOG_SOURCES = [
    # (키, 라벨, 종류, 값, 그룹)
    ("nginx_access", "Nginx 접속 로그", "file", "/var/log/nginx/access.log", "웹 서버"),
    ("blog_access", "블로그 전용 접속 로그", "file", "/var/log/nginx/blog-access.log", "웹 서버"),
    ("nginx_error", "Nginx 에러 로그", "file", "/var/log/nginx/error.log", "웹 서버"),
    ("hugo_build", "Hugo 빌드 로그", "file", "/home/myuser/hugo-build.log", "블로그"),
    ("letsencrypt", "SSL 인증서(Certbot) 로그", "file", "/var/log/letsencrypt/letsencrypt.log", "블로그"),
    ("fail2ban_log", "fail2ban 로그", "file", "/var/log/fail2ban.log", "보안"),
    ("auth_log", "인증 로그 (SSH 등)", "file", "/var/log/auth.log", "보안"),
    ("syslog", "시스템 로그", "file", "/var/log/syslog", "시스템"),
    ("kern", "커널 로그", "file", "/var/log/kern.log", "시스템"),
    ("dpkg", "패키지 설치/업데이트 로그", "file", "/var/log/dpkg.log", "시스템"),
    ("nginx_journal", "Nginx 서비스 기록", "journal", "nginx", "서비스"),
    ("ssh_journal", "SSH 서비스 기록", "journal", "ssh", "서비스"),
    ("fail2ban_journal", "fail2ban 서비스 기록", "journal", "fail2ban", "서비스"),
]
# 대시보드 "서비스 상태"에 살아있는지 보여줄 systemd 서비스들 (서비스 이름, 화면에 쓸 이름).
SERVICE_UNITS = [("nginx", "Nginx"), ("ssh", "SSH"), ("fail2ban", "fail2ban")]

# 큰 로그 파일도 끝에서 최대 2MB 만 읽어서 빠르게 보여줍니다.
LOG_TAIL_MAX_BYTES = 2 * 1024 * 1024
# 로그 화면에서 고를 수 있는 "몇 줄 보기" 선택지.
LINE_CHOICES = [50, 100, 200, 500]

# --- 감사(작업) 기록: 파일 저장 같은 쓰기 작업만 남긴다 ---
# 작업 기록 파일 위치. 환경변수 HUGODASH_AUDIT_LOG 로 바꿀 수 있습니다.
AUDIT_LOG_PATH = os.environ.get("HUGODASH_AUDIT_LOG", "/home/myuser/hugodash-audit.log")


def audit(action, target, result, detail=""):
    # 쓰기 작업(파일 저장, 권한 변경, 차단 해제)을 한 줄짜리 JSON 으로 기록 파일에 남기는 함수.
    # 나중에 "언제, 무엇을, 어떻게 바꿨는지" 되짚어 볼 때 씁니다. 기록이 실패해도 본 작업은 막히지 않게 오류는 무시합니다.
    try:
        rec = {  # 기록 한 건의 내용을 사전(dict)으로 만든다
            "ts": datetime.now().isoformat(timespec="seconds"),
            "action": action,
            "target": target,
            "result": result,
            "detail": detail,
        }
        with open(AUDIT_LOG_PATH, "a", encoding="utf-8") as f:  # 기록 파일을 '이어쓰기(a)' 모드로 연다
            f.write(json.dumps(rec, ensure_ascii=False) + "\n")  # JSON 한 줄로 저장 (한글이 깨지지 않게 ensure_ascii=False)
    except Exception:  # 기록이 실패해도 원래 작업(저장 등)은 막지 않는다
        pass


# ----------------------------------------------------------------------------
# 경로 권한 판정 (핵심 보안 부분)
# ----------------------------------------------------------------------------
def _real(p):
    # 심볼릭 링크(바로가기)를 전부 풀어서 "진짜 경로"를 돌려줍니다. 보안 검사는 항상 이 진짜 경로로 합니다.
    return os.path.realpath(p)  # 링크를 모두 풀고 .. 도 정리한 절대경로


def _is_under(path, root):
    # path 가 root 폴더 "안"(또는 root 자신)인지 확인하는 함수. 접두사만 비교하면 /home/a 와 /home/ab 를 헷갈리므로 끝에 / 를 붙여 비교합니다.
    root = _real(root)  # 비교 기준 폴더도 진짜 경로로
    return path == root or path.startswith(root + os.sep)  # 같은 경로이거나, '기준폴더/' 로 시작하면 그 안


def classify(path):
    """실제 경로(심볼릭 링크를 풀어낸 뒤)로 권한을 판정한다.
    반환: 'deny' (막은 곳) | 'write' (블로그 폴더 안) | 'read' (그 외 전부)
    """
    # 이 프로그램의 핵심 보안 판정 함수. 어떤 경로든 세 가지 중 하나로 분류합니다.
    #   deny  = 막은 곳 (목록에는 보여도 열 수 없음) / write = 블로그 폴더 안 (수정 가능) / read = 그 외 전부 (읽기만)
    # 화면에서 버튼을 숨기는 것으로 끝내지 않고, 파일을 열거나 저장하기 직전에도 서버 쪽에서 매번 다시 호출합니다.
    real = _real(path)  # 링크를 다 풀어낸 진짜 경로를 먼저 구한다 (링크로 벽을 넘는 꼼수 방지)

    for d in BLOCKED_PATHS:  # 1순위: 막아둔 경로들을 하나씩 확인
        if _is_under(real, d):  # 이 경로가 막힌 곳 '안'이면
            return "deny"  # 접근 불가로 판정 (목록에는 보여도 열 수 없음)

    for r in WRITE_ROOTS:  # 2순위: 쓰기 허용 폴더(블로그 폴더)인지 확인
        if _is_under(real, r):  # 그 폴더 '안'이면
            return "write"  # 수정 가능으로 판정

    return "read"  # 나머지는 전부 '읽기만 가능'


def resolve(raw_path):
    """사용자가 준 경로를 정규화해 실제 경로로 바꾼다. 절대경로만 허용."""
    # 사용자가 URL 로 보낸 경로 문자열을 안전한 진짜 경로로 바꿉니다. ../ 같은 꼼수도 여기서 다 정리됩니다.
    raw_path = (raw_path or "/").strip()  # 비어 있으면 루트(/), 앞뒤 공백은 제거
    if "\x00" in raw_path:  # 글자 중간에 숨긴 널 문자(\x00)가 있으면 거부 (경로 검사를 속이는 수법)
        raise ValueError("bad_path")  # 잘못된 경로라고 알림
    if not raw_path.startswith("/"):  # 절대경로(/ 로 시작)만 허용, 아니면 앞에 / 를 붙임
        raw_path = "/" + raw_path
    return _real(raw_path)  # 링크와 ../ 를 모두 정리한 진짜 경로를 돌려줌


# ----------------------------------------------------------------------------
# 소유·권한 (표시와 변경)
# ----------------------------------------------------------------------------
def uid_name(uid):
    # 숫자 사용자 번호(uid)를 이름으로 바꿔서 화면에 보여줍니다. 이름이 없으면 숫자 그대로.
    try:
        return pwd.getpwuid(uid).pw_name
    except KeyError:
        return str(uid)


def gid_name(gid):
    # 숫자 그룹 번호(gid)를 이름으로 바꿉니다.
    try:
        return grp.getgrgid(gid).gr_name
    except KeyError:
        return str(gid)


def mode_octal(mode):
    # 권한 숫자를 3자리 8진수 문자열로 (예: 0o100644 → "644").
    return format(mode & 0o777, "03o")


def mode_rwx(mode):
    # 권한을 rwxr-xr-x 모양의 글자로 (ls -l 에서 보던 그 모양).
    return stat.filemode(mode)[1:]


def perm_block_reason(real, st):
    """이 화면에서 소유·권한을 바꿀 수 없는 이유. 바꿀 수 있으면 빈 문자열."""
    # "이건 여기서 권한을 바꾸면 안 되는 대상"이면 그 이유를, 바꿔도 되면 빈 문자열을 돌려줍니다.
    # 화면(변경 가능 여부 표시)과 실제 변경 함수가 같은 기준을 쓰도록 한 곳에 모아뒀습니다.
    if classify(real) != "write":  # 블로그 폴더 밖이면 바꾸지 않음
        return "블로그 폴더 밖이라 여기서 바꿀 수 없습니다."
    if stat.S_ISLNK(st.st_mode):  # 링크(바로가기)이면 링크가 가리키는 곳이 바뀌어 버리므로 바꾸지 않음
        return "링크(바로가기)는 바꾸지 않습니다."
    if not (stat.S_ISREG(st.st_mode) or stat.S_ISDIR(st.st_mode)):  # 일반 파일도 폴더도 아니면(장치·파이프 등) 바꾸지 않음
        return "일반 파일과 폴더만 바꿉니다."
    if st.st_mode & 0o7000:  # setuid/setgid/sticky 같은 특수 권한 비트가 붙어 있으면 바꾸지 않음
        return "특수 권한(setuid·setgid·sticky)이 붙어 있어 바꾸지 않습니다."
    return ""  # 문제 없음 → 빈 문자열


def perm_apply(real, mode_s):
    """권한(읽기·쓰기·실행)을 바꾸고 결과를 다시 읽어 확인한다. (성공 여부, 화면에 보여 줄 문장)"""
    # 파일·폴더 하나의 권한(읽기·쓰기·실행)을 바꾸고, 바뀐 결과를 다시 읽어서 확인하는 함수.
    # 돌려주는 값: (성공 여부, 화면에 보여줄 문장). 주인·그룹은 바꾸지 않고 권한 숫자만 바꿉니다.
    def fail(msg, detail=""):  # 실패 처리를 한 곳에 모은 작은 도우미: 실패를 기록하고 (False, 메시지)를 돌려준다
        audit("file_perm", real, "fail", (detail or msg))  # 실패도 작업 기록에 남긴다
        return False, msg

    if not re.fullmatch(r"[0-7]{3}", mode_s or ""):  # 권한 값은 숫자 3자리(각 0~7)여야 한다. 엉뚱한 값은 거부
        return fail("권한 값이 올바르지 않습니다.")
    if int(mode_s[2]) & 2:  # 마지막 자리가 '나머지 모두'의 권한. 거기에 쓰기(2)가 들어 있으면 거부
        return fail("'나머지 모두'에게 쓰기 권한은 줄 수 없습니다.")
    try:
        st = os.lstat(real)  # 링크를 따라가지 않고 이 파일 자체의 정보를 읽는다
    except FileNotFoundError:  # 파일이 없으면
        return fail("파일을 찾을 수 없습니다.")
    except PermissionError:  # 위쪽 폴더에 들어갈 권한이 없으면
        return fail("이 파일이나 그 위 폴더가 myuser 에게 열려 있지 않아 확인할 수 없습니다.")
    except OSError as e:  # 그 밖의 파일 시스템 오류
        return fail(f"파일 상태를 확인하지 못했습니다: {e.strerror}")
    reason = perm_block_reason(real, st)  # 블로그 밖·링크·특수 파일처럼 '바꾸면 안 되는 대상'인지 확인
    if reason:
        return fail(reason)
    if st.st_uid != os.geteuid():  # 이 프로그램을 실행 중인 계정이 파일 주인이 아니면 chmod 가 어차피 안 되므로 미리 거절
        return fail(f"이 파일의 주인은 {uid_name(st.st_uid)} 입니다. 주인이 myuser 인 것만 바꿀 수 있습니다.", "not_owner")

    cur_mode = st.st_mode & 0o777  # 지금 권한 숫자 (예: 644)
    want_mode = int(mode_s, 8)  # 바꾸려는 권한 (8진수 문자열 → 숫자)
    if want_mode == cur_mode:  # 이미 같은 값이면 아무것도 하지 않고 종료
        return True, "바뀐 것이 없습니다 (지금 값과 같음)."
    try:
        os.chmod(real, want_mode)  # 실제로 권한을 바꾸는 유일한 줄
    except OSError as e:  # 권한 변경이 실패한 경우 (예: 읽기 전용 디스크)
        return fail(f"적용하지 못했습니다: {e.strerror}", f"chmod_failed:{e.strerror}")
    try:
        st2 = os.lstat(real)  # 바꾼 뒤 다시 읽어서 진짜 바뀌었는지 확인 ("됐겠지" 하고 넘어가지 않는다)
    except OSError as e:  # 다시 읽기가 실패한 경우
        return fail(f"바꾼 뒤 확인하지 못했습니다: {e.strerror}", "verify_failed")
    if (st2.st_mode & 0o777) != want_mode:  # 다시 읽은 값이 원하는 값과 다르면 실패로 처리
        return fail(f"적용은 했지만 다시 읽어 보니 값이 다릅니다: {mode_octal(st2.st_mode)}", "verify_mismatch")
    # 성공도 기록에 남긴다 (예전 값 -> 새 값)
    audit("file_perm", real, "ok", f"권한 {mode_octal(cur_mode)} -> {mode_octal(want_mode)}")
    # 성공 메시지
    return True, f"변경 완료 (권한 {mode_octal(cur_mode)}{mode_octal(want_mode)})"


# ----------------------------------------------------------------------------
# 시스템 조회 함수
# ----------------------------------------------------------------------------
def run_lines(cmd, timeout=15):
    # 리눅스 명령어를 실행하고 결과 글자를 돌려주는 공용 도우미. (출력, 오류메시지) 중 하나만 채워서 돌려줍니다.
    # 명령이 없거나 시간이 오래 걸려도 대시보드가 멈추지 않도록 시간 제한(timeout)을 둡니다.
    try:
        # 명령 실행: 출력을 글자로 받고(text), 정해진 시간(timeout) 안에 끝나야 함
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        out = (r.stdout or "").rstrip("\n")  # 정상 출력 (끝의 줄바꿈 제거)
        err = (r.stderr or "").strip()  # 오류 출력
        if not out and err:  # 출력이 없고 오류만 있으면 오류를 돌려줌
            return None, err
        return out, None
    except FileNotFoundError:  # 그런 명령어가 설치되어 있지 않으면
        return None, "명령 없음: " + cmd[0]
    except subprocess.TimeoutExpired:  # 시간 안에 안 끝나면 (멈춘 명령 때문에 화면이 안 열리는 것 방지)
        return None, "명령 시간 초과"
    except Exception as e:  # noqa
        return None, str(e)


def stat_state(path):
    """경로가 어떤 상태인지만 본다(읽기 권한은 따지지 않음).
    ('file'|'dir'|'other', 정보) / ('missing'|'denied'|'error', None)
    - missing : 그런 경로가 없다
    - denied  : 있는지 확인하려는데 상위 폴더에 들어갈 권한이 없다 (os.path.isfile 은 이 경우도 그냥 False 라서 없는 것과 구분이 안 됐음)
    - error   : 그 밖의 오류
    """
    try:
        st = os.stat(path)  # 경로 정보를 읽어본다 (읽기 권한은 따지지 않음)
    except (FileNotFoundError, NotADirectoryError):  # 그런 파일이 없다
        return "missing", None
    except PermissionError:  # 상위 폴더에 들어갈 권한이 없다 → '없음'과 구분해서 알려주는 게 이 함수의 핵심
        return "denied", None
    except OSError:
        return "error", None
    if stat.S_ISREG(st.st_mode):
        return "file", st
    if stat.S_ISDIR(st.st_mode):
        return "dir", st
    return "other", st


def probe_file(path):
    """로그 파일을 읽을 수 있는지. ('ok', 크기) / ('missing'|'denied'|'error', None)"""
    # 로그 파일을 실제로 열어볼 수 있는지 미리 확인 (열 수 있으면 'ok' 와 파일 크기).
    state, st = stat_state(path)
    if state in ("dir", "other"):
        return "missing", None          # 파일이 아니면 로그로 볼 수 없으니 '없음'으로 본다
    if state != "file":
        return state, None
    try:
        with open(path, "rb"):
            pass
    except PermissionError:
        return "denied", None
    except OSError:
        return "error", None
    return "ok", st.st_size


# 파일 상태별로 화면에 보여줄 짧은 이름과 마우스를 올렸을 때 나오는 설명.
FILE_STATE_LABEL = {"missing": "파일 없음", "denied": "권한 없음", "error": "읽기 실패"}
FILE_STATE_HINT = {
    "missing": "그 파일이 없습니다",
    "denied": "이 대시보드는 myuser 계정으로 실행됩니다. 파일이나 그 위 폴더가 myuser 에게 열려 있지 않습니다",
    "error": "파일 상태를 확인하지 못했습니다",
}


def read_problem(path, state):
    """읽을 수 없는 이유를 화면에 보여 줄 문장으로."""
    # 로그를 못 읽었을 때 그 이유(없음/권한 없음/기타)를 화면에 보여줄 문장으로 바꿔줍니다.
    if state == "missing":
        return "파일이 아직 없습니다: " + path
    if state == "denied":
        return "권한이 없어 읽을 수 없습니다 (" + FILE_STATE_HINT["denied"] + "): " + path
    return "읽을 수 없습니다: " + path


def tail_file(path, n):
    """파일 끝에서 n줄을 최신순(아래→위)으로 돌려준다."""
    # 파일의 끝에서 n 줄을 최신순(아래→위)으로 가져옵니다. 파일 전체를 읽지 않고 끝부분만 읽어서 큰 로그도 빠릅니다.
    state, _size = probe_file(path)  # 먼저 읽을 수 있는 파일인지 확인
    if state != "ok":
        return None, read_problem(path, state)
    try:
        size = os.path.getsize(path)
        with open(path, "rb") as f:
            f.seek(max(0, size - min(size, LOG_TAIL_MAX_BYTES)))  # 파일 끝에서 최대 2MB 앞으로 이동해서 그 뒤만 읽는다
            data = f.read()
        lines = data.decode("utf-8", errors="replace").splitlines()
        lines = lines[-n:]  # 마지막 n 줄만 남기고
        lines.reverse()  # 최신 줄이 위로 오게 뒤집는다
        return lines, None
    except PermissionError:
        return None, "권한이 없어 읽을 수 없습니다: " + path
    except Exception as e:  # noqa
        return None, str(e)


def journal_tail(unit, n):
    # journalctl 로 서비스(systemd unit)의 최근 기록 n 줄을 가져옵니다.
    out, err = run_lines(["journalctl", "-u", unit, "-n", str(n), "-r", "--no-pager"])
    if out is None:
        return None, err or "내용 없음"
    return out.splitlines(), None


def is_active(unit):
    # systemctl is-active 로 서비스가 실행 중인지 확인 ("active" 이면 True).
    out, _ = run_lines(["systemctl", "is-active", unit])
    return (out or "").strip() == "active"


def fail2ban_bans():
    """[(jail, [ip,...]), ...] 또는 None"""
    # fail2ban 이 지금 차단 중인 IP 목록을 [(jail 이름, [IP, ...]), ...] 로 돌려줍니다.
    # fail2ban-client 는 root 권한이 필요해서 sudo -n(비밀번호 묻지 않음)으로 부르고, 이 명령만 허용하도록 sudo 규칙을 좁게 만들어 둡니다.
    # sudo -n : 비밀번호를 묻지 않는 sudo. 허용 안 된 명령이면 그냥 실패한다
    out, err = run_lines(["sudo", "-n", "fail2ban-client", "status"])
    if out is None:
        return None
    m = re.search(r"Jail list:\s*(.+)", out)  # 출력에서 'Jail list:' 뒤의 jail 이름 목록을 뽑는다
    if not m:
        return None
    jails = [j.strip() for j in m.group(1).split(",") if j.strip()]
    result = []
    for jail in jails:  # jail 마다 다시 조회해서
        jo, _ = run_lines(["sudo", "-n", "fail2ban-client", "status", jail])
        ips = []
        if jo:
            bm = re.search(r"Banned IP list:\s*(.*)", jo)  # 그 jail 이 차단 중인 IP 목록을 뽑는다
            if bm:
                ips = [x for x in bm.group(1).split() if x]
        result.append((jail, ips))
    return result


def fail2ban_unban(ip, jail=""):
    # 입력받은 IP 의 차단을 풀어주는 함수. 아무 글자나 명령에 끼워 넣지 못하도록 IP 형식부터 검사합니다.
    import ipaddress  # IP 형식 검사용 표준 라이브러리
    try:
        ipaddress.ip_address(ip)  # 올바른 IPv4/IPv6 주소가 아니면 여기서 오류가 난다
    except ValueError:  # 형식이 틀리면 명령에 넣지 않고 바로 거절
        return False, "올바른 IP 형식이 아닙니다."
    bans = fail2ban_bans()  # 지금 존재하는 jail 목록을 조회
    if bans is None:  # 조회 실패 (sudo 규칙이 없거나 fail2ban 이 꺼진 경우)
        return False, "fail2ban 조회 실패 (sudo 권한 확인 필요)"
    jails = [j for j, _ in bans]  # jail 이름만 뽑는다
    if jail:  # 특정 jail 을 골랐으면
        if jail not in jails:  # 목록에 없는 이름이면 거절 (임의 글자를 명령에 넣지 못하게)
            return False, "없는 jail 입니다."
        jails = [jail]  # 그 jail 하나만 대상으로
    done = False  # 한 곳이라도 풀었는지 표시
    for j in jails:  # 대상 jail 마다
        # fail2ban-client set <jail> unbanip <IP> 실행
        out, _ = run_lines(["sudo", "-n", "fail2ban-client", "set", j, "unbanip", ip])
        if out and "is not banned" not in out:  # 그 jail 에서 실제로 풀렸으면
            done = True  # 풀렸다고 표시
    if done:  # 한 곳이라도 풀었으면
        audit("unban", ip, "ok", jail or "all")  # 누가 어느 IP 를 풀었는지 기록
        return True, f"{ip} 차단 해제 완료"  # 성공 메시지
    return False, f"{ip} 는 어떤 jail 에도 차단되어 있지 않거나 해제에 실패했습니다."  # 아무 jail 에도 없었거나 실패


def top404(path, limit=20):
    # 접속 기록에서 404(없는 주소) 요청을 세어서 많이 두드린 주소 상위 N개를 보여줍니다. 자동 스캐너 탐지용.
    state, _size = probe_file(path)
    if state != "ok":
        return None, read_problem(path, state)
    counts = {}
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as f:
            for line in f:
                m = re.search(r'"[A-Z]+ (\S+)[^"]*"\s+404\s', line)
                if m:
                    counts[m.group(1)] = counts.get(m.group(1), 0) + 1
    except PermissionError:
        return None, "권한이 없어 읽을 수 없습니다: " + path
    if not counts:
        return ["(404 없음)"], None
    top = sorted(counts.items(), key=lambda x: x[1], reverse=True)[:limit]
    return [f"{c}{p}" for p, c in top], None


def security_checks(n):
    # "보안 점검" 탭이 실행하는 진단 모음. 항목마다 (이름, 힌트, 결과 줄들)을 모아서 돌려줍니다.
    # 전부 읽기만 하는 명령이며, 탭에 들어올 때마다 실제 서버에서 새로 실행합니다.
    items = []

    def add(key, label, hint, lines, err=None):  # 항목 하나를 결과 목록에 넣는 작은 도우미 (결과가 비었을 때 안내 문구도 채워줌)
        if lines is None:
            lines = ["(" + (err or "내용 없음") + ")"]
        elif not lines:
            lines = ["(해당 없음)"]
        items.append({"key": key, "label": label, "hint": hint, "lines": lines})

    out, err = run_lines(["last", "-a", "-n", str(n)])  # 1) 최근 SSH 로그인 성공 이력 (last)
    lines = None
    if out is not None:
        lines = [l for l in out.splitlines() if l.strip() and not re.match(r"^(wtmp|btmp) begins", l.strip())]
    add("ssh_success", f"SSH 로그인 성공 이력 (최근 {n}건)",
        "낯선 IP나 본인이 접속하지 않은 시간대가 있으면 침해 의심 신호입니다.", lines, err)

    out, err = run_lines(["sudo", "-n", "lastb", "-n", str(n)])  # 2) 로그인 실패 이력 (lastb, sudo 필요)
    lines = None
    if out is not None:
        lines = [l for l in out.splitlines() if l.strip() and not re.match(r"^(wtmp|btmp) begins", l.strip())]
    add("ssh_failed", f"SSH 로그인 실패 이력 (최근 {n}건)",
        "반복 실패는 흔한 브루트포스 시도입니다. fail2ban 이 차단 중이면 큰 문제는 아닙니다.", lines, err)

    fl, ferr = tail_file("/var/log/fail2ban.log", 100000)  # 3) fail2ban 로그에서 'Ban' 이 들어간 줄만 (최근 차단 이력)
    lines = None
    if fl is not None:
        lines = [l for l in fl if re.search(r"\bBan\b", l)][:n]
    add("fail2ban_bans", f"fail2ban 최근 차단 이력 (최근 {n}건)",
        "평소보다 차단 빈도가 급증했으면 스캔/공격이 진행 중이라는 신호입니다.", lines, ferr)

    tl, terr = top404("/var/log/nginx/blog-access.log", 20)  # 4) 블로그 404 상위 경로 (스캐너 탐지)
    add("blog_404_top", "블로그 404 상위 경로 (상위 20개)",
        "존재하지 않는 경로(/wp-admin, /.env 등)를 다수 두드리는 건 흔한 자동 스캐너입니다.", tl, terr)

    cmds = [  # 5) 나머지는 (키, 이름, 힌트, 실행할 명령) 표로 만들어 반복 실행
        ("crontabs", "등록된 크론잡", "본인이 등록하지 않은 항목이 있으면 확인하세요.",
         ["bash", "-c", "for u in root www-data myuser; do echo \"--- $u ---\"; sudo -n crontab -u $u -l 2>/dev/null || echo '(등록된 크론잡 없음)'; done"]),
        ("cron_dirs", "/etc/cron.d, /etc/cron.daily 목록", "낯선 파일이 있으면 확인하세요.",
         ["bash", "-c", "echo '--- /etc/cron.d ---'; ls -la /etc/cron.d/ 2>/dev/null; echo '--- /etc/cron.daily ---'; ls -la /etc/cron.daily/ 2>/dev/null"]),
        ("running_services", "실행 중인 systemd 서비스", "낯선 서비스가 있으면 확인하세요.",
         ["bash", "-c", f"systemctl list-units --type=service --state=running --no-legend --no-pager | head -n {n}"]),
        ("recent_files", "최근 7일 내 수정된 시스템 파일", "본인이 바꾸지 않은 파일이 있으면 침해 의심 신호입니다.",
         ["bash", "-c", f"find /etc /usr/local/bin /opt -mtime -7 -type f 2>/dev/null | head -n {n}"]),
        ("established_conn", "현재 연결 세션 (ESTABLISHED)", "낯선 IP와의 연결이 있으면 확인하세요.",
         ["bash", "-c", f"sudo -n ss -tnp state established 2>/dev/null | head -n {n}"]),
    ]
    for key, label, hint, cmd in cmds:  # 표의 명령을 하나씩 실행해서 결과를 담는다
        out, err = run_lines(cmd)
        lines = out.splitlines() if out else None
        add(key, label, hint, lines, err)

    return items


def read_uptime_load():
    # 서버가 켜진 지 얼마나 됐는지(가동 시간)와 최근 1/5/15분 평균 부하를 읽습니다.
    try:
        with open("/proc/uptime") as f:
            up = float(f.read().split()[0])
        days, rem = divmod(int(up), 86400)
        hours, rem = divmod(rem, 3600)
        mins = rem // 60
        up_txt = (f"{days}일 " if days else "") + f"{hours}시간 {mins}분"
        load = os.getloadavg()
        return up_txt, f"{load[0]:.2f} / {load[1]:.2f} / {load[2]:.2f}"
    except Exception:
        return "-", "-"


def disk_usage():
    # 루트(/) 디스크의 사용량과 전체 용량을 돌려줍니다.
    try:
        u = shutil.disk_usage("/")
        return u.used, u.total
    except Exception:
        return 0, 0


def human_size(n):
    # 바이트 숫자를 KB/MB/GB 처럼 사람이 읽기 좋게 바꿉니다.
    v = float(n)
    for unit in ["B", "KB", "MB", "GB", "TB"]:
        if v < 1024.0 or unit == "TB":
            return f"{v:.2f} {unit}" if unit != "B" else f"{int(v)} B"
        v /= 1024.0
    return f"{n} B"


# ----------------------------------------------------------------------------
# HTML 조각
# ----------------------------------------------------------------------------
def h(s):
    # 글자를 HTML 에 안전하게 넣기 위한 변환(< > & " 를 무해한 글자로). 화면에 값을 찍을 때는 거의 항상 이 함수를 거칩니다.
    return html.escape(str(s), quote=True)


# 로그 줄에서 색을 칠할 단어 패턴 3종: 보안(ban/blocked/attack 등), 경고(warn), 오류(error/fail 등).
# (단어 경계 \b 를 써서 다른 단어 속 글자에는 반응하지 않게 했습니다.)
HL_PATTERNS = [
    (re.compile(r"\b(ban(?:ned)?|block(?:ed)?|attack|intrusion|unauthorized|suspicious|deny|denied|refused)\b", re.I), "log-hl-security"),
    (re.compile(r"\b(warn(?:ing)?)\b", re.I), "log-hl-warn"),
    (re.compile(r"\b(error|err|fail(?:ed)?|critical|fatal|exception|panic|timed out|timeout)\b", re.I), "log-hl-error"),
]


def highlight_line(line):
    # 로그 한 줄 안에서 위험/경고/오류 단어를 찾아 색을 입힙니다.
    esc = h(line)
    for pat, cls in HL_PATTERNS:
        esc = pat.sub(lambda m, c=cls: f'<span class="{c}">{m.group(0)}</span>', esc)
    return esc


def log_box(lines, big=True):
    # 로그 줄들을 검은 상자(<pre>) 안에 색칠해서 보여주는 HTML 을 만듭니다.
    cls = "log-box log-box-lg" if big else "log-box log-box-sm"
    return f'<pre class="{cls}">' + "\n".join(highlight_line(l) for l in lines) + "</pre>"


# 아이콘들은 작은 SVG 그림을 문자열로 넣어둔 것입니다. 별도 이미지 파일이 필요 없습니다.
ICON_FILE = ('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" '
             'stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>'
             '<polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line>'
             '<line x1="16" y1="17" x2="8" y2="17"></line></svg>')

ICON_SVC = ('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" '
            'stroke-linejoin="round"><rect x="2" y="3" width="20" height="8" rx="2"></rect><rect x="2" y="13" width="20" height="8" rx="2"></rect>'
            '<line x1="6" y1="7" x2="6.01" y2="7"></line><line x1="6" y1="17" x2="6.01" y2="17"></line></svg>')
ICON_SHIELD = ('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" '
               'stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>')
ICON_DISK = ('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" '
             'stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"></ellipse><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>'
             '<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path></svg>')
ICON_CLOCK = ('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" '
              'stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>')

ICON_FOLDER = ('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" '
               'stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>')

# 대시보드 바로가기: (이름, 이동 주소, 색, 아이콘) — 4개씩 한 줄. 바꾸고 싶으면 이 목록만 고치면 된다.
SHORTCUTS = [
    ("블로그 폴더 열기",     "?tab=files&fpath=/home/myuser/blog.snsoz.com", "badge-blue",   ICON_FOLDER),
    ("Hugo 빌드 로그",       "?tab=svclogs&logkey=hugo_build",               "badge-green",  ICON_FILE),
    ("Nginx 에러 로그",      "?tab=svclogs&logkey=nginx_error",              "badge-amber",  ICON_FILE),
    ("보안 점검",            "?tab=seccheck",                                "badge-purple", ICON_FILE),
    ("Nginx 접속 로그",      "?tab=svclogs&logkey=nginx_access",             "badge-sky",    ICON_FILE),
    ("블로그 전용 접속 로그", "?tab=svclogs&logkey=blog_access",              "badge-green",  ICON_FILE),
    ("fail2ban 로그",        "?tab=svclogs&logkey=fail2ban_log",             "badge-purple", ICON_SHIELD),
    ("인증 로그 (SSH 등)",   "?tab=svclogs&logkey=auth_log",                 "badge-amber",  ICON_FILE),
    ("차단 상태 · 해제",     "?tab=svclogs",                                 "badge-purple", ICON_SHIELD),
    ("SSL 인증서 로그",      "?tab=svclogs&logkey=letsencrypt",              "badge-green",  ICON_FILE),
    ("Nginx 설정 폴더",      "?tab=files&fpath=/etc/nginx",                  "badge-blue",   ICON_FOLDER),
    ("스크립트 폴더",        "?tab=files&fpath=/usr/local/bin",              "badge-sky",    ICON_FOLDER),
]

# 왼쪽 메뉴 구성: (그룹 이름, [(탭 키, 화면에 보일 이름), ...]).
TABS = [
    ("개요", [("dashboard", "대시보드")]),
    ("시스템", [("files", "파일 탐색기"), ("svclogs", "서버 로그"), ("seccheck", "보안 점검")]),
]
# 탭 키 → 이름 사전. 화면 제목과 "이 탭이 실제로 있는 탭인지" 확인에 씁니다.
TAB_LABEL = {"dashboard": "대시보드", "files": "파일 탐색기", "svclogs": "서버 로그", "seccheck": "보안 점검"}

# ----------------------------------------------------------------------------
# 화면 페이지 조립
# ----------------------------------------------------------------------------
# hugodash.css 뒤에 덧붙이는 휴고서버 전용 규칙.
# 바로가기 박스: 화면이 넓어져 가로가 커지면 세로도 같은 비율(3:1)로 커진다. 글자·아이콘도 같이 커진다.
# 기본 스타일시트 뒤에 덧붙이는 이 대시보드 전용 CSS (바로가기 박스 크기 규칙).
EXTRA_CSS = """
.shortcut-grid { gap: 14px; }
.shortcut-grid .quick-btn {
    aspect-ratio: 3 / 1; justify-content: center; text-align: center;
    padding: 8px; gap: clamp(7px, 0.6vw, 14px);
    font-size: clamp(12px, 0.95vw, 18px);
}
.shortcut-grid .quick-btn .icon-badge {
    width: clamp(34px, 2.6vw, 64px); height: clamp(34px, 2.6vw, 64px);
    border-radius: clamp(10px, 0.8vw, 18px);
}
.shortcut-grid .quick-btn .icon-badge svg { width: 46%; height: 46%; }
"""


def load_css():
    # 같은 폴더의 hugodash.css 를 읽고 이 프로그램 전용 규칙(EXTRA_CSS)을 덧붙입니다. 파일이 없으면 빈 스타일로 계속 동작합니다.
    p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hugodash.css")
    try:
        with open(p, "r", encoding="utf-8") as f:
            return f.read() + EXTRA_CSS
    except Exception:
        return ""


def load_js():
    # 같은 폴더의 hugodash.js(확인 창 등 화면 동작 코드)를 읽습니다. 없으면 빈 값.
    p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hugodash.js")
    try:
        with open(p, "r", encoding="utf-8") as f:
            return f.read()
    except Exception:
        return ""


def build_notifications():
    """상단 종 아이콘에 보여줄 알림 목록."""
    # 오른쪽 위 종(벨) 아이콘에 보여줄 알림을 만듭니다: 차단 중인 IP 와 멈춘 서비스.
    notes = []
    bans = fail2ban_bans()
    ban_total = 0
    if bans:
        for jail, ips in bans:
            if ips:
                ban_total += len(ips)
                notes.append(("fail2ban", f"{jail}{len(ips)}개", ", ".join(ips[:5])))
    down = [label for unit, label in SERVICE_UNITS if not is_active(unit)]
    for label in down:
        notes.insert(0, ("service", f"{label} 서비스 중단", "systemctl 로 상태를 확인하세요."))
    return notes, ban_total, bans


def badge_text(n):
    """벨 아이콘의 숫자 표시. 99 를 넘으면 99+."""
    # 종 아이콘 숫자에 쓰는 글자. 99 가 넘으면 "99+".
    return "99+" if n > 99 else str(n)


def page_dashboard(bans, ban_total):
    # "대시보드" 탭의 화면(HTML)을 만듭니다. 서비스 상태·차단 IP 수·디스크·가동 시간 카드와 바로가기 버튼으로 구성됩니다.
    states = [(label, is_active(unit)) for unit, label in SERVICE_UNITS]
    up_count = sum(1 for _, a in states if a)
    up_txt, load_txt = read_uptime_load()
    used, total = disk_usage()
    pct = int(used * 100 / total) if total else 0

    svc_rows = "".join(
        f'<div class="log-mini-row"><span>{h(l)}</span>'
        f'<span class="status-dot {"status-ok" if a else "status-bad"}"><span class="d {"on" if a else "off"}"></span>{"정상" if a else "중단"}</span></div>'
        for l, a in states
    )

    if bans is None:
        ban_body = '<div class="small">fail2ban 상태를 불러오지 못했습니다 (sudo 권한 확인 필요).</div>'
    elif ban_total == 0:
        ban_body = '<div class="small">지금 차단 중인 IP가 없습니다.</div>'
    else:
        rows = "".join(
            f'<tr><td><b>{h(j)}</b></td><td style="text-align:right;">{len(ips)}</td>'
            f'<td class="small mono">{h(", ".join(ips[:8]))}{" …" if len(ips) > 8 else ""}</td></tr>'
            for j, ips in bans if ips
        )
        ban_body = (f'<table><thead><tr><th>Jail</th><th style="white-space:nowrap; text-align:right;">차단 수</th><th>IP (일부)</th></tr></thead>'
                    f'<tbody>{rows}</tbody></table>')

    shortcut_html = "".join(
        f'<a class="quick-btn" style="text-decoration:none;" href="{h(href)}">'
        f'<span class="icon-badge {badge}">{icon}</span>{h(label)}</a>'
        for label, href, badge, icon in SHORTCUTS
    )

    return f'''
<div class="grid stat-row">
  <div class="stat-card">
    <div class="icon-row"><span class="icon-badge badge-green">{ICON_SVC}</span>
      <div><div class="label">서비스 상태</div><div class="value">{up_count}/{len(states)}</div></div></div>
    <div class="foot">{"<b>전체 정상</b>" if up_count == len(states) else '<span class="bad">일부 중단됨</span>'}</div>
  </div>
  <div class="stat-card">
    <div class="icon-row"><span class="icon-badge badge-purple">{ICON_SHIELD}</span>
      <div><div class="label">차단 중 IP</div><div class="value">{ban_total}</div></div></div>
    <div class="foot">fail2ban 모든 jail 합계</div>
  </div>
  <div class="stat-card">
    <div class="icon-row"><span class="icon-badge badge-amber">{ICON_DISK}</span>
      <div><div class="label">디스크 사용량 (/)</div><div class="value">{pct}%</div></div></div>
    <div class="foot">{h(human_size(used))} / {h(human_size(total))}</div>
  </div>
  <div class="stat-card">
    <div class="icon-row"><span class="icon-badge badge-blue">{ICON_CLOCK}</span>
      <div><div class="label">가동 시간</div><div class="value" style="font-size:18px;">{h(up_txt)}</div></div></div>
    <div class="foot">부하 {h(load_txt)}</div>
  </div>
</div>

<div class="grid grid-2 section">
  <div class="card"><h2>서비스 상태</h2><div class="desc">systemctl is-active 실시간 조회</div>{svc_rows}</div>
  <div class="card"><h2>현재 차단(Ban) 요약</h2><div class="desc">자세한 목록과 차단 해제는 「서버 로그」 탭</div>{ban_body}</div>
</div>

<div class="card section"><h2>바로가기</h2>
  <div class="quick-grid shortcut-grid">{shortcut_html}</div>
</div>'''


def page_svclogs(qs, csrf, bans, flash_msg):
    # "서버 로그" 탭: 위쪽은 차단 IP 목록과 차단 해제 폼, 아래쪽은 로그 버튼 격자와 선택한 로그 내용입니다.
    # 읽을 수 없는 로그(파일 없음/권한 없음)는 버튼을 흐리게 하고 이유를 함께 보여줍니다.
    logkey = (qs.get("logkey", [""])[0] or "")
    try:
        n = int(qs.get("loglines", ["200"])[0])
    except ValueError:
        n = 200
    if n not in LINE_CHOICES:
        n = 200

    # 차단 상태
    total = sum(len(ips) for _, ips in bans) if bans else 0
    jail_opts = "".join(f'<option value="{h(j)}">{h(j)}</option>' for j, _ in (bans or []))
    if bans is None:
        ban_html = '<div class="small">fail2ban 상태를 불러오지 못했습니다.</div>'
    elif total == 0:
        ban_html = '<div class="small">지금 차단 중인 IP가 없습니다.</div>'
    else:
        rows = "".join(
            f'<tr><td><b>{h(j)}</b></td><td style="text-align:right;">{len(ips)}</td>'
            f'<td class="small mono">{h(", ".join(ips))}</td></tr>'
            for j, ips in bans if ips
        )
        ban_html = (f'<details><summary style="cursor:pointer; font-weight:700; font-size:13.5px;">'
                    f'현재 <span style="color:var(--bad);">{total}개 IP</span> 차단 중 — 클릭해서 목록 보기</summary>'
                    f'<table style="margin-top:10px;"><thead><tr><th>Jail</th><th style="white-space:nowrap; text-align:right;">차단 수</th>'
                    f'<th>IP 목록</th></tr></thead><tbody>{rows}</tbody></table></details>')

    flash_html = f'<div class="alert success" style="margin-bottom:12px;">{h(flash_msg)}</div>' if flash_msg else ""

    # 로그 버튼: 하나의 격자 (한 줄 7개는 CSS .svclog-grid 에서 정함)
    btns = '<div class="quick-grid svclog-grid">'
    for key, label, typ, val, group in LOG_SOURCES:
        state = "ok"
        if typ == "file":
            state, fsize = probe_file(val)
            exists = (state == "ok")
            size_txt = human_size(fsize) if exists else FILE_STATE_LABEL[state]
        else:
            exists = True
            size_txt = "journalctl"
        sel = "outline:2px solid var(--accent);" if key == logkey else ""
        if exists:
            btns += (f'<a class="quick-btn" style="text-decoration:none; {sel}" '
                     f'href="?tab=svclogs&logkey={h(key)}&loglines={n}">'
                     f'<span class="icon-badge badge-blue">{ICON_FILE}</span>{h(label)}'
                     f'<span class="small">{h(size_txt)}</span></a>')
        else:
            btns += (f'<div class="quick-btn" style="opacity:.4; cursor:not-allowed;" title="{h(FILE_STATE_HINT[state])}">'
                     f'<span class="icon-badge badge-blue">{ICON_FILE}</span>{h(label)}'
                     f'<span class="small">{h(FILE_STATE_LABEL[state])}</span></div>')
    btns += "</div>"

    # 선택된 로그 내용
    body = '<div class="small section">위에서 로그를 선택하세요.</div>'
    if logkey:
        src = next((s for s in LOG_SOURCES if s[0] == logkey), None)
        if src is None:
            body = '<div class="alert error">알 수 없는 로그입니다.</div>'
        else:
            _, label, typ, val, _g = src
            if typ == "file":
                lines, err = tail_file(val, n)
                where = val
            else:
                lines, err = journal_tail(val, n)
                where = "journalctl -u " + val
            if lines is None:
                content = f'<pre class="log-box">{h(err)}</pre>'
                meta = h(where)
            else:
                content = log_box(lines) if lines else '<pre class="log-box">(내용 없음)</pre>'
                meta = h(where)
            sel_opts = "".join(f'<option value="{c}" {"selected" if c == n else ""}>{c}줄</option>' for c in LINE_CHOICES)
            body = f'''<div class="section">
  <div class="small" style="margin-bottom:10px;">
    <span class="mono">{meta}</span> &nbsp;·&nbsp;
    <form method="get" style="display:inline-flex; align-items:center; gap:6px;">
      <input type="hidden" name="tab" value="svclogs"><input type="hidden" name="logkey" value="{h(logkey)}">
      <label class="small" style="margin:0;">줄 수:</label>
      <select name="loglines" onchange="this.form.requestSubmit();" style="width:auto; margin:0; padding:4px 8px;">{sel_opts}</select>
    </form> &nbsp;·&nbsp;
    <a href="?tab=svclogs&logkey={h(logkey)}&loglines={n}">새로고침</a>
  </div>{content}</div>'''

    return f'''
<div class="card">
  <h2>현재 차단(Ban) 상태</h2>
  <div class="desc">fail2ban-client 실시간 조회 — 지금 이 순간 차단 중인 IP</div>
  {flash_html}
  <form method="post" action="/unban" autocomplete="off" style="margin-bottom:16px;" onsubmit="return appConfirm(this, '이 IP의 차단을 해제하시겠습니까?');">
    <input type="hidden" name="csrf" value="{h(csrf)}">
    <div class="grid grid-3">
      <div><label for="unban_ip">차단 해제할 IP</label><input type="text" id="unban_ip" name="unban_ip" placeholder="1.2.3.4"></div>
      <div><label for="unban_jail">Jail (비우면 전체에서 시도)</label>
        <select id="unban_jail" name="unban_jail"><option value="">전체</option>{jail_opts}</select></div>
      <div style="display:flex; align-items:flex-end;"><button type="submit" class="btn-blue" style="margin-bottom:12px;">차단 해제</button></div>
    </div>
  </form>
  {ban_html}
</div>

<div class="card section">
  <h2>서버 로그</h2>
  <div class="desc">휴고서버가 실제로 기록 중인 로그를 원클릭으로 조회 (읽기 전용, 파일당 최근 500줄까지)</div>
  {btns}
  {body}
</div>'''


def page_seccheck(qs):
    # "보안 점검" 탭: security_checks() 결과를 항목별 상자로 보여줍니다.
    try:
        n = int(qs.get("seclines", ["50"])[0])
    except ValueError:
        n = 50
    if n not in LINE_CHOICES:
        n = 50
    items = security_checks(n)
    sel_opts = "".join(f'<option value="{c}" {"selected" if c == n else ""}>{c}줄</option>' for c in LINE_CHOICES)
    blocks = "".join(
        f'<div class="section" style="border-top:1px solid var(--line); padding-top:14px;">'
        f'<div style="font-weight:800; font-size:14px; margin-bottom:2px;">{h(it["label"])}</div>'
        f'<div class="small" style="margin-bottom:8px;">⚑ {h(it["hint"])}</div>'
        f'{log_box(it["lines"])}</div>'
        for it in items
    )
    return f'''
<div class="card">
  <h2>보안 점검</h2>
  <div class="desc">탭에 들어올 때마다 실제 서버에서 진단 명령을 실행해 결과를 보여줍니다 (읽기 전용).
    각 항목의 힌트를 참고해서 낯선/이상한 게 있는지 눈으로 확인하세요.</div>
  <div class="inline-actions" style="margin-bottom:6px; align-items:center;">
    <form method="get" style="display:inline-flex; align-items:center; gap:6px;">
      <input type="hidden" name="tab" value="seccheck">
      <label class="small" style="margin:0;">항목별 줄 수:</label>
      <select name="seclines" onchange="this.form.requestSubmit();" style="width:auto; margin:0; padding:6px 10px;">{sel_opts}</select>
      <button type="submit" class="btn-blue">다시 실행</button>
    </form>
  </div>
  {blocks}
</div>'''


# 압축파일로 보고 빨간색으로 칠할 확장자들.
ARCHIVE_EXTS = (".zip", ".tar", ".gz", ".tgz", ".bz2", ".tbz2", ".xz", ".txz", ".zst", ".7z", ".rar", ".z", ".lz4")


def name_style(name):
    """파일 이름 색: 압축파일은 빨간색, .bak 백업 파일은 밝은 연두색. 그 밖에는 기본색."""
    # 파일 이름 색: 압축파일은 빨간색, .bak 백업 파일은 밝은 연두색. 그 밖에는 기본색.
    low = name.lower()
    if low.endswith(ARCHIVE_EXTS):
        return ' style="color:#ef4444;"'
    if ".bak" in low:
        return ' style="color:#a3e635;"'
    return ""


def perm_badge(kind):
    # 파일 탐색기의 "접근" 표시: 읽기·쓰기 / 읽기 전용 / 🔒 접근 불가.
    if kind == "write":
        return '<span class="small" style="color:var(--ok); font-weight:700;">읽기·쓰기</span>'
    if kind == "read":
        return '<span class="small" style="color:var(--muted); font-weight:700;">읽기 전용</span>'
    return '<span class="small">🔒 접근 불가</span>'


def page_files(qs, csrf, flash_msg):
    # "파일 탐색기" 탭. 주소의 값에 따라 화면이 세 가지로 나뉩니다.
    #   fperm=경로 → 권한 변경 화면 / ffile=경로 → 파일 보기·편집 / 그 외 → 폴더 목록
    fpath = qs.get("fpath", [""])[0]
    ffile = qs.get("ffile", [""])[0]
    fperm = qs.get("fperm", [""])[0]  # 주소의 fperm 값이 있으면 권한 변경 화면

    quick = "".join(  # 위쪽 바로가기 버튼 HTML
        f'<a class="btn-gray" href="?tab=files&fpath={h(q["path"])}">{h(q["label"])}</a>' for q in QUICKLINKS
    )
    flash_html = f'<div class="alert success" style="margin-bottom:12px;">{h(flash_msg)}</div>' if flash_msg else ""

    # 이 탭 공통 머리말(설명 문구 + 바로가기)
    head = f'''<h2>파일 탐색기</h2>
<div class="desc">
  서버 전체 폴더 구조가 그대로 보입니다. 시스템 핵심 영역(계정정보, SSH, 크론/systemd, fail2ban, 인증서 개인키 등, 🔒 표시)은
  목록에는 나오되 열람·수정이 차단됩니다. <b>수정은 블로그 폴더만 가능</b>하고 나머지는 읽기 전용입니다.
  저장 시 원본은 자동 백업(.bak-타임스탬프)됩니다. 새 파일 생성/삭제는 불가 — 기존 파일 수정만 가능.<br>
  <b>소유·권한</b>은 목록에 표시되고, 블로그 폴더 안의 것은 <b>눌러서 권한을 바꿀 수 있습니다</b>(파일·폴더 하나씩, 폴더 안까지 한꺼번에 바꾸지는 않음. 주인·그룹은 바꾸지 않음).
</div>{flash_html}
<div class="inline-actions" style="margin-bottom:14px;">{quick}</div>'''

    # ---------- 소유·권한 변경 화면 ----------
    if fperm:  # 권한 변경 화면이면 page_perm 에 맡기고 끝
        return page_perm(head, fperm, csrf)

    # ---------- 파일 보기/편집 ----------
    if ffile:  # 파일 보기·편집 화면
        try:
            real = resolve(ffile)
        except ValueError:
            return f'<div class="card">{head}<div class="alert error">잘못된 경로입니다.</div></div>'
        kind = classify(real)  # 이 경로가 막힘/쓰기/읽기 중 무엇인지 서버 쪽에서 판정
        parent = os.path.dirname(real) or "/"
        back = f'<a href="?tab=files&fpath={h(parent)}">‹ 목록으로</a> &nbsp;·&nbsp; <span class="mono">{h(real)}</span> &nbsp;·&nbsp; {perm_badge(kind)}'
        if kind == "deny":
            return f'<div class="card">{head}<div class="small" style="margin-bottom:10px;">{back}</div><div class="alert error">이 파일은 시스템 영역이라 열 수 없습니다.</div></div>'
        fstate, _fst = stat_state(real)
        if fstate != "file":
            if fstate == "denied":
                msg = "권한이 없어 열 수 없습니다. 이 대시보드는 myuser 계정으로 실행되는데, 이 파일이나 그 위 폴더가 myuser 에게 열려 있지 않습니다."
            elif fstate == "error":
                msg = "파일 상태를 확인하지 못했습니다."
            else:
                msg = "파일을 찾을 수 없습니다."
            return f'<div class="card">{head}<div class="small" style="margin-bottom:10px;">{back}</div><div class="alert error">{h(msg)}</div></div>'
        size = os.path.getsize(real)
        if size > MAX_VIEW_BYTES:
            return f'<div class="card">{head}<div class="small" style="margin-bottom:10px;">{back}</div><div class="alert error">파일이 너무 커서({h(human_size(size))}) 여기서 볼 수 없습니다.</div></div>'
        try:
            with open(real, "r", encoding="utf-8", errors="replace") as f:
                content = f.read()
        except PermissionError:
            return f'<div class="card">{head}<div class="small" style="margin-bottom:10px;">{back}</div><div class="alert error">권한이 없어 읽을 수 없습니다.</div></div>'

        # 저장 버튼을 보여줄 조건: 블로그 폴더 안 + 3MB 이하 + 실제 쓰기 권한 있음
        can_edit = (kind == "write") and size <= MAX_EDIT_BYTES and os.access(real, os.W_OK)
        notes = ""
        if kind == "read":
            notes = '<div class="alert error" style="margin-bottom:10px;">읽기 전용 영역입니다 (수정/저장 불가).</div>'
        elif size > MAX_EDIT_BYTES:
            notes = '<div class="alert error" style="margin-bottom:10px;">파일이 3MB보다 커서 미리보기만 가능합니다 (수정/저장 불가).</div>'
        elif not os.access(real, os.W_OK):
            notes = '<div class="alert error" style="margin-bottom:10px;">이 파일은 현재 계정에 쓰기 권한이 없어 수정할 수 없습니다.</div>'

        ro = "" if can_edit else "readonly"
        save_btn = '<button type="submit" class="btn-blue">저장 (자동 백업 후 덮어쓰기)</button>' if can_edit else ""
        return f'''<div class="card">{head}
<div class="small" style="margin-bottom:10px;">{back}</div>{notes}
<form method="post" action="/file/write" autocomplete="off" id="fmEditForm" onsubmit="return appConfirm(this, '이 파일을 저장하시겠습니까? 저장 전 원본은 자동으로 백업됩니다.');">
  <input type="hidden" name="csrf" value="{h(csrf)}"><input type="hidden" name="path" value="{h(real)}">
  <textarea name="content" id="fmEditor" spellcheck="false" {ro} style="width:100%; min-height:480px; font-family:Consolas,Monaco,monospace; font-size:13px; padding:14px; border:1px solid #cfd7e3; border-radius:10px;">{h(content)}</textarea>
  <div class="inline-actions" style="margin-top:10px;">{save_btn}<span class="small">크기: {size} bytes</span></div>
</form></div>'''

    # ---------- 폴더 목록 ----------
    try:
        real = resolve(fpath or "/")
    except ValueError:
        return f'<div class="card">{head}<div class="alert error">잘못된 경로입니다.</div></div>'
    kind = classify(real)
    if kind == "deny":
        return f'<div class="card">{head}<div class="small" style="margin-bottom:10px;"><span class="mono">{h(real)}</span></div><div class="alert error">이 폴더는 시스템 영역이라 열 수 없습니다.</div></div>'
    dstate, _dst = stat_state(real)
    if dstate != "dir":
        if dstate == "denied":
            dmsg = "이 폴더에 들어갈 권한이 없습니다. 이 대시보드는 myuser 계정으로 실행되는데, 이 폴더나 그 위 폴더가 myuser 에게 열려 있지 않습니다."
        elif dstate == "error":
            dmsg = "폴더 상태를 확인하지 못했습니다."
        else:
            dmsg = "폴더를 찾을 수 없습니다."
        return f'<div class="card">{head}<div class="alert error">{h(dmsg)}</div></div>'

    entries = []  # 폴더 목록 화면: 폴더 안 항목들을 모은다
    try:
        with os.scandir(real) as it:
            for e in it:
                try:
                    full = os.path.join(real, e.name)
                    st = e.stat(follow_symlinks=False)
                    is_dir = e.is_dir(follow_symlinks=False)
                    # 심볼릭 링크는 실제 목적지 기준으로 판정한다 (링크로 벽을 넘지 못하게)
                    if e.is_symlink():
                        tgt_kind = classify(full)
                        is_dir_link = os.path.isdir(full)
                        is_dir = is_dir_link
                    else:
                        tgt_kind = classify(full)
                    entries.append({
                        "name": e.name, "is_dir": is_dir, "kind": tgt_kind,
                        "size": None if is_dir else st.st_size,
                        "mtime": datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
                        "full": full,
                        "is_link": e.is_symlink(),
                        "owner": uid_name(st.st_uid), "group": gid_name(st.st_gid),
                        "oct": mode_octal(st.st_mode), "rwx": mode_rwx(st.st_mode),
                    })
                except OSError:
                    continue
    except PermissionError:
        return f'<div class="card">{head}<div class="alert error">이 폴더를 읽을 권한이 없습니다.</div></div>'

    # 폴더 먼저, 그다음 이름순
    entries.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))

    up = os.path.dirname(real) if real != "/" else ""
    up_html = f' &nbsp;·&nbsp; <a href="?tab=files&fpath={h(up or "/")}">‹ 상위 폴더</a>' if real != "/" else ""
    rows = ""
    for e in entries:
        ok = e["kind"] != "deny"
        dim = "" if ok else ' style="opacity:.55;"'
        if not ok:
            name_html = f'🔒 <span title="시스템 영역 — 접근 차단됨">{h(e["name"])}</span>'
            size_html, time_html, perm_html = "—", "—", perm_badge("deny")
            owner_html, mode_html = "—", "—"
        else:
            if e["is_dir"]:
                name_html = f'📁 <a href="?tab=files&fpath={h(e["full"])}">{h(e["name"])}</a>'
                size_html, time_html = "", h(e["mtime"])
            else:
                name_html = f'📄 <a href="?tab=files&ffile={h(e["full"])}"{name_style(e["name"])}>{h(e["name"])}</a>'
                size_html, time_html = h(human_size(e["size"])), h(e["mtime"])
            perm_html = perm_badge(e["kind"])
            owner_txt = f'{h(e["owner"])}:{h(e["group"])}'
            mode_txt = f'{h(e["rwx"])} <span class="small">({h(e["oct"])})</span>'
            if e["is_link"]:
                owner_html = f'<span class="mono small" style="color:var(--muted);">{owner_txt}</span>'
                mode_html = '<span class="small" style="color:var(--muted);" title="링크(바로가기)는 바꾸지 않습니다">링크</span>'
            elif e["kind"] == "write":
                href = "?tab=files&fperm=" + quote(e["full"], safe="/")
                lk = 'style="text-decoration:none; color:inherit; border-bottom:1px dashed var(--muted);" title="눌러서 소유·권한 변경"'
                owner_html = f'<a class="mono small" href="{href}" {lk}>{owner_txt}</a>'
                mode_html = f'<a class="mono small" href="{href}" {lk}>{mode_txt}</a>'
            else:
                owner_html = f'<span class="mono small" style="color:var(--muted);">{owner_txt}</span>'
                mode_html = f'<span class="mono small" style="color:var(--muted);">{mode_txt}</span>'
        rows += (f'<tr{dim}><td>{name_html}</td><td>{size_html}</td><td class="small">{time_html}</td>'
                 f'<td>{owner_html}</td><td>{mode_html}</td><td>{perm_html}</td></tr>')

    table = ('<div class="small">빈 폴더입니다.</div>' if not entries else
             f'<table><thead><tr><th>이름</th><th>크기</th><th>수정시각</th><th>소유(주인:그룹)</th><th>권한</th><th>변경</th></tr></thead><tbody>{rows}</tbody></table>')
    return f'''<div class="card">{head}
<div class="small" style="margin-bottom:10px;"><span class="mono">{h(real)}</span> &nbsp;·&nbsp; {perm_badge(kind)}{up_html}</div>{table}</div>'''


def page_perm(head, raw, csrf):
    """소유·권한 변경 화면 (블로그 폴더 안의 파일·폴더 하나)."""
    # 권한 변경 화면: 읽기·쓰기·실행 체크박스를 보여주고 [적용]을 누르면 /file/perm 으로 보냅니다.
    # 바꿀 수 없는 대상이면 이유만 보여주고 폼은 그리지 않습니다.
    try:
        real = resolve(raw)
    except ValueError:
        return f'<div class="card">{head}<div class="alert error">잘못된 경로입니다.</div></div>'
    parent = os.path.dirname(real) or "/"
    back = (f'<a href="?tab=files&fpath={quote(parent, safe="/")}">‹ 목록으로</a> &nbsp;·&nbsp; '
            f'<span class="mono">{h(real)}</span>')

    def err(msg):
        return f'<div class="card">{head}<div class="small" style="margin-bottom:10px;">{back}</div><div class="alert error">{h(msg)}</div></div>'

    if classify(real) == "deny":
        return err("이 경로는 시스템 영역이라 열 수 없습니다.")
    try:
        st = os.lstat(real)
    except FileNotFoundError:
        return err("파일을 찾을 수 없습니다.")
    except PermissionError:
        return err("권한이 없어 확인할 수 없습니다. 이 파일이나 그 위 폴더가 myuser 에게 열려 있지 않습니다.")
    except OSError:
        return err("파일 상태를 확인하지 못했습니다.")
    reason = perm_block_reason(real, st)  # 바꿀 수 없는 대상이면 이유만 보여주고 끝
    if reason:
        return err(reason)

    is_dir = stat.S_ISDIR(st.st_mode)
    cur_owner, cur_group = uid_name(st.st_uid), gid_name(st.st_gid)
    m = st.st_mode & 0o777
    mine = (st.st_uid == os.geteuid())  # 이 프로그램을 실행 중인 계정이 주인인가?

    notes = ""
    if not mine:
        notes = (f'<div class="alert error" style="margin-bottom:10px;">이 {"폴더" if is_dir else "파일"}의 주인은 {h(cur_owner)} 입니다. '
                 '주인이 myuser 인 것만 바꿀 수 있습니다.</div>')

    def cb(name, on, disabled=False):  # 체크박스 하나를 그리는 작은 함수
        return (f'<input type="checkbox" name="{name}" value="1"{" checked" if on else ""}{" disabled" if disabled else ""}'
                ' style="width:18px; height:18px;">')

    def row(label, who, shift, other=False):  # '주인/같은 그룹/나머지' 한 줄(읽기·쓰기·실행 3칸)을 그리는 함수
        r, w, x = bool(m & (4 << shift)), bool(m & (2 << shift)), bool(m & (1 << shift))
        return (f'<tr><td>{label}</td><td style="text-align:center;">{cb("pm_" + who + "r", r)}</td>'
                f'<td style="text-align:center;">{cb("pm_" + who + "w", w, disabled=other)}</td>'
                f'<td style="text-align:center;">{cb("pm_" + who + "x", x)}</td></tr>')

    # 권한 표 전체
    grid = (f'<table style="width:auto; margin-bottom:12px;"><thead><tr><th></th><th>읽기</th><th>쓰기</th>'
            f'<th>{"들어가기" if is_dir else "실행"}</th></tr></thead><tbody>'
            + row(f'주인 ({h(cur_owner)})', "u", 6)
            + row(f'같은 그룹 ({h(cur_group)})', "g", 3)
            + row('나머지 모두 (쓰기는 줄 수 없음)', "o", 0, other=True)
            + '</tbody></table>')
    save_btn = '<button type="submit" class="btn-blue">적용</button>' if mine else ""
    form = f'''<form method="post" action="/file/perm" autocomplete="off" onsubmit="return appConfirm(this, '소유·권한을 바꾸시겠습니까?');">
  <input type="hidden" name="csrf" value="{h(csrf)}"><input type="hidden" name="path" value="{h(real)}">
  {grid}
  <div class="inline-actions">{save_btn}
    <button type="button" class="btn-gray" onclick="location.href='?tab=files&fpath={quote(parent, safe="/")}'">취소</button></div>
</form>'''
    kind_word = "폴더" if is_dir else "파일"
    return f'''<div class="card">{head}
<div class="small" style="margin-bottom:10px;">{back}</div>
<h3 style="margin:6px 0 4px;">소유·권한 변경 ({kind_word})</h3>
<div class="small" style="margin-bottom:12px;">지금: <span class="mono">{h(mode_rwx(st.st_mode))} ({h(mode_octal(st.st_mode))})</span>
 &nbsp;·&nbsp; 소유 <span class="mono">{h(cur_owner)}:{h(cur_group)}</span>
 {"<br>이 폴더 하나만 바뀌고, 안에 있는 파일·폴더는 그대로입니다." if is_dir else ""}</div>{notes}
{form}</div>'''


def render_page(qs, csrf, flash_msg=""):
    # 화면 전체를 조립하는 함수: 선택된 탭의 본문을 만들고, 왼쪽 메뉴·위쪽 바·알림 벨·확인 창을 감싸서 완성된 HTML 한 장을 돌려줍니다.
    tab = qs.get("tab", ["dashboard"])[0]  # 주소의 ?tab= 값으로 어떤 탭인지 정한다
    if tab not in TAB_LABEL:
        tab = "dashboard"

    notes, ban_total, bans = build_notifications()  # 종 아이콘용 알림을 먼저 계산

    if tab == "dashboard":  # 탭에 맞는 화면 조각(body)을 만든다
        body = page_dashboard(bans, ban_total)
    elif tab == "files":
        body = page_files(qs, csrf, flash_msg)
    elif tab == "svclogs":
        body = page_svclogs(qs, csrf, bans, flash_msg)
    else:
        body = page_seccheck(qs)

    nav = ""  # 왼쪽 메뉴 HTML
    for group, items in TABS:
        nav += f'<div class="nav-group"><div class="nav-label">{h(group)}</div>'
        for key, label in items:
            act = "active" if key == tab else ""
            nav += f'<a class="nav-item {act}" href="?tab={key}" style="text-decoration:none;"><span class="dot"></span>{h(label)}</a>'
        nav += "</div>"

    # 벨 숫자 = 차단된 IP 전체 + 멈춘 서비스 수
    notif_total = ban_total + sum(1 for n in notes if n[0] == "service")
    if notes:
        note_rows = "".join(
            f'<div style="padding:6px 10px; border-bottom:1px solid var(--line); font-size:12.5px;">'
            f'<div style="font-weight:700; color:{"#b42318" if k == "service" else "#b45309"};">{h(t)}</div>'
            f'<div class="small" style="margin-top:2px;">{h(d)}</div></div>'
            for k, t, d in notes
        )
        badge = (f'<span style="position:absolute; top:-4px; right:-4px; background:#dc2626; color:#fff; font-size:10px; '
                 f'font-weight:800; min-width:18px; height:16px; border-radius:8px; display:flex; align-items:center; '
                 f'justify-content:center; padding:0 3px;">{badge_text(notif_total)}</span>')
    else:
        note_rows = '<div class="small" style="padding:8px 10px;">알림 없음 — 이상 없음</div>'
        badge = ""

    now = datetime.now().strftime("%Y-%m-%d %H:%M")  # 위쪽 '터널 연결됨' 옆에 보여줄 현재 시각
    return f'''<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hugo Admin — {h(TAB_LABEL[tab])}</title>
<style>{load_css()}</style></head>
<body>
<div class="app">
  <aside class="sidebar">
    <div class="brand"><span class="logo">H</span>
      <span>Hugo Admin<div style="font-size:10px; font-weight:400; color:var(--muted);">v1.0.0</div></span></div>
    <nav>{nav}</nav>
  </aside>
  <main class="main">
    <div class="topbar">
      <div class="topbar-left">
        <div><div class="page-title">{h(TAB_LABEL[tab])}</div>
          <div class="sub">Access: <span class="mono">SSH 터널 전용</span></div></div>
        <div class="conn-pill"><span class="dot2"></span>터널 연결됨 · <span class="mono">127.0.0.1:{BIND_PORT}</span> · {now}</div>
      </div>
      <div class="topbar-right">
        <div class="user-menu">
          <button type="button" class="icon-btn" id="notifBtn" title="알림" style="position:relative; cursor:pointer;">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:17px; height:17px;"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg>{badge}
          </button>
          <div class="user-dropdown" id="notifDropdown" style="min-width:340px; max-height:420px; overflow:auto;">
            {note_rows}
            <div style="padding:8px 10px; border-top:1px solid var(--line); margin-top:4px;">
              <a href="?tab=svclogs" class="small">차단 상태·로그 전체 보기 ›</a></div>
          </div>
        </div>
        <button type="button" class="icon-btn" id="themeBtn" title="다크모드 전환" style="cursor:pointer;">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:17px; height:17px;"><circle cx="12" cy="12" r="5"></circle><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path></svg>
        </button>
        <div class="user-menu"><span class="user-pill"><span class="avatar">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:13px; height:13px;"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg></span>myuser</span></div>
      </div>
    </div>
    {body}
  </main>
</div>
<div id="appConfirmOverlay" style="display:none; position:fixed; inset:0; background:rgba(15,20,30,0.55); z-index:9999; align-items:center; justify-content:center; padding:20px;">
  <div style="background:var(--panel); color:var(--ink); border-radius:14px; padding:24px; max-width:400px; width:100%; box-shadow:0 24px 64px rgba(0,0,0,0.35); border:1px solid var(--line);">
    <div id="appConfirmMsg" style="margin-bottom:22px; font-size:14.5px; line-height:1.55; text-align:center;"></div>
    <div style="display:flex; gap:8px; justify-content:center;">
      <button type="button" class="btn-gray" onclick="appConfirmCancel()">취소</button>
      <button type="button" class="btn-red" onclick="appConfirmOk()">확인</button>
    </div>
  </div>
</div>
<script>{load_js()}</script>
</body></html>'''


# ----------------------------------------------------------------------------
# HTTP 서버
# ----------------------------------------------------------------------------
# 페이지 이동 때마다 바뀌지 않는 CSRF 토큰(프로세스가 살아있는 동안 유지).
# 터널 전용이라 외부에서 이 서버에 요청을 보낼 수 없지만, 브라우저가 다른 탭의
# 악성 페이지 요청을 대신 보내는 것(CSRF)은 막아야 하므로 토큰을 둔다.
# 서버가 켜질 때 한 번 만드는 무작위 비밀 문자열. 모든 POST 요청에 이 값이 같이 와야만 처리합니다.
# (다른 사이트가 내 브라우저를 시켜 몰래 요청을 보내는 CSRF 공격 방지)
CSRF_TOKEN = os.urandom(24).hex()


class Handler(BaseHTTPRequestHandler):
    server_version = "HugoDash/1.0"

    def log_message(self, fmt, *args):
        pass  # 접속 기록은 남기지 않는다 (터널 전용, 본인만 사용)

    def _send(self, code, body, ctype="text/html; charset=utf-8", headers=None):
        # 응답을 보내는 공통 함수. 보안 헤더(캐시 금지, iframe 금지 등)를 항상 같이 붙입니다.
        data = body.encode("utf-8") if isinstance(body, str) else body
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(data)))
        self.send_header("Cache-Control", "no-store")
        self.send_header("X-Frame-Options", "DENY")
        self.send_header("X-Content-Type-Options", "nosniff")
        self.send_header("Referrer-Policy", "no-referrer")
        for k, v in (headers or {}).items():
            self.send_header(k, v)
        self.end_headers()
        self.wfile.write(data)

    def _host_ok(self):
        """DNS 리바인딩 방어: Host 헤더가 127.0.0.1/localhost 일 때만 응답한다."""
        # 요청의 Host 가 127.0.0.1 또는 localhost 인지 확인합니다. 다른 이름으로 들어오는 요청(DNS 리바인딩 공격)은 거부합니다.
        host = (self.headers.get("Host") or "").split(":")[0].strip().lower()
        return host in ("127.0.0.1", "localhost")

    def _redirect(self, location):
        # 처리 뒤 다른 주소로 보내는 응답(303). 새로고침해도 같은 작업이 두 번 실행되지 않게 합니다.
        self._send(303, "", headers={"Location": location})

    def do_GET(self):
        # GET 요청(화면 보기) 처리. 주소는 "/" 하나뿐이고, 어떤 화면인지는 ?tab=... 같은 값으로 정합니다.
        if not self._host_ok():  # Host 가 127.0.0.1/localhost 가 아니면 거부 (DNS 리바인딩 방어)
            return self._send(403, "forbidden", "text/plain; charset=utf-8")
        parsed = urlparse(self.path)  # 주소를 경로와 ?뒤 값으로 분해
        if parsed.path != "/":  # 화면은 "/" 하나뿐. 다른 주소는 404
            return self._send(404, "not found", "text/plain; charset=utf-8")
        qs = parse_qs(parsed.query, keep_blank_values=True)  # ?tab=files&fpath=... 같은 값을 사전으로
        flash = qs.get("msg", [""])[0]  # 직전 작업 결과 메시지(✅/⚠️)가 있으면 꺼내 둔다
        try:  # 화면 만들기
            page = render_page(qs, CSRF_TOKEN, flash)  # HTML 한 장을 만든다
        # 만들다 오류가 나면
        except Exception as e:  # noqa
            page = f"<pre>내부 오류: {h(e)}</pre>"  # 오류 내용을 보여주고
            return self._send(500, page)  # 500 으로 응답
        self._send(200, page)  # 정상이면 200 과 함께 화면을 보낸다

    def do_POST(self):
        # POST 요청(저장·차단 해제·권한 변경 같은 "바꾸는 동작") 처리.
        # 먼저 Host 와 CSRF 토큰을 검사하고, 주소에 따라 /unban, /file/write, /file/perm 중 하나를 실행합니다.
        if not self._host_ok():  # Host 검사 (GET 과 동일)
            return self._send(403, "forbidden", "text/plain; charset=utf-8")
        try:  # 보낸 내용의 길이를 읽는다
            length = int(self.headers.get("Content-Length") or 0)  # 요청 본문 크기
        except ValueError:  # 숫자가 아니면 0 으로 취급
            length = 0
        if length > MAX_EDIT_BYTES * 2:  # 너무 큰 요청은 읽지 않고 거부 (메모리 보호)
            return self._send(413, "too large", "text/plain; charset=utf-8")
        # 본문을 읽고
        raw = self.rfile.read(length).decode("utf-8", errors="replace")
        form = parse_qs(raw, keep_blank_values=True)  # 이름=값 형태로 분해

        def f(name):  # 폼 값을 꺼내는 짧은 도우미 (없으면 빈 문자열)
            return (form.get(name, [""])[0])

        if f("csrf") != CSRF_TOKEN:  # 비밀 토큰이 다르면 거부 (CSRF 방어)
            return self._send(403, "CSRF 검증 실패", "text/plain; charset=utf-8")

        path = urlparse(self.path).path  # 어떤 동작인지는 주소로 구분

        if path == "/unban":  # ① 차단 해제
            # IP 와 jail 을 넘겨 해제하고
            ok, msg = fail2ban_unban(f("unban_ip").strip(), f("unban_jail").strip())
            # 결과 메시지를 붙여 화면으로 되돌린다
            return self._redirect("/?" + urlencode({"tab": "svclogs", "msg": ("✅ " if ok else "⚠️ ") + msg}))

        if path == "/file/write":  # ② 파일 저장
            target = f("path")  # 저장할 파일 경로
            content = f("content")  # 새 내용
            try:
                real = resolve(target)  # 진짜 경로로 정리
            except ValueError:
                return self._redirect("/?" + urlencode({"tab": "files", "msg": "⚠️ 잘못된 경로입니다."}))
            back = os.path.dirname(real) or "/"  # 끝나면 돌아갈 폴더

            def fail(msg):  # 실패 처리 도우미: 기록 + 메시지와 함께 되돌아가기
                audit("file_write", real, "fail", msg)
                return self._redirect("/?" + urlencode({"tab": "files", "fpath": back, "msg": "⚠️ " + msg}))

            if classify(real) != "write":  # 블로그 폴더 안이 아니면 저장 거부 (서버 쪽에서 다시 검사)
                return fail("이 파일은 수정할 수 없는 영역입니다.")
            wstate, _wst = stat_state(real)  # 파일 상태 확인
            if wstate == "denied":  # 폴더 권한 문제
                return fail("권한이 없어 저장할 수 없습니다 (이 파일이나 그 위 폴더가 myuser 계정에게 열려 있지 않음).")
            if wstate != "file":  # 없는 파일이면 거부 (새 파일 만들기는 없음)
                return fail("파일이 없습니다 (새 파일 생성은 불가).")
            if os.path.getsize(real) > MAX_EDIT_BYTES:  # 원본이 3MB 초과면 거부
                return fail("파일이 너무 커서 수정할 수 없습니다.")
            if len(content.encode("utf-8")) > MAX_EDIT_BYTES:  # 새 내용이 3MB 초과여도 거부
                return fail("내용이 너무 큽니다.")
            try:
                ts = datetime.now().strftime("%Y%m%d-%H%M%S")  # 백업 이름에 넣을 시각
                bak = f"{real}.bak-{ts}"  # 원본 옆에 .bak-시각 이름으로 백업
                shutil.copy2(real, bak)  # 저장하기 전에 원본을 먼저 복사해 둔다
                content = content.replace("\r\n", "\n")  # 윈도우식 줄바꿈을 리눅스식으로 통일
                with open(real, "w", encoding="utf-8") as fh:  # 실제 저장
                    fh.write(content)
            except PermissionError:  # 쓰기 권한이 없으면
                return fail("쓰기 권한이 없습니다.")
            # 그 밖의 실패
            except Exception as e:  # noqa
                return fail(f"저장 실패: {e}")
            audit("file_write", real, "ok", "backup=" + bak)  # 성공 기록 (어떤 백업을 만들었는지 포함)
            return self._redirect("/?" + urlencode({"tab": "files", "fpath": back, "msg": f"✅ 저장 완료 (백업: {os.path.basename(bak)})"}))

        if path == "/file/perm":  # ③ 권한 변경
            try:
                real = resolve(f("path"))  # 대상 경로를 진짜 경로로
            except ValueError:
                return self._redirect("/?" + urlencode({"tab": "files", "msg": "⚠️ 잘못된 경로입니다."}))
            # 링크(바로가기) 경로를 주면 링크가 가리키는 곳이 바뀌어 버리므로 링크는 받지 않는다.
            # 링크 경로가 오면 거부 (링크가 가리키는 다른 곳이 바뀌는 것 방지)
            if os.path.islink(os.path.normpath("/" + f("path").lstrip("/"))):
                audit("file_perm", real, "fail", "link_refused")
                return self._redirect("/?" + urlencode({"tab": "files", "fperm": real, "msg": "⚠️ 링크(바로가기)는 바꾸지 않습니다."}))
            digits = []  # 체크박스 → 권한 숫자 만들기
            for who in ("u", "g", "o"):  # 주인(u) / 그룹(g) / 나머지(o) 차례로
                # 읽기=4 + 쓰기=2 + 실행=1 을 더해 한 자리로 (예: 읽기+쓰기 = 6)
                digits.append(str((4 if f(f"pm_{who}r") else 0) + (2 if f(f"pm_{who}w") else 0) + (1 if f(f"pm_{who}x") else 0)))
            ok, msg = perm_apply(real, "".join(digits))  # 세 자리를 합쳐 (예: "640") 변경 실행
            return self._redirect("/?" + urlencode({"tab": "files", "fperm": real, "msg": ("✅ " if ok else "⚠️ ") + msg}))

        # 어느 주소에도 안 맞으면 404
        return self._send(404, "not found", "text/plain; charset=utf-8")


def main():
    # 프로그램 시작 지점: 바인딩 주소가 127.0.0.1 인지 다시 확인하고 서버를 켭니다.
    if BIND_HOST != "127.0.0.1":  # 누가 실수로 주소를 바꿔 외부에 공개하는 것을 마지막으로 막는 안전장치
        print("BIND_HOST 는 127.0.0.1 이어야 합니다.", file=sys.stderr)
        sys.exit(1)
    srv = ThreadingHTTPServer((BIND_HOST, BIND_PORT), Handler)  # 요청마다 별도 스레드로 처리하는 서버 (동시에 여러 요청이 와도 밀리지 않음)
    print(f"hugodash listening on {BIND_HOST}:{BIND_PORT}", flush=True)
    try:
        srv.serve_forever()  # 멈출 때까지 계속 요청을 기다린다
    except KeyboardInterrupt:
        pass


if __name__ == "__main__":
    main()

간단히 구조만 짚어보면:

  • do_GET / do_POST: 요청이 들어오면 화면을 보여줄지(GET), 저장·차단 해제·권한 변경 같은 “바꾸는 동작"을 할지(POST) 정해서 알맞은 함수로 넘겨주는 교통정리 역할을 합니다.
  • page_* 함수들: 탭마다 화면(HTML)을 만드는 함수들입니다. (page_dashboard, page_svclogs, page_seccheck, page_files, page_perm)
  • classify / resolve: 어떤 경로든 “막힘 / 수정 가능 / 읽기만"으로 분류하는 핵심 보안 함수입니다.
  • perm_apply: 권한을 바꾼 뒤 다시 읽어서 진짜 바뀌었는지 확인하는 함수입니다.
  • _로 시작하는 함수들: 다른 함수들이 공통으로 쓰는 내부 도우미 함수입니다.

AI의 도움으로 작성되었습니다.