📂 solution-1.0

← 返回上级
#!/usr/bin/env python3
"""
设备认证网关 — v2 用 requests 做代理转发
监听 8081,Nginx 将所有动态请求转发至此

架构:
  Nginx(80) → 静态资源直接返回
            → 动态请求 → 认证网关(8081) → 检查 Cookie
                ├─ 已授权 → requests 转发到实际后端
                └─ 未授权 → 302 → /device-unauthorized/
"""
import os
import sys
import json
import uuid
import sqlite3
import subprocess
import requests
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs, quote
from datetime import datetime

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import device_auth as auth

AUTH_PORT = 8081

# 后端映射
BACKENDS = {
    'obsidian_web': 'http://127.0.0.1:8080',
    'hermes_chat':  'http://127.0.0.1:8765',
    'hermes_chat_http': 'http://127.0.0.1:8766',
    'world_monitor':'http://127.0.0.1:5173',
    'hermes_knowledge': 'http://127.0.0.1:8010',
    'hermes_webui': 'http://127.0.0.1:8643',
}

# 动态资源 → (后端, 目标路径前缀) 映射(按最长前缀匹配)
DYNAMIC = [
    ('/doc/',        ('obsidian_web', '/doc/')),
    ('/liangliang/', ('obsidian_web', '/liangliang/')),
    ('/world/',      ('world_monitor', '/world/')),
    ('/hermes/',     ('hermes_knowledge', '/hermes/')),
    ('/ask',         ('hermes_chat', '/chat')),       # /ask → /chat
    ('/chat/config-',('chat_static', '/var/www/chat/')),    # 聊天配置JS
    ('/chat/marked.',('chat_static', '/var/www/chat/')),    # marked库
    ('/chat/index.', ('chat_static', '/var/www/chat/')),    # 聊天框架HTML
    ('/chat/chat-track.',('chat_static', '/var/www/chat/')),# 跟踪脚本
    ('/_chat/',      ('hermes_chat_http', '/chat/')),    # /_chat/history → 8766 /chat/history
    ('/chat/tasks',  ('hermes_chat_http', '')),    # /chat/tasks → 8766/chat/tasks
    ('/chat/tree',   ('hermes_chat_http', '')),    # /chat/tree → 8766/chat/tree
    ('/chat/history',('hermes_chat_http', '')),    # /chat/history → 8766/chat/history
    ('/chat/prd',    ('hermes_chat_http', '')),    # /chat/prd → 8766/chat/prd
    ('/webchat/',    ('webchat_static', '/var/www/webchat/')),
    ('/child-mian/',    ('mian_static', '/var/www/child-mian/')),
    ('/child-liangliang/',    ('liangliang_static', '/var/www/child-liangliang/')),
    ('/health',      ('obsidian_web', '/health')),
]

# 直接放行的路径(静态资源)
PASSTHROUGH = [
    '/assets/',
    '/.well-known/',
]

# 不需要设备检查的内部路径(但走 proxy)
NO_AUTH = [
    '/health',
    '/_device_auth/',
    '/_chat/',
    '/chat/history',
    '/chat/prd',
    '/chat/tasks',
    '/chat/tree',
]

requests.packages.urllib3.disable_warnings()

def match_backend(path):
    """匹配路径到后端,返回 (backend_name, target_prefix, matched_prefix) 或 (None, None, None)"""
    for prefix, (backend, target_prefix) in sorted(DYNAMIC, key=lambda x: -len(x[0])):
        if path == prefix.rstrip('/') or path.startswith(prefix):
            return backend, target_prefix, prefix
    return None, None, None


class AuthGateway(BaseHTTPRequestHandler):
    
    def do_GET(self):
        self._handle()
    
    def do_POST(self):
        self._handle()
    
    def do_HEAD(self):
        self._handle()
    
    def do_OPTIONS(self):
        self._handle()
    
    def do_DELETE(self):
        self._handle()
    
    def do_PUT(self):
        self._handle()
    
    def _handle(self):
        path = urlparse(self.path).path
        
        # ===== 0. 黑名单检查 =====
        device_id = self.headers.get('X-Device-ID', '') or self._get_cookie('device_id')
        if device_id and auth.is_blacklisted(device_id):
            self.send_response(403)
            self.send_header('Content-Type', 'text/plain; charset=utf-8')
            self.end_headers()
            self.wfile.write(b'Device blocked')
            return
        
        # ===== 1. 设备认证内部 API(不经过代理) =====
        if path == '/_device_auth/register':
            self._handle_device_register()
            return
        if path == '/_device_auth/check':
            self._handle_device_check()
            return
        if path == '/_device_auth/fingerprint':
            self._handle_fingerprint_check()
            return
        if path == '/_device_auth/list':
            self._handle_device_list()
            return
        
        # ===== 1. 放行路径 =====
        for p in PASSTHROUGH:
            if path.startswith(p):
                self._proxy()
                return
        
        # ===== 2. 不需要认证的内部路径 =====
        for p in NO_AUTH:
            if path.startswith(p):
                self._proxy()
                return
        
        # ===== 3. 设备管理页面(放行,不拦截) =====
        if path == '/device-auth/' or path == '/device-auth':
            self._serve_device_manager()
            return
        
        # ===== 4. 未授权页面(旧兼容,放行) =====
        if path == '/device-unauthorized/' or path == '/device-unauthorized':
            self._serve_unauthorized_page()
            return
        
        # ===== 5. 匹配动态资源 =====
        backend, target_prefix, matched_prefix = match_backend(path)
        if backend is None:
            # 不在动态列表 → 放行(兼容老路径)
            self._proxy()
            return
        
        # ===== 6. 设备认证 =====
        device_id = self.headers.get('X-Device-ID', '') or self._get_cookie('device_id')
        
        if device_id:
            resource_type = auth.resolve_type(matched_prefix) or 'unknown'
            if auth.is_device_allowed(device_id, resource_type):
                # ✅ 授权通过
                self._proxy(device_id)
                return
        
        # ❌ 未授权 → 返回 403 + 设备指纹采集页面
        if not device_id:
            device_id = str(uuid.uuid4())
            self._set_cookie = device_id
        
        self._record_new_device(device_id, path)
        
        # 返回 403 + 采集页
        self._serve_fingerprint_collector(device_id)
    
    def _handle_device_register(self):
        """浏览器注册 device_id + 前端指纹 + 设备画像"""
        length = int(self.headers.get('Content-Length', 0) or 0)
        body = self.rfile.read(length) if length else b'{}'
        try:
            data = json.loads(body)
            did = data.get('device_id', '')
            redirect = data.get('redirect', '/')
            fingerprint = data.get('fingerprint', '')
            profile = data.get('profile', {})
            
            # 如果有指纹,用指纹查询 / 创建设备身份
            if fingerprint:
                fp_device_id, is_new = auth.get_or_create_fingerprint(
                    fingerprint, profile, device_id=did or None
                )
                did = fp_device_id
            
            if did:
                # 生成人类可读的设备描述
                desc = auth.generate_device_description(profile)
                
                # 如果是新设备(指纹没见过),加入 pending 并推微信
                is_new_device = is_new if fingerprint else True
                if is_new_device and not auth.device_exists(did):
                    ip = self.client_address[0]
                    ua = self.headers.get('User-Agent', '')
                    rtype = auth.resolve_type(data.get('redirect', '/')) or 'unknown'
                    now = datetime.now().isoformat()
                    conn = sqlite3.connect(auth.DB_PATH)
                    # 检查是否已有 pending
                    existing = conn.execute("SELECT 1 FROM pending_auth WHERE device_id=?", (did,)).fetchone()
                    if not existing:
                        conn.execute("""
                            INSERT INTO pending_auth (device_id, fingerprint, user_agent, ip, requested_path, resource_type, created_at)
                            VALUES (?, ?, ?, ?, ?, ?, ?)
                        """, (did, fingerprint, ua[:200], ip, '/fingerprint-register', rtype, now))
                        conn.commit()
                    conn.close()
                    # 推微信
                    self._push_wechat_new_device(did, desc, ua, ip)
                
                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self.send_header('Set-Cookie', f'device_id={did}; Path=/; Max-Age=31536000; SameSite=Lax')
                self.end_headers()
                
                # 检查是否已授权
                authorized = auth.device_exists(did)
                
                self.wfile.write(json.dumps({
                    'ok': True,
                    'redirect': redirect,
                    'device_id': did,
                    'authorized': authorized,
                    'description': desc,
                    'is_new_device': is_new_device,
                }).encode())
                return
        except Exception as e:
            print(f"[auth] register error: {e}")
            import traceback
            traceback.print_exc()
        self.send_response(400)
        self.end_headers()
        self.wfile.write(b'{"ok":false}')
    
    def _handle_fingerprint_check(self):
        """指纹找回:前端用指纹查是否有已授权的设备"""
        length = int(self.headers.get('Content-Length', 0) or 0)
        body = self.rfile.read(length) if length else b'{}'
        try:
            data = json.loads(body)
            fp = data.get('fingerprint', '')
            if fp:
                found = auth.find_device_by_fingerprint(fp)
                if found:
                    self.send_response(200)
                    self.send_header('Content-Type', 'application/json')
                    self.send_header('Set-Cookie', f'device_id={found["device_id"]}; Path=/; Max-Age=31536000; SameSite=Lax')
                    self.end_headers()
                    self.wfile.write(json.dumps({
                        'ok': True,
                        'device_id': found['device_id'],
                        'description': found.get('description', ''),
                    }).encode())
                    return
        except:
            pass
        self.send_response(200)
        self.send_header('Content-Type', 'application/json')
        self.end_headers()
        self.wfile.write(b'{"ok":false}')
    
    def _handle_device_list(self):
        """返回所有已注册设备的列表(含指纹描述、授权状态)"""
        self.send_response(200)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Access-Control-Allow-Origin', '*')
        self.end_headers()
        devices = auth.get_fingerprint_descriptions()
        self.wfile.write(json.dumps(devices, ensure_ascii=False).encode())
    
    def _handle_device_check(self):
        """浏览器轮询授权状态"""
        length = int(self.headers.get('Content-Length', 0) or 0)
        body = self.rfile.read(length) if length else b'{}'
        try:
            data = json.loads(body)
            did = data.get('device_id', '')
            redirect = data.get('redirect', '/')
            if did and auth.device_exists(did):
                # ⚠️ 修复:浏览器传来的完整 UUID 可能跟 Cookie 里的截断版不同
                # 确保 Set-Cookie 用完整的 UUID 覆盖任何旧截断版
                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self.send_header('Set-Cookie', f'device_id={did}; Path=/; Max-Age=31536000; SameSite=Lax')
                self.end_headers()
                self.wfile.write(json.dumps({'ok': True, 'redirect': redirect}).encode())
                return
            # 二次尝试:如果完整 UUID 没找到,尝试截断前16位匹配(兼容旧数据)
            if did and len(did) > 16:
                truncated = did[:16]
                if auth.device_exists(truncated):
                    # 截断版已授权,用完整 UUID 覆盖 Cookie 并更新 whitelist
                    self.send_response(200)
                    self.send_header('Content-Type', 'application/json')
                    self.send_header('Set-Cookie', f'device_id={did}; Path=/; Max-Age=31536000; SameSite=Lax')
                    self.end_headers()
                    self.wfile.write(json.dumps({'ok': True, 'redirect': redirect}).encode())
                    # 后台同步:把完整 UUID 加入 whitelist
                    import threading
                    def _sync():
                        try:
                            conn = sqlite3.connect(auth.DB_PATH)
                            row = conn.execute("SELECT label, allowed_types, authorized_by FROM device_whitelist WHERE device_id=?", (truncated,)).fetchone()
                            if row and not conn.execute("SELECT 1 FROM device_whitelist WHERE device_id=?", (did,)).fetchone():
                                conn.execute("INSERT OR IGNORE INTO device_whitelist (device_id, label, allowed_types, created_at, authorized_at, authorized_by) VALUES (?, ?, ?, datetime('now'), datetime('now'), ?)", (did, row[0], row[1], row[2]))
                                conn.commit()
                            conn.close()
                        except:
                            pass
                    threading.Thread(target=_sync, daemon=True).start()
                    return
        except:
            pass
        self.send_response(200)
        self.send_header('Content-Type', 'application/json')
        self.end_headers()
        self.wfile.write(b'{"ok":false}')
    
    def _record_new_device(self, device_id, path):
        """记录新设备 + 推微信"""
        ip = self.client_address[0]
        ua = self.headers.get('User-Agent', '')
        fingerprint = ua[:100]
        is_new = auth.record_device(device_id, fingerprint, ua, ip, path)
        
        if is_new:
            try:
                pending = auth.get_pending_list(limit=1)
                if pending:
                    p = pending[0]
                    msg = (
                        f"🔐 新设备\n"
                        f"ID: {p['device_id'][:8]}…\n"
                        f"UA: {p['user_agent'][:40]}\n"
                        f"IP: {p['ip']}\n"
                        f"路径: {p['path']}\n"
                        f"分类: {p['resource_type']}\n"
                        f"时间: {p['created_at']}\n\n"
                        f"回复「授权 {p['device_id'][:8]}」开通"
                    )
                    subprocess.Popen(
                        ['hermes', 'send', '--to', 'weixin', msg],
                        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                        env={**os.environ, 'HERMES_NO_TOOL_OUTPUT': '1'}
                    )
                    auth.mark_notified(p['id'])
            except Exception as e:
                print(f"[!] 推微信失败: {e}")

    def _push_wechat_new_device(self, device_id, description, ua, ip):
        """推送新设备通知到微信,带设备画像描述"""
        try:
            msg = (
                f"🔐 新设备\n"
                f"📌 {description or '未知设备'}\n"
                f"ID: {device_id[:8]}…\n"
                f"IP: {ip}\n\n"
                f"回复「授权 {device_id[:8]}」开通"
            )
            subprocess.Popen(
                ['hermes', 'send', '--to', 'weixin', msg],
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                env={**os.environ, 'HERMES_NO_TOOL_OUTPUT': '1'}
            )
        except Exception as e:
            print(f"[!] 推微信失败: {e}")

    def _serve_static(self, root_dir, matched_prefix):
        """直接返回静态文件,根据匹配的前缀剥离 URL 路径"""
        path = urlparse(self.path).path
        suffix = path[len(matched_prefix):] if path.startswith(matched_prefix) else ''
        file_path = root_dir.rstrip('/') + suffix
        if not file_path:
            file_path = root_dir.rstrip('/') + '/index.html'
        if os.path.isdir(file_path):
            file_path = file_path.rstrip('/') + '/index.html'
        try:
            with open(file_path, 'rb') as f:
                content = f.read()
            ext = os.path.splitext(file_path)[1].lower()
            ctype = {
                '.html': 'text/html; charset=utf-8',
                '.css': 'text/css; charset=utf-8',
                '.js': 'application/javascript; charset=utf-8',
                '.png': 'image/png',
                '.jpg': 'image/jpeg',
                '.jpeg': 'image/jpeg',
                '.gif': 'image/gif',
                '.svg': 'image/svg+xml',
                '.ico': 'image/x-icon',
                '.json': 'application/json',
                '.txt': 'text/plain; charset=utf-8',
            }.get(ext, 'application/octet-stream')
            self.send_response(200)
            self.send_header('Content-Type', ctype)
            self.send_header('Content-Length', str(len(content)))
            self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
            self.send_header('Pragma', 'no-cache')
            self.send_header('Expires', '0')
            self.end_headers()
            self.wfile.write(content)
        except FileNotFoundError:
            self.send_response(404)
            self.end_headers()
            self.wfile.write(b'Not Found')

    def _serve_device_manager(self):
        """设备管理页面:展示所有已授权/待授权设备 + 当前设备采集"""
        html = """<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>设备管理</title>
<style>
  *{margin:0;padding:0;box-sizing:border-box;}
  body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
       background:#0a0a0a;color:#e0e0e0;padding:20px;}
  h1{font-size:18px;margin-bottom:16px;color:#facc15;}
  h2{font-size:14px;color:#888;margin:16px 0 8px;}
  .card{background:#111;border-radius:12px;padding:16px;margin-bottom:12px;border:1px solid #222;}
  .device{background:#1a1a1a;border-radius:8px;padding:10px;margin-bottom:8px;font-size:12px;line-height:1.8;}
  .device .desc{color:#4ade80;font-weight:bold;font-size:13px;}
  .device .meta{color:#888;}
  .device .auth{color:#facc15;}
  .device .unauth{color:#f87171;}
  .btn{padding:10px 24px;border:none;border-radius:8px;font-size:14px;cursor:pointer;color:#fff;
       width:100%;margin-top:8px;}
  .btn-upload{background:#1976d2;}
  .btn:disabled{background:#444;cursor:not-allowed;}
  .status{padding:8px 12px;border-radius:8px;font-size:13px;margin-top:8px;}
  .status.loading{background:#2a2a1e;color:#facc15;}
  .status.ok{background:#1a2a1e;color:#4ade80;}
  .status.err{background:#2a1a1e;color:#f87171;}
  pre{font-size:10px;color:#666;max-height:150px;overflow:auto;margin-top:8px;background:#0f0f0f;padding:8px;border-radius:6px;}
  #deviceList{margin-top:12px;}
</style></head>
<body>
  <h1>🔐 设备管理</h1>
  <div class="card" id="currentDevice">
    <div id="myInfo">正在采集当前设备信息...</div>
    <div id="uploadStatus" class="status loading">🔍 采集设备信息中...</div>
    <button id="uploadBtn" class="btn btn-upload" disabled onclick="uploadFingerprint()">📤 上传此设备到服务器</button>
  </div>
  <h2>📋 所有已注册设备</h2>
  <div class="card" id="deviceList">加载中...</div>

<script>
// ===== 设备指纹采集 =====
function hashString(str){let h=0;for(let i=0;i<str.length;i++){h=((h<<5)-h)+str.charCodeAt(i);h=h&h;}return (h>>>0).toString(16).padStart(8,'0');}

function getCanvasFP(){
  const c=document.createElement('canvas');c.width=256;c.height=60;
  const x=c.getContext('2d');
  x.textBaseline='alphabetic';x.font='16px Arial';
  x.fillStyle='#f60';x.fillRect(10,10,40,40);
  x.fillStyle='#069';x.fillText('Hermes Device Auth',5,30);
  x.fillStyle='rgba(102,204,0,0.7)';x.fillText('Device Manager',15,48);
  x.beginPath();x.arc(220,30,18,0,Math.PI*2);
  x.strokeStyle='#f00';x.lineWidth=2;x.stroke();
  for(let i=0;i<10;i++){x.fillStyle='rgba('+(i*25)+','+(100+i*15)+','+(200-i*10)+',0.5)';x.fillRect(50+i*18,5,8,8);}
  return c.toDataURL();
}

function getWebGL(){
  try{
    const c=document.createElement('canvas');
    const g=c.getContext('webgl')||c.getContext('experimental-webgl');
    if(!g)return{R:'无',V:'无'};
    const e=g.getExtension('WEBGL_debug_renderer_info');
    return{R:e?g.getParameter(e.UNMASKED_RENDERER_WEBGL):'不暴露',V:e?g.getParameter(e.UNMASKED_VENDOR_WEBGL):'不暴露'};
  }catch(e){return{R:'error',V:'error'};}
}

async function getAudioFP(){
  try{
    const a=new(window.AudioContext||window.webkitAudioContext)();
    const o=a.createOscillator();const n=a.createAnalyser();const g=a.createGain();
    o.type='sawtooth';o.frequency.value=440;
    o.connect(n);n.connect(g);g.gain.value=0;
    const d=new Uint8Array(n.frequencyBinCount);o.start(0);
    await new Promise(r=>setTimeout(r,100));
    n.getByteFrequencyData(d);let h=0;
    for(let i=0;i<100;i++){h=((h<<5)-h)+d[i];h=h&h;}
    o.stop();a.close();return h;
  }catch(e){return'no-audio';}
}

async function collectProfile(){
  const webgl=getWebGL();const audioFp=await getAudioFP();const canvasFp=getCanvasFP();
  const ua=navigator.userAgent;
  
  // 系统识别
  let os='';let browser='';let browserVersion='';let deviceBrand='';let deviceModel='';
  const ul=ua.toLowerCase();
  
  if(/openharmony|harmonyos/i.test(ua)){const v=ua.match(/(?:OpenHarmony|HarmonyOS)\\s*([\\d.]+)/i);os='鸿蒙 '+(v?v[1]:'');}
  else if(/android (\\d+[._]\\d+)/i.test(ua)){os='Android '+RegExp.$1.replace('_','.');}
  else if(/iphone.*os (\\d+[._]\\d+)/i.test(ua)){os='iOS '+RegExp.$1.replace('_','.');}
  else if(/ipad.*os (\\d+[._]\\d+)/i.test(ua)){os='iPadOS '+RegExp.$1.replace('_','.');}
  else if(/mac os x (\\d+[._]\\d+)/i.test(ua)){os='macOS '+RegExp.$1.replace('_','.');}
  else if(/windows nt 10/i.test(ua))os='Windows 10';else if(/windows nt 11/i.test(ua))os='Windows 11';
  else if(/linux/i.test(ua))os='Linux';else os=ua.match(/\\(([^)]+)\\)/)?.[1]||'未知';
  
  // 品牌
  if(/huawei|honor|arkweb/i.test(ul))deviceBrand='华为';
  else if(/oppo|oneplus|realme/i.test(ul))deviceBrand=/oppo/i.test(ul)?'OPPO':/oneplus/i.test(ul)?'OnePlus':'Realme';
  else if(/vivo|iqoo/i.test(ul))deviceBrand=/vivo/i.test(ul)?'vivo':'iQOO';
  else if(/xiaomi|mi\\s|redmi|poco/i.test(ul))deviceBrand='小米';
  else if(/samsung|sm-/i.test(ul))deviceBrand='三星';
  else if(/iphone|ipad/i.test(ul))deviceBrand='Apple';
  else if(/macintosh|mac os/i.test(ul))deviceBrand='Mac';
  else if(/windows/i.test(ul))deviceBrand='Windows PC';
  else if(/linux/i.test(ul))deviceBrand='Linux';
  
  // 型号
  if(deviceBrand==='华为'){
    const hm=ua.match(/(?:[A-Z]{3,4}-(?:AL|TL|DL|L|N)\\d{2,3}|BRA-AL00|JAD-AL[0-9]{2}|JAD-LX[0-9]{2}|TET-AN[0-9]{2}|NOP-AN[0-9]{2})/i);
    if(hm)deviceModel=hm[0].toUpperCase();
    const av=ua.match(/ArkWeb\\/([\\d.]+)/);
    if(av&&!deviceModel)deviceModel='华为('+av[0]+')';
    if(!deviceModel&&/openharmony/i.test(ua))deviceModel='鸿蒙设备';
  }else if(deviceBrand==='三星'){const sm=ua.match(/SM-[A-Z0-9]+/i);if(sm)deviceModel=sm[0].toUpperCase();}
  else if(deviceBrand==='小米'){const mi=ua.match(/(?:MI\\s\\d[A-Z]*|MIX\\s\\d|Redmi\\s[A-Z0-9]+)/i);if(mi)deviceModel=mi[0];}
  else if(/Apple|Mac/i.test(deviceBrand)){const ip=ua.match(/(iPhone\\d+,\\d+|iPad\\d+,\\d+)/i);if(ip)deviceModel=ip[0];}
  
  // 浏览器
  if(/micromessenger/i.test(ua)){browser='微信';const w=ua.match(/MicroMessenger\\/([\\d.]+)/i);if(w)browserVersion=w[1];}
  else if(/edg/i.test(ua)){browser='Edge';browserVersion=ua.match(/edg\\/([\\d.]+)/i)?.[1]||'';}
  else if(/chrome/i.test(ua)){browser='Chrome';browserVersion=ua.match(/chrome\\/([\\d.]+)/i)?.[1]||'';}
  else if(/safari/i.test(ua)){browser='Safari';browserVersion=ua.match(/version\\/([\\d.]+)/i)?.[1]||'';}
  else if(/firefox/i.test(ua)){browser='Firefox';browserVersion=ua.match(/firefox\\/([\\d.]+)/i)?.[1]||'';}
  
  // 构造profile
  const profile={
    userAgent:ua,os,browser,browserVersion,deviceBrand,deviceModel,
    platform:navigator.platform||'',language:navigator.language,
    sec_ch_ua:navigator.userAgentData?.brands?.map(b=>b.brand+' v'+b.version).join(', ')||'',
    sec_ch_ua_platform:navigator.userAgentData?.platform||'',
    sec_ch_ua_model:navigator.userAgentData?.model||'',
    screenWidth:screen.width,screenHeight:screen.height,screenColorDepth:screen.colorDepth,
    devicePixelRatio:window.devicePixelRatio||1,orientation:screen.orientation?.type||'',
    hardwareConcurrency:navigator.hardwareConcurrency||'未知',deviceMemory:navigator.deviceMemory||'未知',
    maxTouchPoints:navigator.maxTouchPoints||0,touchDevice:'ontouchstart' in window,
    webglRenderer:webgl.R,webglVendor:webgl.V,audioFingerprint:audioFp,canvasData:canvasFp,
    timezone:Intl.DateTimeFormat().resolvedOptions().timeZone,timezoneOffset:new Date().getTimezoneOffset(),
    cookiesEnabled:navigator.cookieEnabled,localTime:new Date().toString(),
  };
  
  const hashInput=canvasFp+'|'+webgl.R+'|'+webgl.V+'|'+audioFp+'|'+screen.width+'x'+screen.height+'x'+screen.colorDepth+'|'+window.devicePixelRatio+'|'+navigator.hardwareConcurrency;
  const fpHash=hashString(hashInput);
  return{profile,fpHash};
}

// ===== 上传 =====
async function uploadFingerprint(){
  const btn=document.getElementById('uploadBtn');
  const st=document.getElementById('uploadStatus');
  btn.disabled=true;btn.textContent='⏳ 上传中...';
  st.className='status loading';st.textContent='📤 上传到服务器...';
  try{
    const fp=window.__fp;const profile=window.__profile;
    const did=localStorage.getItem('hdid')||'d'+Date.now()+Math.random().toString(36).slice(2);
    localStorage.setItem('hdid',did);
    
    const r=await fetch('/_device_auth/register',{
      method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({device_id:did,redirect:location.pathname, fingerprint:fp,profile})
    });
    const d=await r.json();
    if(d.ok){
      st.className='status ok';
      st.textContent='✅ 已上传!设备: '+(d.description||'未知')+(d.authorized?'\\n✅ 已授权':'\\n⏳ 等待管理员授权...');
      if(d.authorized){btn.textContent='✅ 已授权';}else{btn.textContent='⏳ 等待授权';setTimeout(loadDeviceList,3000);}
    }else{st.className='status err';st.textContent='❌ 上传失败';btn.disabled=false;btn.textContent='🔄 重试';}
  }catch(e){st.className='status err';st.textContent='❌ 错误: '+e.message;btn.disabled=false;btn.textContent='🔄 重试';}
}

// ===== 设备列表 =====
async function loadDeviceList(){
  try{
    const r=await fetch('/_device_auth/list');
    const devices=await r.json();
    const el=document.getElementById('deviceList');
    if(!devices||devices.length===0){el.innerHTML='<div style="color:#666;font-size:13px;">暂无设备记录</div>';return;}
    el.innerHTML=devices.map(d=>{
      const a=d.authorized?'✅ 已授权':'⏳ 待授权';
      const aCls=d.authorized?'auth':'unauth';
      return '<div class="device"><div class="desc">'+escHtml(d.description||'未知设备')+'</div>'+
        '<div class="meta">编号: '+escHtml(d.device_id?.substring(0,12)||'')+'…</div>'+
        '<div class="meta">指纹: '+escHtml(d.fingerprint||'')+'</div>'+
        '<div class="meta">最后在线: '+(d.last_seen_at||'')+'</div>'+
        '<div class="'+aCls+'">'+a+'</div></div>';
    }).join('');
  }catch(e){document.getElementById('deviceList').innerHTML='<div style="color:#f87171;">加载失败: '+e.message+'</div>';}
}
function escHtml(s){if(!s)return'';return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}

// ===== 主流程 =====
(async function(){
  const st=document.getElementById('uploadStatus');
  try{
    const{profile,fpHash}=await collectProfile();
    window.__fp=fpHash;window.__profile=profile;
    
    document.getElementById('myInfo').innerHTML=
      '<div style="font-size:12px;line-height:1.8;">'+
      '🔑 指纹: <span style="color:#4ade80;font-weight:bold;">'+fpHash+'</span><br>'+
      '📱 设备: '+escHtml(profile.deviceBrand||'')+' '+escHtml(profile.deviceModel||'')+'<br>'+
      '💻 系统: '+escHtml(profile.os||'')+'<br>'+
      '🌐 浏览器: '+escHtml(profile.browser||'')+' '+escHtml(profile.browserVersion||'')+'<br>'+
      '🕐 时区: '+escHtml(profile.timezone||'')+'<br>'+
      '📐 屏幕: '+profile.screenWidth+'×'+profile.screenHeight+' (x'+profile.devicePixelRatio+')'+
      '</div>';
    
    st.className='status ok';st.textContent='✅ 采集完成,点击上传';
    document.getElementById('uploadBtn').disabled=false;
    
    // 加载设备列表
    loadDeviceList();
  }catch(e){st.className='status err';st.textContent='采集失败: '+e.message;}
})();
</script>
</body></html>"""
        self.send_response(200)
        self.send_header('Content-Type', 'text/html; charset=utf-8')
        self.end_headers()
        self.wfile.write(html.encode('utf-8'))

    def _serve_fingerprint_collector(self, device_id):
        """403 页面:设备未授权 + 嵌入指纹采集器(极简版,兼容微信浏览器)"""
        html = """<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>设备未授权</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
     background:#0a0a0a;color:#e0e0e0;padding:20px;}
h1{font-size:18px;margin-bottom:4px;color:#facc15;}
.sub{font-size:12px;color:#666;margin-bottom:12px;}
.card{background:#111;border-radius:12px;padding:14px;margin-bottom:10px;border:1px solid #222;}
.fp{font-size:18px;font-weight:bold;color:#4ade80;text-align:center;padding:6px 0;}
.row{display:flex;justify-content:space-between;padding:3px 0;font-size:12px;border-bottom:1px solid #1a1a1a;}
.row:last-child{border-bottom:none;}
.row .l{color:#888;}
.row .v{color:#ddd;word-break:break-all;}
.btn{display:block;padding:14px;border:none;border-radius:10px;font-size:16px;font-weight:600;
      cursor:pointer;color:#fff;width:100%;margin-top:8px;text-align:center;}
.btn-up{background:#1976d2;}
.btn:disabled{background:#444;cursor:not-allowed;}
.st{padding:8px 12px;border-radius:8px;font-size:13px;margin-top:8px;text-align:center;}
.st.loading{background:#2a2a1e;color:#facc15;}
.st.ok{background:#1a2a1e;color:#4ade80;}
.st.err{background:#2a1a1e;color:#f87171;}
</style></head>
<body>
<h1>&#128274; 此设备未授权</h1>
<p class="sub">点击按钮将设备信息发送给管理员,授权后自动放行</p>

<div class="card"><div class="fp" id="fpD">计算中…</div></div>
<div class="card"><div id="infoD"></div></div>
<div class="card"><div id="hwD"></div></div>
<div class="card"><div id="netD"></div></div>
<div class="card">
  <div id="stD" class="st loading">&#128269; 采集设备信息…</div>
  <button id="upBtn" class="btn btn-up" disabled>&#128228; 上传并请求授权</button>
</div>

<script>
(function(){
var did = localStorage.getItem('hdid');
if(!did){did='d'+Date.now()+Math.random().toString(36).slice(2);localStorage.setItem('hdid',did);}
var redir = location.pathname+location.search;
function esc(s){if(!s)return'';return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function r(l,v){return '<div class=\"row\"><span class=\"l\">'+l+'</span><span class=\"v\">'+v+'</span></div>';}
function hash(s){var h=0;for(var i=0;i<s.length;i++){h=((h<<5)-h)+s.charCodeAt(i);h=h&h;}return (h>>>0).toString(16).padStart(8,'0');}

function getCFP(){
  try{
    var c=document.createElement('canvas');c.width=200;c.height=40;
    var x=c.getContext('2d');
    x.textBaseline='top';x.font='12px Arial';
    x.fillStyle='#f60';x.fillRect(5,5,20,20);
    x.fillStyle='#069';x.fillText('Fp'+Date.now(),5,5);
    x.fillStyle='rgba(102,204,0,0.5)';x.fillText('DeviceAuth',10,20);
    return c.toDataURL();
  }catch(e){return 'no-canvas';}
}

function collectBasic(){
  var fp={};
  var ua=navigator.userAgent;
  fp.ua=ua;
  
  if(/openharmony|harmonyos/i.test(ua)) fp.os='鸿蒙';
  else if(/android/i.test(ua)) fp.os='Android';
  else if(/iphone|ipad/i.test(ua)) fp.os='iOS/iPadOS';
  else if(/mac os/i.test(ua)) fp.os='macOS';
  else if(/windows/i.test(ua)) fp.os='Windows';
  else if(/linux/i.test(ua)) fp.os='Linux';
  
  if(/huawei|honor|arkweb/i.test(ua)) fp.brand='华为';
  else if(/xiaomi|mi\s|redmi/i.test(ua)) fp.brand='小米';
  else if(/samsung|sm-/i.test(ua)) fp.brand='三星';
  else if(/iphone|ipad/i.test(ua)) fp.brand='Apple';
  else if(/macintosh/i.test(ua)) fp.brand='Mac';
  else if(/windows/i.test(ua)) fp.brand='Windows PC';
  else if(/linux/i.test(ua)) fp.brand='Linux';
  
  if(fp.brand==='华为'){
    var hm=ua.match(/(?:[A-Z]{3,4}-(?:AL|TL|DL|L|N)\d{2,3})/i);
    if(hm) fp.model=hm[0].toUpperCase();
    var av=ua.match(/ArkWeb\/([\d.]+)/);
    if(av&&!fp.model) fp.model='华为('+av[0]+')';
  }
  if(fp.brand==='三星'){var sm=ua.match(/SM-[A-Z0-9]+/i);if(sm)fp.model=sm[0].toUpperCase();}
  if(fp.brand==='小米'){var mi=ua.match(/(?:MI\s\d[A-Z]*|MIX|Redmi\s[A-Z0-9]+)/i);if(mi)fp.model=mi[0];}
  
  fp.screen=screen.width+'x'+screen.height;
  fp.dpr=window.devicePixelRatio||1;
  fp.touch='ontouchstart' in window;
  fp.tz=Intl.DateTimeFormat().resolvedOptions().timeZone;
  fp.lang=navigator.language;
  fp.cores=navigator.hardwareConcurrency||'?';
  fp.platform=navigator.platform||'';
  fp.browser='';
  if(/micromessenger/i.test(ua)) fp.browser='微信';
  else if(/edg/i.test(ua)) fp.browser='Edge';
  else if(/chrome/i.test(ua)) fp.browser='Chrome';
  else if(/safari/i.test(ua)) fp.browser='Safari';
  else if(/firefox/i.test(ua)) fp.browser='Firefox';
  fp.bwVer=(ua.match(/(?:Chrome|Edg|Firefox|Version)\/([\d.]+)/)||[])[1]||'';
  fp.wxVer=(ua.match(/MicroMessenger\/([\d.]+)/)||[])[1]||'';
  fp.wechat=fp.wxVer?'是('+fp.wxVer+')':'否';
  
  var cfp=getCFP();
  fp.cfp=cfp;
  var raw=cfp+'|'+fp.screen+'|'+fp.dpr+'|'+(fp.cores||'');
  fp.hash=hash(raw);
  
  return fp;
}

async function doCollect(){
  try{
    var fp=collectBasic();
    
    document.getElementById('fpD').textContent=fp.hash;
    document.getElementById('infoD').innerHTML=
      r('&#128241; 品牌',esc(fp.brand||'?'))+r('&#128241; 型号',esc(fp.model||'?'))+
      r('&#128187; 系统',esc(fp.os||'?'))+
      r('&#127760; 浏览器',esc(fp.browser+(fp.bwVer?' '+fp.bwVer:'')))+
      r('&#128500; 微信',fp.wechat)+
      r('&#128344; 时区',esc(fp.tz||'?'))+
      r('&#128208; 分辨率',fp.screen+' (x'+fp.dpr+')')+
      r('&#128483; 语言',esc(fp.lang||'?'));
    document.getElementById('hwD').innerHTML=
      r('&#129504; CPU',String(fp.cores||'?'))+
      r('&#128241; 触控',String(fp.touch));
    
    try{
      var r2=await fetch('https://ipapi.co/json/');
      if(r2.ok){var d2=await r2.json();
        fp.ip=d2.ip;fp.loc=[d2.city,d2.region,d2.country_name].filter(Boolean).join(' ');
        fp.isp=d2.org||'';
        document.getElementById('netD').innerHTML=r('&#127760; IP',esc(fp.ip||''))+r('&#128205; 归属地',esc(fp.loc||''));}
    }catch(e){}
    
    window.__fp=fp.hash;
    window.__profile=fp;
    
    var st=document.getElementById('stD');
    st.className='st ok';
    st.textContent='&#9989; 采集完成,点击上传';
    document.getElementById('upBtn').disabled=false;
    document.getElementById('upBtn').onclick=uploadFP;
  }catch(e){
    var st=document.getElementById('stD');
    st.className='st err';
    st.textContent='采集失败: '+e.message;
    document.getElementById('upBtn').disabled=false;
    document.getElementById('upBtn').textContent='&#128228; 紧急上传(仅UA)';
    document.getElementById('upBtn').onclick=emergencyUpload;
  }
}

async function uploadFP(){
  var btn=document.getElementById('upBtn');var st=document.getElementById('stD');
  btn.disabled=true;btn.textContent='&#9203; 上传中...';
  st.className='st loading';st.textContent='&#128228; 正在上传...';
  try{
    var r=await fetch('/_device_auth/register',{
      method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({device_id:did,redirect:redir,fingerprint:window.__fp,profile:window.__profile})
    });
    var d=await r.json();
    if(d.ok){
      st.className='st ok';
      st.textContent='&#9989; 已发送,等待授权...\n设备: '+(d.description||'');
      btn.textContent='&#9203; 等待授权';
      pollAuth();
    }else{st.className='st err';st.textContent='&#10060; 上传失败';btn.disabled=false;btn.textContent='&#128260; 重试';}
  }catch(e){st.className='st err';st.textContent='&#10060; '+e.message;btn.disabled=false;btn.textContent='&#128260; 重试';}
}

async function emergencyUpload(){
  window.__profile={ua:navigator.userAgent,emergency:true};
  window.__fp='emergency-'+Date.now();
  await uploadFP();
}

async function pollAuth(){
  try{
    var r=await fetch('/_device_auth/check',{
      method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({device_id:did,redirect:redir})
    });
    var d=await r.json();
    if(d.ok){
      document.getElementById('stD').className='st ok';
      document.getElementById('stD').textContent='&#9989; 已授权!即将跳转...';
      document.cookie='device_id='+did+';path=/;max-age='+(365*86400);
      setTimeout(function(){location.href=d.redirect||'/';},1200);
      return;
    }
  }catch(e){}
  setTimeout(pollAuth,3000);
}

doCollect();
})();
</script>
</body></html>"""
        self.send_response(403)
        self.send_header('Content-Type', 'text/html; charset=utf-8')
        self.send_header('Set-Cookie', f'device_id={device_id}; Path=/; Max-Age=31536000; SameSite=Lax')
        self.end_headers()
        self.wfile.write(html.encode('utf-8'))

    def _serve_unauthorized_page(self):
        """设备未授权页面"""
        qs = parse_qs(urlparse(self.path).query)
        redirect = qs.get('redirect', ['/'])[0]
        device_id = qs.get('device_id', [''])[0]
        
        html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>设备未授权</title>
<style>
  *{{margin:0;padding:0;box-sizing:border-box;}}
  body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
        background:#0a0a0a;color:#e0e0e0;height:100vh;display:flex;
        align-items:center;justify-content:center;}}
  .card{{background:#111;border-radius:16px;padding:40px;max-width:420px;
         text-align:center;border:1px solid #222;}}
  .icon{{font-size:64px;margin-bottom:20px;}}
  h1{{font-size:20px;margin-bottom:12px;color:#facc15;}}
  p{{font-size:14px;color:#888;line-height:1.8;}}
  .did{{font-size:11px;color:#555;margin-top:20px;word-break:break-all;
        padding:8px;background:#1a1a1a;border-radius:8px;}}
  .s{{display:inline-block;margin-top:20px;padding:8px 20px;
      border-radius:20px;font-size:13px;background:#2a2a1e;color:#facc15;}}
  .s.ok{{background:#1a2a1e;color:#4ade80;}}
</style></head>
<body><div class="card">
  <div class="icon">🔐</div>
  <h1>本设备未授权</h1>
  <p>已通知管理员<br>授权通过后自动跳转</p>
  <div class="did" id="did">{device_id[:16] or '生成中...'}</div>
  <div class="s" id="st">⏳ 等待授权...</div>
</div>
<script>
let did = localStorage.getItem('hdid');
if(!did){{did=(crypto.randomUUID||(()->'d'+Date.now()+Math.random().toString(36).slice(2)))();localStorage.setItem('hdid',did);}}
document.getElementById('did').textContent=did;
// 注册 device
fetch('/_device_auth/register',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{device_id:did,redirect:'{redirect}'}})}}).catch(()=>{{}});
// 轮询
async function poll(){{
  try{{
    let r=await fetch('/_device_auth/check',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{device_id:did}})}});
    let d=await r.json();
    if(d.ok){{
      document.getElementById('st').textContent='✅ 已授权,跳转中...';
      document.getElementById('st').className='s ok';
      document.cookie='device_id='+did+';path=/;max-age='+(365*86400);
      setTimeout(()=>location.href=d.redirect||'/',1200);
    }}
  }}catch(e){{}}
  setTimeout(poll,3000);
}}
setTimeout(poll,2000);
</script></body></html>"""
        self.send_response(200)
        self.send_header('Content-Type', 'text/html; charset=utf-8')
        if device_id:
            self.send_header('Set-Cookie', f'device_id={device_id}; Path=/; Max-Age=31536000; SameSite=Lax')
        self.end_headers()
        self.wfile.write(html.encode('utf-8'))
    
    def _proxy(self, device_id=None):
        """代理请求到后端"""
        # 找后端
        path = urlparse(self.path).path
        backend, target_prefix, matched_prefix = match_backend(path)
        
        # ═══ 调试日志 ═══
        print(f"[PROXY] path={path} matched=({matched_prefix})→{backend} target_prefix={target_prefix}", flush=True)
        # ═══ ═══
        if not backend:
            # 没匹配到 → 默认走 Flask
            backend = 'obsidian_web'
            target_prefix = path
            matched_prefix = path  # fallback: use path directly
        
        # 特殊处理:静态文件直接返回
        if backend == 'webchat_static':
            self._serve_static(target_prefix, matched_prefix)
            return
        if backend == 'mian_static':
            self._serve_static(target_prefix, matched_prefix)
            return
        if backend == 'liangliang_static':
            self._serve_static(target_prefix, matched_prefix)
            return
        if backend == 'chat_static':
            self._serve_static(target_prefix, matched_prefix)
            return
        
        target = BACKENDS.get(backend)
        if not target:
            self.send_response(502)
            self.end_headers()
            self.wfile.write(b'Bad Gateway: unknown backend')
            return
        
        # 构造目标 URL(替换路径前缀以实现 /ask → /chat 等映射)
        qs = urlparse(self.path).query
        # matched_prefix and target_prefix are from match_backend(); in fallback both equal path
        suffix = ''
        if path.startswith(matched_prefix):
            suffix = path[len(matched_prefix):]
        url = target + target_prefix + suffix
        # target_prefix 为空时保持原始路径
        if not target_prefix:
            url = target.rstrip('/') + '/' + path.lstrip('/')
        url = url.rstrip('/')
        if qs:
            url += '?' + qs
        
        # 读取 body
        body = None
        if self.command in ('POST', 'PUT', 'PATCH', 'DELETE'):
            length = int(self.headers.get('Content-Length', 0) or 0)
            if length > 0:
                body = self.rfile.read(length)
        
        # 构造 headers(去掉 hop-by-hop)
        headers = {}
        skip = {'host', 'content-length', 'transfer-encoding', 'connection', 'x-device-id'}
        for k, v in self.headers.items():
            if k.lower() not in skip:
                headers[k] = v
        
        # 透传原始 IP
        headers['X-Real-IP'] = self.client_address[0]
        headers['X-Forwarded-For'] = self.headers.get('X-Forwarded-For', '') + ',' + self.client_address[0]
        
        try:
            resp = requests.request(
                method=self.command,
                url=url,
                data=body,
                headers=headers,
                timeout=120,
                stream=True,
                allow_redirects=False,
            )
            
            self.send_response(resp.status_code)
            # 回传响应头
            for k, v in resp.headers.items():
                if k.lower() not in ('transfer-encoding', 'content-encoding'):
                    self.send_header(k, v)
            # 设置 Cookie(如果有新 device_id)
            if device_id:
                self.send_header('Set-Cookie', f'device_id={device_id}; Path=/; Max-Age=31536000; SameSite=Lax')
            self.end_headers()
            
            # 流式回传
            for chunk in resp.iter_content(chunk_size=65536):
                if chunk:
                    self.wfile.write(chunk)
                    self.wfile.flush()
        
        except requests.exceptions.Timeout:
            self.send_response(504)
            self.end_headers()
            self.wfile.write(b'Gateway Timeout')
        except requests.exceptions.ConnectionError as e:
            self.send_response(502)
            self.end_headers()
            self.wfile.write(f'Backend connection failed: {e}'.encode())
        except Exception as e:
            self.send_response(502)
            self.end_headers()
            self.wfile.write(f'Proxy error: {e}'.encode())
    
    def _get_cookie(self, name):
        cookies = self.headers.get('Cookie', '')
        for c in cookies.split(';'):
            c = c.strip()
            if c.startswith(name + '='):
                return c[len(name)+1:]
        return ''
    
    def log_message(self, fmt, *args):
        path = urlparse(self.path).path
        did = self._get_cookie('device_id') or self.headers.get('X-Device-ID', '')[:8]
        print(f"[AUTH] {self.command} {path} -> {args[1]} {args[2]}  did={did}")


def main():
    print(f"\n{'='*50}")
    print(f"  设备认证网关 (port {AUTH_PORT})")
    print(f"  动态请求 → 检查 Cookie → 授权放行 / 未授权拦截")
    print(f"  白名单: device_auth.db")
    print(f"{'='*50}\n")
    server = HTTPServer(('0.0.0.0', AUTH_PORT), AuthGateway)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n网关已关闭")
        server.server_close()

if __name__ == '__main__':
    main()