#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
frpc Web 管理面板（单文件版）
- 仅依赖 Python3 标准库，适配 Armbian（N1 / arm64）
- 自动识别 frpc.toml（v0.52+ 新版）与 frpc.ini（旧版）两种配置格式
- 功能：登录认证、服务状态/启停/重启、代理增删改、服务端设置、
        备份/恢复、原始配置编辑、配置校验（frpc verify）

用法：
  python3 frpc-manager.py                # 启动面板（默认端口 8999）
  python3 frpc-manager.py set-password   # 设置/修改登录密码
"""

import os
import io
import re
import sys
import json
import time
import shutil
import hashlib
import hmac
import secrets
import threading
import subprocess
import configparser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse

APP_VERSION = '1.0.0'
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_FILE = os.path.join(BASE_DIR, 'panel-data.json')
BACKUP_DIR = os.path.join(BASE_DIR, 'backups')
MAX_BACKUPS = 20
SESSION_HOURS = 12
MAX_LOGIN_FAILS = 5
LOCKOUT_SECONDS = 900

SETTINGS = {}
SESSIONS = {}          # token -> 过期时间
LOGIN_FAILS = {}       # ip -> [失败次数, 锁定截止时间]
SESS_LOCK = threading.Lock()

# ---------------------------------------------------------------- 基础设置

DEFAULT_SETTINGS = {
    'port': 8999,
    'config_path': None,    # None = 自动检测
    'service_name': None,   # None = 自动检测
    'password_hash': None,
    'password_salt': None,
}


def load_settings():
    s = dict(DEFAULT_SETTINGS)
    try:
        with open(DATA_FILE, encoding='utf-8') as f:
            s.update(json.load(f))
    except Exception:
        pass
    return s


def save_settings(s):
    with open(DATA_FILE, 'w', encoding='utf-8') as f:
        json.dump(s, f, ensure_ascii=False, indent=2)


def run(cmd, timeout=15):
    """执行系统命令，返回 CompletedProcess（异常时返回伪对象）"""
    class _R:
        returncode = 1
        stdout = ''
        stderr = '命令执行失败'
    try:
        return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
    except Exception as e:
        r = _R()
        r.stderr = str(e)
        return r


# ---------------------------------------------------------------- 密码

def hash_password(pw, salt=None):
    salt = salt or secrets.token_hex(16)
    h = hashlib.pbkdf2_hmac('sha256', pw.encode('utf-8'), bytes.fromhex(salt), 60000)
    return salt, h.hex()


def verify_password(pw, salt, expected):
    if not salt or not expected:
        return False
    _, h = hash_password(pw, salt)
    return hmac.compare_digest(h, expected)


# ---------------------------------------------------------------- frpc 检测

CANDIDATE_CONFIG_PATHS = [
    '/etc/frp/frpc.toml', '/etc/frp/frpc.ini',
    '/usr/local/frp/frpc.toml', '/usr/local/frp/frpc.ini',
    '/opt/frp/frpc.toml', '/opt/frp/frpc.ini',
    '/root/frp/frpc.toml', '/root/frp/frpc.ini',
    '/usr/local/etc/frp/frpc.toml', '/usr/local/etc/frp/frpc.ini',
]

FRPC_BIN_CANDIDATES = ['/usr/local/bin/frpc', '/usr/bin/frpc', '/opt/frp/frpc', '/root/frp/frpc']


def find_frpc_bin():
    p = shutil.which('frpc')
    if p:
        return p
    for p in FRPC_BIN_CANDIDATES:
        if os.path.exists(p):
            return p
    return None


def get_frpc_version():
    p = find_frpc_bin()
    if not p:
        return None
    r = run([p, '-v'])
    m = re.search(r'(\d+)\.(\d+)\.(\d+)', (r.stdout or '') + (r.stderr or ''))
    return m.group(0) if m else None


def version_at_least(v, target):
    try:
        a = tuple(int(x) for x in v.split('.'))
        b = tuple(int(x) for x in target.split('.'))
        return a >= b
    except Exception:
        return False


def detect_config_path():
    for p in CANDIDATE_CONFIG_PATHS:
        if os.path.exists(p):
            return p
    v = get_frpc_version()
    if v and version_at_least(v, '0.52.0'):
        return '/etc/frp/frpc.toml'
    return '/etc/frp/frpc.ini'


def get_config_path():
    global SETTINGS
    if not SETTINGS.get('config_path'):
        SETTINGS['config_path'] = detect_config_path()
        try:
            save_settings(SETTINGS)
        except Exception:
            pass
    return SETTINGS['config_path']


def detect_service_name():
    for name in ('frpc',):
        if run(['systemctl', 'cat', name]).returncode == 0:
            return name
    r = run(['systemctl', 'list-unit-files', '--type=service', '--no-legend', '--no-pager'])
    for line in (r.stdout or '').splitlines():
        m = re.match(r'^(\S*frp\S*)\.service\b', line.strip())
        if m and 'frpc' in m.group(1).lower():
            return m.group(1)
    return 'frpc'


def get_service_name():
    global SETTINGS
    if not SETTINGS.get('service_name'):
        SETTINGS['service_name'] = detect_service_name()
        try:
            save_settings(SETTINGS)
        except Exception:
            pass
    return SETTINGS['service_name']


# ---------------------------------------------------------------- TOML 解析（轻量实现）

def _strip_toml_comment(s):
    """去掉字符串外部的 # 注释"""
    in_str = None
    i = 0
    while i < len(s):
        ch = s[i]
        if in_str:
            if ch == '\\':
                i += 1
            elif ch == in_str:
                in_str = None
        elif ch in ('"', "'"):
            in_str = ch
        elif ch == '#':
            return s[:i]
        i += 1
    return s


def _toml_value(s):
    s = _strip_toml_comment(s).strip()
    if s.startswith('"""'):
        if s.count('"""') >= 2:
            return s[3:s.rfind('"""')]
        return s.strip('"')
    if s.startswith('"'):
        try:
            return json.loads(s)
        except Exception:
            return s.strip('"')
    if s.startswith("'"):
        end = s.rfind("'")
        return s[1:end] if end > 0 else s.strip("'")
    if s.startswith('['):
        t = s
        try:
            return json.loads(t)
        except Exception:
            t = re.sub(r',\s*([\]}])', r'\1', t)
            t = ' '.join(t.split())
            try:
                return json.loads(t)
            except Exception:
                inner = t.strip('[]').strip()
                return [x.strip().strip('"\'') for x in inner.split(',') if x.strip()]
    if s == 'true':
        return True
    if s == 'false':
        return False
    try:
        return int(s)
    except ValueError:
        pass
    try:
        return float(s)
    except ValueError:
        pass
    return s


def _toml_nav_table(root, dotted):
    """按 [a.b] 表头定位/创建目标字典，遇到数组表取最后一个元素"""
    d = root
    for part in dotted.split('.'):
        if isinstance(d, list):
            if not d or not isinstance(d[-1], dict):
                d.append({})
            d = d[-1]
        cur = d.get(part)
        if isinstance(cur, dict):
            d = cur
        elif isinstance(cur, list):
            if not cur or not isinstance(cur[-1], dict):
                cur.append({})
            d = cur[-1]
        else:
            d[part] = {}
            d = d[part]
    if isinstance(d, list):
        if not d or not isinstance(d[-1], dict):
            d.append({})
        d = d[-1]
    return d


def _set_dotted(d, dotted, value):
    parts = dotted.split('.')
    for p in parts[:-1]:
        cur = d.get(p)
        if isinstance(cur, dict):
            d = cur
        elif isinstance(cur, list):
            d = cur[-1] if cur else d.setdefault(p, {})
        else:
            d = d.setdefault(p, {})
    d[parts[-1]] = value


def parse_toml(text):
    """解析 frpc 使用的 TOML 子集，返回嵌套 dict/list 结构"""
    root = {}
    current = root
    lines = text.splitlines()
    i = 0
    while i < len(lines):
        line = lines[i].strip()
        i += 1
        if not line or line.startswith('#'):
            continue
        if line.startswith('[['):
            end = line.find(']]')
            if end < 0:
                continue
            name = line[2:end].strip()
            d = {}
            lst = root.setdefault(name, [])
            if isinstance(lst, list):
                lst.append(d)
                current = d
            continue
        if line.startswith('['):
            end = line.find(']')
            if end < 0:
                continue
            name = line[1:end].strip()
            current = _toml_nav_table(root, name)
            continue
        if '=' in line:
            key, _, val = line.partition('=')
            key = key.strip().strip('"').strip("'")
            v = val.strip()
            # 多行字符串
            if v.startswith('"""') and v.count('"""') < 2:
                buf = [v[3:]]
                while i < len(lines):
                    l2 = lines[i]
                    i += 1
                    if '"""' in l2:
                        buf.append(l2[:l2.index('"""')])
                        break
                    buf.append(l2)
                value = '\n'.join(buf)
            else:
                value = _toml_value(v)
            if key:
                _set_dotted(current, key, value)
    return root


def flatten_dict(d, prefix=''):
    """嵌套 dict 展平为点号键名"""
    out = {}
    for k, v in d.items():
        key = prefix + str(k)
        if isinstance(v, dict):
            out.update(flatten_dict(v, key + '.'))
        else:
            out[key] = v
    return out


def toml_repr(v):
    if isinstance(v, bool):
        return 'true' if v else 'false'
    if isinstance(v, (int, float)):
        return str(v)
    if isinstance(v, list):
        return '[' + ', '.join(toml_repr(x) for x in v) + ']'
    return json.dumps(str(v), ensure_ascii=False)


GLOBAL_ORDER = ['serverAddr', 'serverPort', 'auth.method', 'auth.token', 'user',
                'transport.tls.enable']
PROXY_ORDER = ['name', 'type', 'localIP', 'localPort', 'remotePort',
               'customDomains', 'subdomain', 'secretKey', 'serverName']


def dump_toml(model):
    lines = []
    g = model.get('global') or {}
    for k in GLOBAL_ORDER:
        if k in g and g[k] is not None:
            lines.append('%s = %s' % (k, toml_repr(g[k])))
    for k in sorted(g):
        if k not in GLOBAL_ORDER and g[k] is not None:
            lines.append('%s = %s' % (k, toml_repr(g[k])))
    for p in model.get('proxies') or []:
        lines.append('')
        lines.append('[[proxies]]')
        ordered = [k for k in PROXY_ORDER if k in p] + [k for k in p if k not in PROXY_ORDER]
        for k in ordered:
            if p[k] is not None:
                lines.append('%s = %s' % (k, toml_repr(p[k])))
    for v in model.get('visitors') or []:
        lines.append('')
        lines.append('[[visitors]]')
        for k in sorted(v):
            lines.append('%s = %s' % (k, toml_repr(v[k])))
    return '\n'.join(lines) + '\n'


# ---------------------------------------------------------------- INI 解析

# 规范键名（UI 使用，TOML 命名） <-> INI 键名
INI_GLOBAL_TO_CANON = {
    'server_addr': 'serverAddr',
    'server_port': 'serverPort',
    'token': 'auth.token',
    'user': 'user',
    'tls_enable': 'transport.tls.enable',
    'authentication_method': 'auth.method',
}
CANON_TO_INI_GLOBAL = {v: k for k, v in INI_GLOBAL_TO_CANON.items()}

INI_PROXY_TO_CANON = {
    'type': 'type',
    'local_ip': 'localIP',
    'local_port': 'localPort',
    'remote_port': 'remotePort',
    'custom_domains': 'customDomains',
    'subdomain': 'subdomain',
    'sk': 'secretKey',
    'server_name': 'serverName',
}
CANON_TO_INI_PROXY = {v: k for k, v in INI_PROXY_TO_CANON.items()}

INI_LIST_KEYS = {'custom_domains', 'customDomains', 'locations'}


def coerce_ini_value(key, v):
    v = v.strip()
    if key in INI_LIST_KEYS:
        return [x.strip() for x in v.split(',') if x.strip()]
    if v.lower() == 'true':
        return True
    if v.lower() == 'false':
        return False
    if re.fullmatch(r'-?\d+', v):
        return int(v)
    return v


def ini_str(v):
    if isinstance(v, bool):
        return 'true' if v else 'false'
    if isinstance(v, list):
        return ', '.join(str(x) for x in v)
    return str(v)


def load_ini_model(path):
    cp = configparser.ConfigParser(interpolation=None, strict=False)
    cp.optionxform = str
    with open(path, encoding='utf-8', errors='replace') as f:
        text = f.read()
    cp.read_string(text.lstrip('\ufeff').lstrip('\ufffe'))
    model = {'global': {}, 'proxies': [], 'visitors': []}
    for sec in cp.sections():
        items = dict(cp.items(sec))
        if sec.strip().lower() == 'common':
            for k, v in items.items():
                ck = INI_GLOBAL_TO_CANON.get(k, k)
                model['global'][ck] = coerce_ini_value(ck, v)
        else:
            p = {'name': sec.strip()}
            for k, v in items.items():
                ck = INI_PROXY_TO_CANON.get(k, k)
                p[ck] = coerce_ini_value(ck, v)
            model['proxies'].append(p)
    return model


def dump_ini(model):
    cp = configparser.ConfigParser(interpolation=None)
    cp.optionxform = str
    cp.add_section('common')
    g = model.get('global') or {}
    for k in GLOBAL_ORDER:
        if k in g and g[k] is not None:
            cp.set('common', CANON_TO_INI_GLOBAL.get(k, k), ini_str(g[k]))
    for k in sorted(g):
        if k not in GLOBAL_ORDER and g[k] is not None:
            cp.set('common', k, ini_str(g[k]))
    for p in model.get('proxies') or []:
        name = str(p.get('name') or 'proxy').strip()
        cp.add_section(name)
        for k, v in p.items():
            if k == 'name':
                continue
            cp.set(name, CANON_TO_INI_PROXY.get(k, k), ini_str(v))
    buf = io.StringIO()
    cp.write(buf)
    return buf.getvalue()


# ---------------------------------------------------------------- 配置模型读写

def load_config_model(path):
    if path.endswith('.toml'):
        with open(path, encoding='utf-8', errors='replace') as f:
            root = parse_toml(f.read().lstrip('\ufeff'))
        proxies = root.pop('proxies', [])
        visitors = root.pop('visitors', [])
        if not isinstance(proxies, list):
            proxies = []
        if not isinstance(visitors, list):
            visitors = []
        return {
            'global': flatten_dict(root),
            'proxies': [flatten_dict(p) for p in proxies if isinstance(p, dict)],
            'visitors': [flatten_dict(v) for v in visitors if isinstance(v, dict)],
        }
    return load_ini_model(path)


def write_config_model(path, model):
    text = dump_toml(model) if path.endswith('.toml') else dump_ini(model)
    d = os.path.dirname(path)
    if d:
        os.makedirs(d, exist_ok=True)
    with open(path, 'w', encoding='utf-8') as f:
        f.write(text)


# ---------------------------------------------------------------- 校验

ALLOWED_PROXY_TYPES = {'tcp', 'udp', 'http', 'https', 'stcp', 'sudp', 'xtcp'}
KEY_RE = re.compile(r'^[A-Za-z0-9_.\-]{1,64}$')
NAME_RE = re.compile(r'^[^=\[\]\r\n]{1,100}$')


def is_port(v):
    if isinstance(v, bool):
        return False
    if isinstance(v, int):
        return 1 <= v <= 65535
    if isinstance(v, str) and re.fullmatch(r'\d+', v or ''):
        return 1 <= int(v) <= 65535
    return False


def check_scalar(v):
    return v is None or isinstance(v, (str, int, float, bool))


def validate_model(model):
    g = model.get('global') or {}
    if not isinstance(g, dict):
        raise ValueError('服务端配置格式错误')
    for k in g:
        if not KEY_RE.match(str(k)):
            raise ValueError('服务端参数名无效: %s' % k)
        v = g[k]
        if not (check_scalar(v) or (isinstance(v, list) and all(check_scalar(x) for x in v))):
            raise ValueError('服务端参数值无效: %s' % k)
    if g.get('serverPort') not in (None, '') and not is_port(g['serverPort']):
        raise ValueError('服务器端口无效')

    proxies = model.get('proxies') or []
    if not isinstance(proxies, list):
        raise ValueError('代理列表格式错误')
    names = set()
    for i, p in enumerate(proxies):
        if not isinstance(p, dict):
            raise ValueError('第 %d 个代理格式错误' % (i + 1))
        name = str(p.get('name', '')).strip()
        if not name or not NAME_RE.match(name):
            raise ValueError('代理名称无效（不能为空、不能包含 [ ] =）: %r' % name)
        if name.lower() == 'common':
            raise ValueError('代理名称不能为 common')
        if name in names:
            raise ValueError('代理名称重复: %s' % name)
        names.add(name)
        t = str(p.get('type', '')).strip()
        if t not in ALLOWED_PROXY_TYPES:
            raise ValueError('%s: 代理类型无效: %s' % (name, t))
        for k in p:
            if not KEY_RE.match(str(k)):
                raise ValueError('%s: 参数名无效: %s' % (name, k))
            v = p[k]
            if not (check_scalar(v) or (isinstance(v, list) and all(check_scalar(x) for x in v))):
                raise ValueError('%s: 参数值无效: %s' % (name, k))
        if not is_port(p.get('localPort')):
            raise ValueError('%s: 本地端口无效' % name)
        rp = p.get('remotePort')
        if rp not in (None, '') and not is_port(rp):
            raise ValueError('%s: 远程端口无效' % name)
    return True


# ---------------------------------------------------------------- 备份

BACKUP_NAME_RE = re.compile(r'^config-\d{8}-\d{6}\.(toml|ini)$')


def backup_config():
    path = get_config_path()
    if not os.path.exists(path):
        return None
    os.makedirs(BACKUP_DIR, exist_ok=True)
    ext = os.path.splitext(path)[1] or '.toml'
    name = time.strftime('config-%Y%m%d-%H%M%S') + ext
    shutil.copy2(path, os.path.join(BACKUP_DIR, name))
    files = sorted(os.listdir(BACKUP_DIR))
    while len(files) > MAX_BACKUPS:
        try:
            os.remove(os.path.join(BACKUP_DIR, files[0]))
        except OSError:
            break
        files.pop(0)
    return name


def list_backups():
    os.makedirs(BACKUP_DIR, exist_ok=True)
    out = []
    for f in sorted(os.listdir(BACKUP_DIR), reverse=True):
        if BACKUP_NAME_RE.match(f):
            fp = os.path.join(BACKUP_DIR, f)
            m = re.match(r'^config-(\d{8})-(\d{6})', f)
            ts = ''
            if m:
                ts = '%s-%s-%s %s:%s:%s' % (m.group(1)[:4], m.group(1)[4:6], m.group(1)[6:],
                                            m.group(2)[:2], m.group(2)[2:4], m.group(2)[4:])
            out.append({'file': f, 'time': ts, 'size': os.path.getsize(fp)})
    return out


def safe_backup_path(name):
    if not BACKUP_NAME_RE.match(str(name or '')):
        raise ValueError('备份文件名无效')
    return os.path.join(BACKUP_DIR, name)


# ---------------------------------------------------------------- 服务控制

def service_action(action):
    if action not in ('start', 'stop', 'restart'):
        raise ValueError('无效操作')
    name = get_service_name()
    r = run(['systemctl', action, name], timeout=60)
    ok = r.returncode == 0
    msg = ((r.stderr or '') + (r.stdout or '')).strip() or ('操作成功' if ok else '操作失败')
    return ok, msg


def service_state():
    name = get_service_name()
    r = run(['systemctl', 'is-active', name])
    s = (r.stdout or '').strip().lower()
    if s in ('active', 'inactive', 'failed', 'activating', 'deactivating'):
        return s
    return 'unknown'


def service_logs(n=150):
    name = get_service_name()
    r = run(['journalctl', '-u', name, '-n', str(n), '--no-pager'], timeout=20)
    return (r.stdout or r.stderr or '').strip()


def verify_config():
    p = find_frpc_bin()
    if not p:
        return None, '未找到 frpc 程序，无法校验'
    path = get_config_path()
    if not os.path.exists(path):
        return False, '配置文件不存在'
    r = run([p, 'verify', '-c', path], timeout=20)
    out = ((r.stdout or '') + (r.stderr or '')).strip()
    if r.returncode == 0:
        return True, out or '语法检查通过'
    return False, out or '校验失败'


# ---------------------------------------------------------------- API 实现

def api_status():
    path = get_config_path()
    exists = os.path.exists(path)
    proxy_count = 0
    if exists:
        try:
            proxy_count = len(load_config_model(path).get('proxies', []))
        except Exception:
            proxy_count = -1
    return {
        'state': service_state(),
        'service_name': get_service_name(),
        'frpc_version': get_frpc_version(),
        'frpc_bin': find_frpc_bin(),
        'config_path': path,
        'config_exists': exists,
        'format': 'toml' if path.endswith('.toml') else 'ini',
        'proxy_count': proxy_count,
        'panel_version': APP_VERSION,
    }


def api_get_config():
    path = get_config_path()
    if os.path.exists(path):
        model = load_config_model(path)
    else:
        model = {'global': {'serverAddr': '', 'serverPort': 7000, 'auth.token': ''},
                 'proxies': [], 'visitors': []}
    return {
        'global': model['global'],
        'proxies': model['proxies'],
        'visitors_count': len(model.get('visitors', [])),
        'format': 'toml' if path.endswith('.toml') else 'ini',
        'config_path': path,
        'exists': os.path.exists(path),
    }


def api_save_config(body):
    model = {'global': body.get('global') or {}, 'proxies': body.get('proxies') or []}
    validate_model(model)
    warnings = []

    # 端口冲突提醒
    seen = {}
    for p in model['proxies']:
        rp = p.get('remotePort')
        if rp:
            seen.setdefault((p.get('type'), rp), []).append(p['name'])
    for (t, rp), names in seen.items():
        if len(names) > 1:
            warnings.append('端口 %s 被多个代理使用: %s' % (rp, ', '.join(names)))
    for p in model['proxies']:
        if p['type'] in ('http', 'https') and not p.get('customDomains') and not p.get('subdomain'):
            warnings.append('%s: http/https 代理建议绑定域名或子域名' % p['name'])

    path = get_config_path()
    # 保留 visitors（面板不编辑，保存时不丢失）
    model['visitors'] = []
    if os.path.exists(path):
        try:
            model['visitors'] = load_config_model(path).get('visitors', [])
        except Exception:
            model['visitors'] = []

    bname = backup_config()
    write_config_model(path, model)

    restarted = False
    if body.get('restart'):
        ok, msg = service_action('restart')
        restarted = ok
        if not ok:
            warnings.append('重启 frpc 失败: %s' % msg)

    try:
        vok, vmsg = verify_config()
        ver = {'ok': vok, 'msg': vmsg}
    except Exception as e:
        ver = {'ok': None, 'msg': str(e)}

    return {'ok': True, 'backup': bname, 'restarted': restarted,
            'warnings': warnings, 'verify': ver}


def api_save_raw(body):
    text = str(body.get('text', '')).lstrip('\ufeff')
    path = get_config_path()
    # 先做解析校验，防止写入坏配置
    if path.endswith('.toml'):
        parse_toml(text)
    else:
        cp = configparser.ConfigParser(interpolation=None, strict=False)
        cp.optionxform = str
        cp.read_string(text.lstrip('\ufeff'))
    bname = backup_config()
    d = os.path.dirname(path)
    if d:
        os.makedirs(d, exist_ok=True)
    with open(path, 'w', encoding='utf-8') as f:
        f.write(text)
    restarted = False
    if body.get('restart'):
        ok, _ = service_action('restart')
        restarted = ok
    return {'ok': True, 'backup': bname, 'restarted': restarted}


def api_restore(body):
    src = safe_backup_path(body.get('file'))
    if not os.path.exists(src):
        raise ValueError('备份文件不存在')
    backup_config()
    shutil.copy2(src, get_config_path())
    restarted = False
    if body.get('restart'):
        ok, _ = service_action('restart')
        restarted = ok
    return {'ok': True, 'restarted': restarted}


def api_backup_delete(body):
    src = safe_backup_path(body.get('file'))
    os.remove(src)
    return {'ok': True}


def api_save_settings(body):
    global SETTINGS
    port_changed = False
    if body.get('port') is not None:
        p = int(body['port'])
        if not (1 <= p <= 65535):
            raise ValueError('端口无效')
        if p != SETTINGS.get('port'):
            SETTINGS['port'] = p
            port_changed = True
    if body.get('config_path'):
        SETTINGS['config_path'] = str(body['config_path']).strip()
    if body.get('service_name'):
        SETTINGS['service_name'] = str(body['service_name']).strip()
    save_settings(SETTINGS)
    note = '面板端口已修改，需重启面板生效: systemctl restart frpc-manager' if port_changed else ''
    return {'ok': True, 'note': note}


def api_change_password(body):
    old = str(body.get('old', ''))
    new = str(body.get('new', ''))
    if len(new) < 8:
        raise ValueError('新密码至少 8 位')
    s = load_settings()
    if not verify_password(old, s.get('password_salt'), s.get('password_hash')):
        raise ValueError('原密码错误')
    salt, h = hash_password(new)
    s['password_hash'] = h
    s['password_salt'] = salt
    save_settings(s)
    # 修改密码后强制所有会话失效
    with SESS_LOCK:
        SESSIONS.clear()
    return {'ok': True}


def safe_settings():
    return {
        'port': SETTINGS.get('port'),
        'config_path': SETTINGS.get('config_path') or get_config_path(),
        'service_name': SETTINGS.get('service_name') or get_service_name(),
        'has_password': bool(load_settings().get('password_hash')),
    }


# ---------------------------------------------------------------- HTTP 服务

class Handler(BaseHTTPRequestHandler):
    server_version = 'frpc-panel'

    def log_message(self, fmt, *args):
        pass

    # ---- 工具方法
    def _send(self, code, body, ctype='application/json; charset=utf-8', headers=None):
        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-Content-Type-Options', 'nosniff')
        for k, v in (headers or []):
            self.send_header(k, v)
        self.end_headers()
        self.wfile.write(data)

    def _json(self, code, obj, headers=None):
        self._send(code, json.dumps(obj, ensure_ascii=False), headers=headers)

    def _read_json(self):
        n = int(self.headers.get('Content-Length') or 0)
        if n > 1024 * 1024:
            raise ValueError('请求体过大')
        raw = self.rfile.read(n) if n else b''
        return json.loads(raw or b'{}')

    def _token(self):
        c = self.headers.get('Cookie') or ''
        for part in c.split(';'):
            part = part.strip()
            if part.startswith('fp_session='):
                tok = part.split('=', 1)[1]
                exp = SESSIONS.get(tok)
                if exp and exp > time.time():
                    return tok
        return None

    def _require_auth(self):
        if self._token():
            return True
        self._json(401, {'error': '未登录或会话已过期'})
        return False

    # ---- 登录
    def _login(self):
        ip = self.client_address[0]
        st = LOGIN_FAILS.get(ip)
        now = time.time()
        if st and st[1] > now:
            self._json(429, {'error': '尝试次数过多，请 %d 分钟后再试' % max(1, int((st[1] - now) / 60))})
            return
        body = self._read_json()
        pw = str(body.get('password', ''))
        s = load_settings()
        if not s.get('password_hash'):
            self._json(400, {'error': '尚未设置密码，请先在 N1 上执行: sudo python3 /opt/frpc-manager/frpc-manager.py set-password'})
            return
        if verify_password(pw, s.get('password_salt'), s.get('password_hash')):
            tok = secrets.token_hex(32)
            with SESS_LOCK:
                SESSIONS[tok] = now + SESSION_HOURS * 3600
            LOGIN_FAILS.pop(ip, None)
            cookie = 'fp_session=%s; Path=/; HttpOnly; SameSite=Strict; Max-Age=%d' % (tok, SESSION_HOURS * 3600)
            self._json(200, {'ok': True}, headers=[('Set-Cookie', cookie)])
        else:
            cnt = (st[0] + 1) if st else 1
            if cnt >= MAX_LOGIN_FAILS:
                LOGIN_FAILS[ip] = [cnt, now + LOCKOUT_SECONDS]
            else:
                LOGIN_FAILS[ip] = [cnt, 0]
            self._json(401, {'error': '密码错误'})

    def _logout(self):
        c = self.headers.get('Cookie') or ''
        for part in c.split(';'):
            part = part.strip()
            if part.startswith('fp_session='):
                with SESS_LOCK:
                    SESSIONS.pop(part.split('=', 1)[1], None)
        self._json(200, {'ok': True}, headers=[('Set-Cookie', 'fp_session=; Path=/; Max-Age=0')])

    # ---- 路由
    def do_GET(self):
        try:
            self._handle('GET')
        except BrokenPipeError:
            pass
        except ValueError as e:
            try:
                self._json(400, {'error': str(e)})
            except Exception:
                pass
        except Exception as e:
            try:
                self._json(500, {'error': '服务器内部错误: %s' % e})
            except Exception:
                pass

    def do_POST(self):
        try:
            self._handle('POST')
        except BrokenPipeError:
            pass
        except ValueError as e:
            try:
                self._json(400, {'error': str(e)})
            except Exception:
                pass
        except Exception as e:
            try:
                self._json(500, {'error': '服务器内部错误: %s' % e})
            except Exception:
                pass

    def _handle(self, method):
        path = urlparse(self.path).path

        if method == 'GET':
            if path in ('/', '/index.html'):
                self._send(200, INDEX_HTML, 'text/html; charset=utf-8')
                return
            if path == '/favicon.ico':
                self._send(404, b'', 'image/x-icon')
                return
            if path == '/api/session':
                if self._token():
                    self._json(200, {'authed': True})
                else:
                    self._json(401, {'authed': False})
                return
            if not self._require_auth():
                return
            if path == '/api/status':
                self._json(200, api_status())
            elif path == '/api/config':
                self._json(200, api_get_config())
            elif path == '/api/logs':
                self._json(200, {'logs': service_logs()})
            elif path == '/api/backups':
                self._json(200, {'backups': list_backups()})
            elif path == '/api/raw':
                text = ''
                p = get_config_path()
                if os.path.exists(p):
                    with open(p, encoding='utf-8', errors='replace') as f:
                        text = f.read().lstrip('\ufeff')
                self._json(200, {'text': text})
            elif path == '/api/settings':
                self._json(200, safe_settings())
            else:
                self._json(404, {'error': '接口不存在'})
            return

        # POST
        if path == '/api/login':
            self._login()
            return
        # CSRF 防护：跨站表单无法携带自定义头
        if self.headers.get('X-Requested-With') != 'XMLHttpRequest':
            self._json(403, {'error': '非法请求'})
            return
        if path == '/api/logout':
            self._logout()
            return
        if not self._require_auth():
            return
        body = self._read_json()

        if path == '/api/config':
            self._json(200, api_save_config(body))
        elif path == '/api/service':
            ok, msg = service_action(str(body.get('action', '')))
            self._json(200, {'ok': ok, 'msg': msg, 'state': service_state()})
        elif path == '/api/verify':
            ok, msg = verify_config()
            self._json(200, {'ok': ok, 'msg': msg})
        elif path == '/api/raw':
            self._json(200, api_save_raw(body))
        elif path == '/api/restore':
            self._json(200, api_restore(body))
        elif path == '/api/backup-delete':
            self._json(200, api_backup_delete(body))
        elif path == '/api/settings':
            self._json(200, api_save_settings(body))
        elif path == '/api/password':
            self._json(200, api_change_password(body))
        else:
            self._json(404, {'error': '接口不存在'})


def session_cleaner():
    while True:
        time.sleep(600)
        now = time.time()
        with SESS_LOCK:
            for tok in list(SESSIONS):
                if SESSIONS[tok] <= now:
                    SESSIONS.pop(tok, None)


def cmd_set_password():
    import getpass
    print('frpc 管理面板 - 设置登录密码')
    pw = getpass.getpass('请输入新密码（至少 8 位）: ')
    if len(pw) < 8:
        sys.exit('密码至少 8 位')
    pw2 = getpass.getpass('请再次输入确认: ')
    if pw != pw2:
        sys.exit('两次输入不一致')
    s = load_settings()
    salt, h = hash_password(pw)
    s['password_hash'] = h
    s['password_salt'] = salt
    save_settings(s)
    print('密码已保存。')
    print('如面板正在运行，无需重启即可用新密码登录。')


# ---------------------------------------------------------------- 前端页面

INDEX_HTML = r'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>frpc 管理面板</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#0b1220;--card:#131d33;--card2:#0e1626;--border:#24304d;--text:#dce6f8;--muted:#7e93b5;--accent:#3b82f6;--accent-h:#60a5fa;--ok:#34d399;--warn:#fbbf24;--err:#f87171}
body{font-family:system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;background:var(--bg);color:var(--text);min-height:100vh;font-size:14px}
.hidden{display:none!important}
a{color:var(--accent-h)}
/* ---------- 登录 ---------- */
#login{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:20px}
.login-card{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:40px 36px;width:380px;max-width:100%;box-shadow:0 20px 60px rgba(0,0,0,.45)}
.brand{display:flex;align-items:center;gap:12px;margin-bottom:8px}
.brand .logo{width:40px;height:40px;border-radius:10px;background:linear-gradient(135deg,#2563eb,#22d3ee);display:flex;align-items:center;justify-content:center;font-size:20px}
.brand h1{font-size:20px;font-weight:700}
.login-sub{color:var(--muted);font-size:13px;margin-bottom:28px}
#login-err{color:var(--err);font-size:13px;min-height:18px;margin:10px 0 4px;word-break:break-all}
/* ---------- 通用组件 ---------- */
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:9px 16px;border-radius:9px;border:1px solid var(--border);background:var(--card2);color:var(--text);cursor:pointer;font-size:13.5px;transition:.15s;white-space:nowrap}
.btn:hover{border-color:var(--accent);color:#fff}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
.btn.primary:hover{background:var(--accent-h)}
.btn.danger{color:var(--err)}
.btn.danger:hover{border-color:var(--err);background:rgba(248,113,113,.1)}
.btn.small{padding:5px 10px;font-size:12.5px;border-radius:7px}
.btn:disabled{opacity:.45;cursor:not-allowed}
input,select,textarea{width:100%;padding:9px 12px;border-radius:9px;border:1px solid var(--border);background:var(--card2);color:var(--text);font-size:14px;outline:none;transition:.15s;font-family:inherit}
input:focus,select:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(59,130,246,.18)}
textarea{font-family:Consolas,Menlo,monospace;font-size:13px;line-height:1.6;resize:vertical}
.field{margin-bottom:14px}
.field label{display:block;font-size:13px;color:var(--muted);margin-bottom:6px}
.hint{font-size:12px;color:var(--muted);margin-top:5px;line-height:1.5}
.card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:20px;margin-bottom:16px}
.badge{display:inline-flex;align-items:center;padding:3px 10px;border-radius:99px;font-size:12.5px;font-weight:600}
.badge.ok{background:rgba(52,211,153,.14);color:var(--ok)}
.badge.err{background:rgba(248,113,113,.14);color:var(--err)}
.badge.warn{background:rgba(251,191,36,.14);color:var(--warn)}
.badge.muted{background:rgba(126,147,181,.15);color:var(--muted)}
.badge.blue{background:rgba(59,130,246,.15);color:var(--accent-h)}
/* ---------- 布局 ---------- */
header{position:sticky;top:0;z-index:50;background:rgba(11,18,32,.85);backdrop-filter:blur(12px);border-bottom:1px solid var(--border)}
.hwrap{max-width:1080px;margin:0 auto;padding:0 20px;display:flex;align-items:center;gap:18px;height:60px;flex-wrap:wrap}
.hwrap .brand h1{font-size:16px}
nav{display:flex;gap:4px;flex:1;overflow-x:auto}
nav button{background:none;border:none;color:var(--muted);padding:8px 14px;border-radius:8px;cursor:pointer;font-size:14px;white-space:nowrap;transition:.15s}
nav button:hover{color:var(--text)}
nav button.active{background:var(--card);color:var(--text);font-weight:600}
.h-right{display:flex;align-items:center;gap:14px}
.restart-toggle{display:flex;align-items:center;gap:6px;font-size:12.5px;color:var(--muted);cursor:pointer;user-select:none}
main{max-width:1080px;margin:0 auto;padding:24px 20px 60px}
h2{font-size:18px;margin-bottom:4px}
.sub{color:var(--muted);font-size:13px;margin-bottom:18px}
/* ---------- 状态页 ---------- */
.status-hero{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}
.status-left{display:flex;align-items:center;gap:16px}
.status-left .big-badge{font-size:15px;padding:8px 18px}
.info-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px 24px;margin-top:18px}
.info-grid .item{display:flex;justify-content:space-between;gap:10px;padding:8px 0;border-bottom:1px dashed var(--border);font-size:13.5px}
.info-grid .item span:first-child{color:var(--muted)}
.info-grid .item span:last-child{text-align:right;word-break:break-all}
#logs{background:#0a101e;border:1px solid var(--border);border-radius:10px;padding:14px;max-height:340px;overflow:auto;font-family:Consolas,Menlo,monospace;font-size:12px;line-height:1.7;color:#9fb4d8;white-space:pre-wrap;word-break:break-all}
#verify-out{margin-top:12px;font-size:13px;padding:10px 14px;border-radius:8px;display:none;word-break:break-all}
#verify-out.ok{display:block;background:rgba(52,211,153,.1);color:var(--ok)}
#verify-out.err{display:block;background:rgba(248,113,113,.1);color:var(--err)}
/* ---------- 代理列表 ---------- */
.toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:16px;flex-wrap:wrap}
.proxy-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:14px}
.proxy-card{background:var(--card);border:1px solid var(--border);border-radius:13px;padding:16px 18px;transition:.15s}
.proxy-card:hover{border-color:#31446e}
.pc-head{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:10px}
.pc-name{font-weight:700;font-size:15px;word-break:break-all}
.pc-map{font-size:13px;color:#a9bede;margin-bottom:6px;word-break:break-all}
.pc-extra{font-size:12.5px;color:var(--muted);word-break:break-all}
.pc-actions{display:flex;gap:8px;margin-top:14px}
.empty{padding:50px 20px;text-align:center;color:var(--muted);border:1px dashed var(--border);border-radius:13px}
/* ---------- 表格 ---------- */
table{width:100%;border-collapse:collapse;font-size:13.5px}
th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--border)}
th{color:var(--muted);font-weight:600;font-size:12.5px}
tr:last-child td{border-bottom:none}
/* ---------- 模态框 ---------- */
#modal-mask{position:fixed;inset:0;background:rgba(4,8,16,.7);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:100;padding:20px}
.modal{background:var(--card);border:1px solid var(--border);border-radius:16px;width:560px;max-width:100%;max-height:90vh;overflow:auto;padding:26px}
.modal h3{font-size:17px;margin-bottom:20px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:0 14px}
.adv-row{display:grid;grid-template-columns:1fr 1.4fr auto;gap:8px;margin-bottom:8px;align-items:center}
.adv-row .btn{padding:7px 10px}
.section-title{font-size:13.5px;font-weight:600;color:var(--muted);margin:18px 0 10px;padding-top:14px;border-top:1px dashed var(--border)}
.modal-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:22px}
/* ---------- 提示 ---------- */
#toasts{position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:200;display:flex;flex-direction:column;gap:8px;align-items:center;max-width:92vw}
.toast{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:11px 18px;font-size:13.5px;box-shadow:0 10px 30px rgba(0,0,0,.4);animation:fadein .2s;word-break:break-all;max-width:100%}
.toast.ok{border-color:rgba(52,211,153,.5)}
.toast.err{border-color:rgba(248,113,113,.5);color:var(--err)}
.toast.warn{border-color:rgba(251,191,36,.5);color:var(--warn)}
@keyframes fadein{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:none}}
.banner{padding:12px 16px;border-radius:10px;font-size:13.5px;margin-bottom:16px}
.banner.warn{background:rgba(251,191,36,.1);border:1px solid rgba(251,191,36,.35);color:var(--warn)}
@media(max-width:680px){
  .grid2{grid-template-columns:1fr}
  .hwrap{height:auto;padding:10px 16px}
  main{padding:16px 14px 50px}
  .modal{padding:20px 16px}
}
</style>
</head>
<body>

<!-- 登录页 -->
<div id="login" class="hidden">
  <div class="login-card">
    <div class="brand">
      <div class="logo">🚀</div>
      <h1>frpc 管理面板</h1>
    </div>
    <div class="login-sub">N1 · Armbian · 内网穿透配置管理</div>
    <div class="field">
      <label>登录密码</label>
      <input type="password" id="login-pw" placeholder="请输入密码" autocomplete="current-password"/>
    </div>
    <div id="login-err"></div>
    <button class="btn primary" id="login-btn" style="width:100%;padding:11px">登 录</button>
  </div>
</div>

<!-- 主界面 -->
<div id="app" class="hidden">
<header>
  <div class="hwrap">
    <div class="brand"><div class="logo" style="width:30px;height:30px;font-size:15px;border-radius:8px">🚀</div><h1>frpc 面板</h1></div>
    <nav id="nav">
      <button data-tab="status" class="active">状态</button>
      <button data-tab="proxies">代理</button>
      <button data-tab="server">服务端</button>
      <button data-tab="backups">备份</button>
      <button data-tab="raw">原始配置</button>
      <button data-tab="settings">设置</button>
    </nav>
    <div class="h-right">
      <label class="restart-toggle" title="每次保存配置后自动重启 frpc 服务使其生效">
        <input type="checkbox" id="auto-restart" checked style="width:auto"/>保存后重启
      </label>
      <button class="btn small" id="logout-btn">退出</button>
    </div>
  </div>
</header>

<main>
  <!-- 状态 -->
  <section id="tab-status">
    <div class="card">
      <div class="status-hero">
        <div class="status-left">
          <span class="badge big-badge muted" id="st-badge">…</span>
          <div>
            <h2>frpc 服务</h2>
            <div class="sub" style="margin:0" id="st-sub"></div>
          </div>
        </div>
        <div style="display:flex;gap:10px;flex-wrap:wrap">
          <button class="btn primary" id="btn-restart">重启</button>
          <button class="btn" id="btn-start">启动</button>
          <button class="btn danger" id="btn-stop">停止</button>
        </div>
      </div>
      <div class="info-grid" id="st-info"></div>
    </div>
    <div class="card">
      <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
        <h2>配置校验</h2>
        <button class="btn small" id="btn-verify">立即校验</button>
      </div>
      <div class="hint" style="margin:0">调用 <b>frpc verify</b> 检查当前配置文件语法是否正确（每次保存后也会自动校验）。</div>
      <div id="verify-out"></div>
    </div>
    <div class="card">
      <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
        <h2>运行日志</h2>
        <button class="btn small" id="btn-logs">刷新</button>
      </div>
      <div id="logs">加载中…</div>
    </div>
  </section>

  <!-- 代理 -->
  <section id="tab-proxies" class="hidden">
    <div class="toolbar">
      <div>
        <h2>代理列表 <span class="badge blue" id="fmt-badge" style="vertical-align:2px"></span></h2>
        <div class="sub" style="margin:0" id="proxy-sub"></div>
      </div>
      <button class="btn primary" id="btn-add">＋ 新增代理</button>
    </div>
    <div id="no-config-banner" class="banner warn hidden">配置文件尚不存在，请先到【服务端】页填写服务器信息并保存。</div>
    <div id="visitor-banner" class="banner warn hidden"></div>
    <div class="proxy-grid" id="proxy-grid"></div>
    <div class="empty hidden" id="proxy-empty">还没有任何代理，点击右上角「新增代理」创建第一个吧</div>
  </section>

  <!-- 服务端 -->
  <section id="tab-server" class="hidden">
    <h2>服务端连接设置</h2>
    <div class="sub">frps 服务器的连接信息，保存后写入配置文件</div>
    <div class="card">
      <div class="grid2">
        <div class="field"><label>服务器地址 (serverAddr)</label><input id="s-addr" placeholder="例如 1.2.3.4 或 frps.example.com"/></div>
        <div class="field"><label>服务器端口 (serverPort)</label><input id="s-port" type="number" placeholder="7000"/></div>
      </div>
      <div class="grid2">
        <div class="field"><label>认证 Token</label><input id="s-token" placeholder="与服务端 frps 保持一致"/></div>
        <div class="field"><label>用户名 user（可选）</label><input id="s-user" placeholder="留空则不设置"/></div>
      </div>
      <div class="field">
        <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
          <input type="checkbox" id="s-tls" style="width:auto"/>启用 TLS 加密传输（transport.tls.enable，推荐）
        </label>
      </div>
      <div class="section-title">高级参数（可选，使用原生配置键名）</div>
      <div id="s-adv"></div>
      <button class="btn small" id="s-adv-add">＋ 添加参数</button>
      <div class="modal-actions" style="justify-content:flex-start">
        <button class="btn primary" id="s-save">保存配置</button>
      </div>
    </div>
  </section>

  <!-- 备份 -->
  <section id="tab-backups" class="hidden">
    <h2>配置备份</h2>
    <div class="sub">每次保存前自动备份，最多保留 20 份，可一键恢复</div>
    <div class="card">
      <table>
        <thead><tr><th>备份时间</th><th>文件</th><th>大小</th><th style="width:170px">操作</th></tr></thead>
        <tbody id="bk-body"></tbody>
      </table>
      <div class="empty hidden" id="bk-empty">暂无备份</div>
    </div>
  </section>

  <!-- 原始配置 -->
  <section id="tab-raw" class="hidden">
    <h2>原始配置编辑</h2>
    <div class="sub">直接编辑配置文件原文（面板未覆盖的高级用法可在此修改）</div>
    <div class="card">
      <textarea id="raw-text" rows="20" spellcheck="false"></textarea>
      <div class="modal-actions">
        <button class="btn" id="raw-reload">重新加载</button>
        <button class="btn primary" id="raw-save">保存</button>
      </div>
    </div>
  </section>

  <!-- 设置 -->
  <section id="tab-settings" class="hidden">
    <h2>面板设置</h2>
    <div class="sub">修改后立即生效（端口除外，需重启面板服务）</div>
    <div class="card">
      <div class="grid2">
        <div class="field"><label>面板端口</label><input id="set-port" type="number"/><div class="hint">修改后需执行: systemctl restart frpc-manager</div></div>
        <div class="field"><label>配置文件路径</label><input id="set-path"/></div>
      </div>
      <div class="field"><label>frpc 服务名 (systemd)</label><input id="set-svc"/></div>
      <div class="modal-actions" style="justify-content:flex-start">
        <button class="btn primary" id="set-save">保存设置</button>
      </div>
    </div>
    <div class="card">
      <h2 style="margin-bottom:14px">修改登录密码</h2>
      <div class="grid2">
        <div class="field"><label>当前密码</label><input type="password" id="pw-old" autocomplete="current-password"/></div>
        <div class="field"><label>新密码（至少 8 位）</label><input type="password" id="pw-new" autocomplete="new-password"/></div>
      </div>
      <div class="field"><label>确认新密码</label><input type="password" id="pw-new2" autocomplete="new-password"/></div>
      <div class="modal-actions" style="justify-content:flex-start">
        <button class="btn primary" id="pw-save">修改密码</button>
      </div>
    </div>
  </section>
</main>
</div>

<!-- 代理编辑模态框 -->
<div id="modal-mask" class="hidden">
  <div class="modal">
    <h3 id="modal-title">新增代理</h3>
    <div class="grid2">
      <div class="field"><label>名称 *</label><input id="p-name" placeholder="例如 n1-ssh"/></div>
      <div class="field"><label>类型 *</label>
        <select id="p-type">
          <option value="tcp">tcp（端口映射）</option>
          <option value="udp">udp</option>
          <option value="http">http（网站）</option>
          <option value="https">https</option>
          <option value="stcp">stcp（安全隧道）</option>
          <option value="sudp">sudp</option>
          <option value="xtcp">xtcp（P2P）</option>
        </select>
      </div>
      <div class="field"><label>本地 IP</label><input id="p-localip" value="127.0.0.1"/></div>
      <div class="field"><label>本地端口 *</label><input id="p-localport" type="number" placeholder="例如 22"/></div>
    </div>
    <div class="field" id="f-remotePort"><label>远程端口</label><input id="p-remoteport" type="number" placeholder="留空由服务端随机分配"/><div class="hint">frps 服务器上对外暴露的端口</div></div>
    <div class="field hidden" id="f-domains"><label>绑定域名</label><input id="p-domains" placeholder="多个用英文逗号分隔"/><div class="hint">http/https 类型：绑定到 frps 的 vhost 端口</div></div>
    <div class="field hidden" id="f-subdomain"><label>子域名 subdomain</label><input id="p-subdomain" placeholder="配合 frps 的 subdomainHost 使用"/></div>
    <div class="field hidden" id="f-secretKey"><label>密钥 secretKey</label><input id="p-secretkey" placeholder="stcp/sudp/xtcp 访问密钥"/></div>
    <div class="section-title">高级参数（可选，使用原生配置键名）</div>
    <div id="p-adv"></div>
    <button class="btn small" id="p-adv-add">＋ 添加参数</button>
    <div class="modal-actions">
      <button class="btn" id="m-cancel">取消</button>
      <button class="btn primary" id="m-save">保存</button>
    </div>
  </div>
</div>

<div id="toasts"></div>

<script>
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
let cfg=null, editIdx=-1, pollTimer=null;
const KNOWN=['name','type','localIP','localPort','remotePort','customDomains','subdomain','secretKey'];
const GKNOWN=['serverAddr','serverPort','auth.token','user','transport.tls.enable'];

async function api(path, method='GET', body=null){
  const r=await fetch(path,{method,headers:{'X-Requested-With':'XMLHttpRequest',...(body?{'Content-Type':'application/json'}:{})},body:body?JSON.stringify(body):undefined});
  let d={};try{d=await r.json()}catch(e){}
  if(r.status===401){showLogin();throw new Error(d.error||'请先登录')}
  if(!r.ok)throw new Error(d.error||('请求失败 '+r.status));
  return d;
}
function toast(msg,type='ok'){
  const t=document.createElement('div');t.className='toast '+type;t.textContent=msg;
  $('#toasts').appendChild(t);setTimeout(()=>t.remove(),5000);
}
function showLogin(){
  stopPolling();
  $('#app').classList.add('hidden');$('#login').classList.remove('hidden');
  $('#login-pw').value='';$('#login-pw').focus();
}
async function boot(){
  $('#login').classList.add('hidden');$('#app').classList.remove('hidden');
  await loadConfig(); await loadStatus();
  switchTab('status');
  startPolling();
}
function startPolling(){stopPolling();pollTimer=setInterval(()=>{if(!$('#tab-status').classList.contains('hidden')){loadStatus();loadLogs();}},5000)}
function stopPolling(){if(pollTimer){clearInterval(pollTimer);pollTimer=null}}

/* ---------- 标签页 ---------- */
$$('#nav button').forEach(b=>b.onclick=()=>switchTab(b.dataset.tab));
function switchTab(name){
  $$('#nav button').forEach(b=>b.classList.toggle('active',b.dataset.tab===name));
  ['status','proxies','server','backups','raw','settings'].forEach(t=>{
    const el=$('#tab-'+t); if(el) el.classList.toggle('hidden',t!==name);
  });
  if(name==='status'){loadStatus();loadLogs()}
  if(name==='proxies')renderProxies();
  if(name==='server')fillServerForm();
  if(name==='backups')loadBackups();
  if(name==='raw')loadRaw();
  if(name==='settings')loadSettings();
}

/* ---------- 登录 ---------- */
$('#login-btn').onclick=async()=>{
  const btn=$('#login-btn');btn.disabled=true;
  try{
    await api('/api/login','POST',{password:$('#login-pw').value});
    $('#login-err').textContent='';
    boot();
  }catch(e){$('#login-err').textContent=e.message}
  btn.disabled=false;
};
$('#login-pw').addEventListener('keydown',e=>{if(e.key==='Enter')$('#login-btn').click()});
$('#logout-btn').onclick=async()=>{try{await api('/api/logout','POST')}catch(e){}showLogin()};

/* ---------- 状态 ---------- */
async function loadStatus(){
  try{
    const d=await api('/api/status');
    const map={active:['运行中','ok'],inactive:['已停止','muted'],failed:['故障','err'],activating:['启动中','warn'],unknown:['未知','warn']};
    const [txt,cls]=map[d.state]||['未知','warn'];
    const b=$('#st-badge');b.textContent=txt;b.className='badge big-badge '+cls;
    $('#st-sub').textContent=d.state==='active'?'frpc 正在运行':'frpc 未在运行';
    $('#btn-start').disabled=d.state==='active';
    $('#btn-stop').disabled=d.state!=='active';
    const rows=[
      ['frpc 版本',d.frpc_version||'未安装'],
      ['服务名',d.service_name],
      ['配置文件',d.config_path+(d.config_exists?'':'（不存在）')],
      ['配置格式',d.format==='toml'?'TOML（新版）':'INI（旧版）'],
      ['代理数量',d.proxy_count<0?'读取失败':d.proxy_count],
      ['面板版本',d.panel_version],
    ];
    $('#st-info').innerHTML=rows.map(r=>'<div class="item"><span>'+r[0]+'</span><span>'+esc(r[1])+'</span></div>').join('');
    $('#no-config-banner').classList.toggle('hidden',!!d.config_exists);
    $('#fmt-badge').textContent=d.format.toUpperCase();
  }catch(e){}
}
async function serviceAction(a){
  if(a!=='start'&&!confirm('确定'+(a==='restart'?'重启':'停止')+' frpc 服务？'))return;
  try{
    const d=await api('/api/service','POST',{action:a});
    toast(d.ok?'已'+(a==='restart'?'重启':a==='start'?'启动':'停止')+' frpc':'操作失败: '+d.msg,d.ok?'ok':'err');
    setTimeout(()=>{loadStatus();loadLogs()},800);
  }catch(e){toast(e.message,'err')}
}
$('#btn-start').onclick=()=>serviceAction('start');
$('#btn-stop').onclick=()=>serviceAction('stop');
$('#btn-restart').onclick=()=>serviceAction('restart');
$('#btn-logs').onclick=loadLogs;
$('#btn-verify').onclick=async()=>{
  const o=$('#verify-out');o.className='';o.textContent='校验中…';
  try{
    const d=await api('/api/verify','POST');
    o.className=d.ok===null?'':(d.ok?'ok':'err');
    o.textContent=d.msg;
  }catch(e){o.className='err';o.textContent=e.message}
};
async function loadLogs(){
  try{
    const d=await api('/api/logs');
    $('#logs').textContent=d.logs||'（暂无日志，可能服务未运行或日志系统不可用）';
  }catch(e){}
}
function showVerify(v){
  const o=$('#verify-out');
  if(!v||v.ok===null){o.className='';o.textContent='';return}
  o.className=v.ok?'ok':'err';
  o.textContent=(v.ok?'✓ 校验通过：':'✗ 校验失败：')+(v.msg||'');
}

/* ---------- 配置加载 ---------- */
async function loadConfig(){
  const d=await api('/api/config');
  cfg=d;
  renderProxies();
}
function renderProxies(){
  if(!cfg)return;
  const list=cfg.proxies||[];
  $('#proxy-sub').textContent=list.length? '共 '+list.length+' 个代理':'';
  $('#visitor-banner').classList.toggle('hidden',!(cfg.visitors_count>0));
  if(cfg.visitors_count>0)$('#visitor-banner').textContent='检测到 '+cfg.visitors_count+' 个 visitor 配置，保存时将自动保留，可在【原始配置】中编辑。';
  const grid=$('#proxy-grid');
  if(!list.length){
    grid.innerHTML='';$('#proxy-empty').classList.remove('hidden');return;
  }
  $('#proxy-empty').classList.add('hidden');
  grid.innerHTML=list.map((p,i)=>{
    const local=(p.localIP||'127.0.0.1')+':'+p.localPort;
    let right;
    if(['tcp','udp','stcp','sudp','xtcp'].includes(p.type)){
      right=p.remotePort?('远程端口 <b>'+p.remotePort+'</b>'):'<span style="color:var(--warn)">未设远程端口</span>';
    }else{
      const dom=(p.customDomains||[]).join(', ');
      right=dom?('域名 <b>'+esc(dom)+'</b>'):(p.subdomain?('子域名 <b>'+esc(p.subdomain)+'</b>'):'<span style="color:var(--warn)">未绑定域名</span>');
    }
    let extra=[];
    if(p.subdomain&&['tcp','udp'].includes(p.type))extra.push('subdomain: '+p.subdomain);
    if(p.secretKey)extra.push('secretKey: 已设置');
    const adv=Object.keys(p).filter(k=>!KNOWN.includes(k));
    if(adv.length)extra.push('高级参数: '+adv.length+' 项');
    return '<div class="proxy-card">'+
      '<div class="pc-head"><span class="pc-name">'+esc(p.name)+'</span><span class="badge blue">'+esc(p.type)+'</span></div>'+
      '<div class="pc-map">'+esc(local)+' → '+right+'</div>'+
      (extra.length?'<div class="pc-extra">'+esc(extra.join(' · '))+'</div>':'')+
      '<div class="pc-actions"><button class="btn small" onclick="openProxyModal('+i+')">编辑</button>'+
      '<button class="btn small danger" onclick="deleteProxy('+i+')">删除</button></div></div>';
  }).join('');
}
function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}

/* ---------- 代理编辑 ---------- */
$('#btn-add').onclick=()=>openProxyModal(-1);
function openProxyModal(i){
  editIdx=i;
  const p=i>=0?cfg.proxies[i]:null;
  $('#modal-title').textContent=p?'编辑代理：'+p.name:'新增代理';
  $('#p-name').value=p?p.name:'';
  $('#p-type').value=p?p.type:'tcp';
  $('#p-localip').value=p?(p.localIP||'127.0.0.1'):'127.0.0.1';
  $('#p-localport').value=p?p.localPort:'';
  $('#p-remoteport').value=p&&p.remotePort?p.remotePort:'';
  $('#p-domains').value=p&&p.customDomains?p.customDomains.join(', '):'';
  $('#p-subdomain').value=p&&p.subdomain?p.subdomain:'';
  $('#p-secretkey').value=p&&p.secretKey?p.secretKey:'';
  const extras=p?Object.entries(p).filter(([k])=>!KNOWN.includes(k)):[];
  renderAdvRows(extras,$('#p-adv'));
  updateTypeFields();
  $('#modal-mask').classList.remove('hidden');
}
window.openProxyModal=openProxyModal;
window.deleteProxy=async function(i){
  const p=cfg.proxies[i];
  if(!confirm('确定删除代理「'+p.name+'」？删除后将立即保存配置。'))return;
  cfg.proxies.splice(i,1);
  await saveConfig();
};
$('#m-cancel').onclick=()=>$('#modal-mask').classList.add('hidden');
$('#modal-mask').addEventListener('click',e=>{if(e.target.id==='modal-mask')$('#modal-mask').classList.add('hidden')});
$('#p-type').onchange=updateTypeFields;
function updateTypeFields(){
  const t=$('#p-type').value;
  $('#f-remotePort').classList.toggle('hidden',!['tcp','udp','stcp','sudp','xtcp'].includes(t));
  $('#f-domains').classList.toggle('hidden',!['http','https'].includes(t));
  $('#f-subdomain').classList.toggle('hidden',!['http','https'].includes(t));
  $('#f-secretKey').classList.toggle('hidden',!['stcp','sudp','xtcp'].includes(t));
}
$('#m-save').onclick=async()=>{
  const p=collectProxy();
  if(!p)return;
  const others=cfg.proxies.filter((_,x)=>x!==editIdx);
  if(others.some(o=>o.name===p.name))return toast('名称「'+p.name+'」已存在','err');
  if(editIdx>=0)cfg.proxies[editIdx]=p;else cfg.proxies.push(p);
  $('#modal-mask').classList.add('hidden');
  await saveConfig();
};
function collectProxy(){
  const type=$('#p-type').value;
  const name=$('#p-name').value.trim();
  const lp=parseInt($('#p-localport').value);
  if(!name)return toast('请填写代理名称','err'),null;
  if(!/^[^=\[\]\r\n]{1,100}$/.test(name))return toast('名称不能包含 [ ] = 等字符','err'),null;
  if(!lp||lp<1||lp>65535)return toast('本地端口无效（1-65535）','err'),null;
  const p={name:name,type:type,localIP:$('#p-localip').value.trim()||'127.0.0.1',localPort:lp};
  if(['tcp','udp','stcp','sudp','xtcp'].includes(type)){
    const rp=$('#p-remoteport').value.trim();
    if(rp){const n=parseInt(rp);if(!n||n<1||n>65535)return toast('远程端口无效','err'),null;p.remotePort=n}
  }
  if(['http','https'].includes(type)){
    const d=$('#p-domains').value.split(/[,，]/).map(x=>x.trim()).filter(Boolean);
    if(d.length)p.customDomains=d;
    const s=$('#p-subdomain').value.trim();if(s)p.subdomain=s;
  }
  if(['stcp','sudp','xtcp'].includes(type)){
    const sk=$('#p-secretkey').value.trim();if(sk)p.secretKey=sk;
  }
  $$('#p-adv .adv-row').forEach(row=>{
    const k=row.querySelector('.adv-k').value.trim(),v=row.querySelector('.adv-v').value;
    if(k)p[k]=parseAdvVal(v);
  });
  return p;
}

/* ---------- 高级参数行 ---------- */
function renderAdvRows(entries,container){
  container.innerHTML='';
  entries.forEach(([k,v])=>addAdvRow(container,k,typeof v==='object'?JSON.stringify(v):String(v)));
}
function addAdvRow(container,k='',v=''){
  const row=document.createElement('div');row.className='adv-row';
  row.innerHTML='<input class="adv-k" placeholder="参数名 如 transport.useEncryption"><input class="adv-v" placeholder="值 如 true"><button class="btn small danger">删除</button>';
  row.querySelector('.adv-k').value=k;row.querySelector('.adv-v').value=v;
  row.querySelector('button').onclick=()=>row.remove();
  container.appendChild(row);
}
$('#p-adv-add').onclick=()=>addAdvRow($('#p-adv'));
$('#s-adv-add').onclick=()=>addAdvRow($('#s-adv'));
function parseAdvVal(s){
  const t=String(s).trim();
  if(t==='')return '';
  try{const j=JSON.parse(t);if(['number','boolean','string'].includes(typeof j)||Array.isArray(j))return j}catch(e){}
  if(t==='true')return true;
  if(t==='false')return false;
  if(/^-?\d+$/.test(t))return parseInt(t);
  return t;
}

/* ---------- 保存配置 ---------- */
async function saveConfig(){
  try{
    const restart=$('#auto-restart').checked;
    const d=await api('/api/config','POST',{global:cfg.global,proxies:cfg.proxies,restart:restart});
    let msg='配置已保存';
    if(d.restarted)msg+='，frpc 已重启';
    toast(msg,'ok');
    showVerify(d.verify);
    (d.warnings||[]).forEach(w=>toast(w,'warn'));
    await loadConfig();
    loadStatus();
  }catch(e){toast('保存失败: '+e.message,'err');loadConfig()}
}

/* ---------- 服务端设置 ---------- */
function fillServerForm(){
  if(!cfg)return;
  const g=cfg.global||{};
  $('#s-addr').value=g.serverAddr||'';
  $('#s-port').value=g.serverPort??'';
  $('#s-token').value=g['auth.token']||'';
  $('#s-user').value=g.user||'';
  $('#s-tls').checked=g['transport.tls.enable']===true;
  renderAdvRows(Object.entries(g).filter(([k])=>!GKNOWN.includes(k)),$('#s-adv'));
}
$('#s-save').onclick=async()=>{
  const g={};
  const addr=$('#s-addr').value.trim();if(addr)g.serverAddr=addr;
  const port=parseInt($('#s-port').value);if(port)g.serverPort=port;
  const tok=$('#s-token').value;if(tok)g['auth.token']=tok;
  const user=$('#s-user').value.trim();if(user)g.user=user;
  if($('#s-tls').checked)g['transport.tls.enable']=true;
  $$('#s-adv .adv-row').forEach(row=>{
    const k=row.querySelector('.adv-k').value.trim(),v=row.querySelector('.adv-v').value;
    if(k)g[k]=parseAdvVal(v);
  });
  cfg.global=g;
  await saveConfig();
  fillServerForm();
};

/* ---------- 备份 ---------- */
async function loadBackups(){
  const d=await api('/api/backups');
  const body=$('#bk-body');
  const list=d.backups||[];
  $('#bk-empty').classList.toggle('hidden',!!list.length);
  body.innerHTML=list.map(b=>
    '<tr><td>'+esc(b.time)+'</td><td>'+esc(b.file)+'</td><td>'+(b.size/1024).toFixed(1)+' KB</td>'+
    '<td><button class="btn small" onclick="restoreBackup(\''+b.file+'\')">恢复</button> '+
    '<button class="btn small danger" onclick="delBackup(\''+b.file+'\')">删除</button></td></tr>'
  ).join('');
}
window.restoreBackup=async function(f){
  if(!confirm('确定恢复备份 '+f+' ？当前配置会先自动备份。'))return;
  try{
    await api('/api/restore','POST',{file:f,restart:$('#auto-restart').checked});
    toast('已恢复备份','ok');
    await loadConfig();loadStatus();loadBackups();
  }catch(e){toast(e.message,'err')}
};
window.delBackup=async function(f){
  if(!confirm('确定删除备份 '+f+' ？'))return;
  try{await api('/api/backup-delete','POST',{file:f});loadBackups()}catch(e){toast(e.message,'err')}
};

/* ---------- 原始配置 ---------- */
async function loadRaw(){
  try{const d=await api('/api/raw');$('#raw-text').value=d.text}catch(e){}
}
$('#raw-reload').onclick=loadRaw;
$('#raw-save').onclick=async()=>{
  try{
    await api('/api/raw','POST',{text:$('#raw-text').value,restart:$('#auto-restart').checked});
    toast('原始配置已保存','ok');
    await loadConfig();
  }catch(e){toast(e.message,'err')}
};

/* ---------- 面板设置 ---------- */
async function loadSettings(){
  try{
    const d=await api('/api/settings');
    $('#set-port').value=d.port;
    $('#set-path').value=d.config_path;
    $('#set-svc').value=d.service_name;
  }catch(e){}
}
$('#set-save').onclick=async()=>{
  try{
    const d=await api('/api/settings','POST',{port:parseInt($('#set-port').value),config_path:$('#set-path').value.trim(),service_name:$('#set-svc').value.trim()});
    toast(d.note||'设置已保存','ok');
    await loadConfig();loadStatus();
  }catch(e){toast(e.message,'err')}
};
$('#pw-save').onclick=async()=>{
  const o=$('#pw-old').value,n=$('#pw-new').value,n2=$('#pw-new2').value;
  if(n!==n2)return toast('两次新密码不一致','err');
  if(n.length<8)return toast('新密码至少 8 位','err');
  try{
    await api('/api/password','POST',{old:o,new:n});
    toast('密码已修改，请重新登录','ok');
    $('#pw-old').value=$('#pw-new').value=$('#pw-new2').value='';
    setTimeout(showLogin,1200);
  }catch(e){toast(e.message,'err')}
};

/* ---------- 启动 ---------- */
(async function(){
  try{await api('/api/session');boot()}catch(e){showLogin()}
})();
</script>
</body>
</html>
'''


# ---------------------------------------------------------------- 入口

def main():
    global SETTINGS
    SETTINGS = load_settings()

    if len(sys.argv) > 1 and sys.argv[1] == 'set-password':
        cmd_set_password()
        return

    port = int(SETTINGS.get('port') or 8999)
    server = ThreadingHTTPServer(('0.0.0.0', port), Handler)
    threading.Thread(target=session_cleaner, daemon=True).start()
    print('frpc 管理面板已启动，端口 %d' % port)
    print('本机访问: http://127.0.0.1:%d' % port)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print('\n面板已停止')


if __name__ == '__main__':
    main()
