| import os |
| import pty |
| import asyncio |
| import re |
| import secrets |
| import shutil |
| from pydantic import BaseModel |
| |
| from fastapi import FastAPI, WebSocket, UploadFile, Request, Response, Depends, HTTPException, status, Form, Cookie |
| from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse |
| import uvicorn |
|
|
| app = FastAPI() |
|
|
| PASSWORD = os.getenv("PASSWORD", "123456") |
|
|
| |
| |
| |
| def verify_auth(auth_token: str = Cookie(default=None)): |
| if not auth_token or not secrets.compare_digest(auth_token, PASSWORD): |
| raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无权访问,请先登录") |
| return auth_token |
|
|
| |
| |
| |
| LOGIN_PAGE = """ |
| <!DOCTYPE html> |
| <html> |
| <head><title>Admin Login</title></head> |
| <body style="background:#1e1e1e; color:#fff; display:flex; justify-content:center; align-items:center; height:100vh; font-family:sans-serif;"> |
| <form method="post" action="/login" style="background:#2d2d2d; padding:40px; border-radius:8px; text-align:center; box-shadow: 0 4px 15px rgba(0,0,0,0.5);"> |
| <h2 style="margin-top:0;">Web Shell Login</h2> |
| <input type="password" name="password" placeholder="请输入终端密码" style="padding:10px; margin-bottom:20px; width:220px; border:1px solid #555; background:#1e1e1e; color:#fff; border-radius:4px;"><br> |
| <button type="submit" style="padding:10px 30px; background:#007acc; color:white; border:none; border-radius:4px; cursor:pointer; font-size:16px;">登 入</button> |
| </form> |
| </body> |
| </html> |
| """ |
|
|
| |
| |
| |
| HTML_PAGE = """ |
| <!DOCTYPE html> |
| <html> |
| <head> |
| <title>HF Web Shell (Pro)</title> |
| <!-- Xterm.js --> |
| <link rel="stylesheet" href="https://unpkg.com/xterm/css/xterm.css" /> |
| <script src="https://unpkg.com/xterm/lib/xterm.js"></script> |
| <script src="https://unpkg.com/xterm-addon-fit/lib/xterm-addon-fit.js"></script> |
| <!-- CodeMirror (自带行号和侧边栏) --> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/codemirror.min.css"> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/theme/darcula.min.css"> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/codemirror.min.js"></script> |
| |
| <style> |
| body { margin: 0; padding: 0; background: #1e1e1e; color: #ccc; font-family: sans-serif; display: flex; height: 100vh; overflow: hidden; } |
| #sidebar { width: 280px; min-width: 280px; background: #252526; display: flex; flex-direction: column; border-right: 1px solid #444; } |
| .sidebar-header { padding: 10px; background: #333; display: flex; flex-direction: column; gap: 5px; } |
| .path-display { font-size: 12px; background: #1e1e1e; padding: 5px; border-radius: 3px; word-break: break-all; } |
| .toolbar { display: flex; gap: 5px; } |
| .toolbar button { flex: 1; padding: 5px; background: #007acc; color: white; border: none; cursor: pointer; border-radius: 2px; font-size: 12px; } |
| .toolbar button:hover { background: #005f9e; } |
| #file-list { flex: 1; overflow-y: auto; list-style: none; padding: 0; margin: 0; } |
| .file-item { padding: 6px 10px; font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid #333; user-select: none; } |
| .file-item:hover { background: #2a2d2e; } |
| .file-item.selected { background: #37373d; color: white; } |
| #main-area { flex: 1; display: flex; flex-direction: column; position: relative; } |
| #terminal-container { flex: 1; padding: 10px; } |
| #editor-panel { position: absolute; right: 0; top: 0; bottom: 0; width: 50%; background: #2b2b2b; border-left: 1px solid #444; display: none; flex-direction: column; z-index: 10; box-shadow: -5px 0 15px rgba(0,0,0,0.5); } |
| .editor-header { padding: 10px; background: #3c3f41; display: flex; justify-content: space-between; align-items: center; } |
| .editor-header span { font-size: 14px; font-weight: bold; color: #fff; } |
| .editor-header button { padding: 5px 15px; background: #4caf50; color: white; border: none; cursor: pointer; border-radius: 3px; } |
| .editor-header button.close { background: #f44336; } |
| #code-editor { flex: 1; overflow: hidden; } |
| .CodeMirror { height: 100%; font-family: monospace; font-size: 14px; } |
| #fileUpload { display: none; } |
| </style> |
| </head> |
| <body> |
| <div id="sidebar"> |
| <div class="sidebar-header"> |
| <div class="path-display" id="current-path">/app</div> |
| <div class="toolbar"> |
| <button onclick="goUp()">⬆️ 上级</button> |
| <button onclick="document.getElementById('fileUpload').click()">📤 上传</button> |
| <button onclick="downloadSelected()">⬇️ 下载</button> |
| </div> |
| <div class="toolbar"> |
| <button onclick="actionCopy()" id="btn-copy">📋 复制</button> |
| <button onclick="actionPaste()" id="btn-paste" style="display:none; background:#d84315;">📋 粘贴</button> |
| <button onclick="actionDelete()" style="background:#c62828;">🗑️ 删除</button> |
| </div> |
| <input type="file" id="fileUpload" multiple onchange="uploadFiles()"> |
| </div> |
| <ul id="file-list"></ul> |
| </div> |
| <div id="main-area"> |
| <div id="terminal-container"></div> |
| <div id="editor-panel"> |
| <div class="editor-header"> |
| <span id="editor-title">Editing...</span> |
| <div> |
| <button onclick="saveFile()">💾 保存 (Ctrl+S)</button> |
| <button class="close" onclick="closeEditor()">❌ 退出</button> |
| </div> |
| </div> |
| <div id="code-editor"></div> |
| </div> |
| </div> |
| |
| <script> |
| let currentPath = "/app"; |
| let selectedFile = null; |
| let clipboard = { action: null, path: null }; |
| let cmEditor = null; |
| let currentEditPath = null; |
| |
| const term = new Terminal({ cursorBlink: true, convertEol: true, theme: { background: '#1e1e1e' } }); |
| const fitAddon = new FitAddon.FitAddon(); |
| term.loadAddon(fitAddon); |
| term.open(document.getElementById('terminal-container')); |
| fitAddon.fit(); |
| window.addEventListener('resize', () => fitAddon.fit()); |
| |
| const wsProtocol = location.protocol === 'https:' ? 'wss://' : 'ws://'; |
| const ws = new WebSocket(wsProtocol + location.host + '/ws'); |
| ws.onopen = () => term.writeln('\\x1b[32m[+] 容器终端连接成功!\\x1b[0m\\r\\n'); |
| ws.onmessage = (evt) => term.write(evt.data); |
| term.onData((data) => ws.send(data)); |
| |
| cmEditor = CodeMirror(document.getElementById('code-editor'), { theme: 'darcula', lineNumbers: true, lineWrapping: true, indentUnit: 4 }); |
| document.addEventListener('keydown', e => { |
| if (e.ctrlKey && e.key === 's' && document.getElementById('editor-panel').style.display === 'flex') { |
| e.preventDefault(); saveFile(); |
| } |
| }); |
| |
| async function loadDir(path) { |
| const res = await fetch(`/api/fs/list?path=${encodeURIComponent(path)}`); |
| if (!res.ok) return alert("身份验证失效,请刷新页面重新登录"); |
| const data = await res.json(); |
| if (data.status !== "ok") return alert(data.msg); |
| |
| currentPath = data.path; document.getElementById('current-path').innerText = currentPath; selectedFile = null; |
| const ul = document.getElementById('file-list'); ul.innerHTML = ''; |
| |
| data.items.forEach(item => { |
| const li = document.createElement('li'); li.className = 'file-item'; |
| li.innerHTML = `${item.is_dir ? '📁' : '📄'} ${item.name}`; |
| li.onclick = () => { document.querySelectorAll('.file-item').forEach(el => el.classList.remove('selected')); li.classList.add('selected'); selectedFile = `${currentPath}/${item.name}`.replace('//', '/'); }; |
| li.ondblclick = () => { const target = `${currentPath}/${item.name}`.replace('//', '/'); item.is_dir ? loadDir(target) : openEditor(target); }; |
| ul.appendChild(li); |
| }); |
| } |
| function goUp() { if (currentPath === '/') return; const parts = currentPath.split('/').filter(Boolean); parts.pop(); loadDir('/' + (parts.join('/') || '')); } |
| async function uploadFiles() { |
| const files = document.getElementById('fileUpload').files; if(!files.length) return; |
| const fd = new FormData(); for(let f of files) fd.append('files', f); |
| const res = await fetch(`/upload?path=${encodeURIComponent(currentPath)}`, { method: 'POST', body: fd }); |
| if(res.ok) loadDir(currentPath); else alert('上传失败'); |
| } |
| function downloadSelected() { if (selectedFile) window.open('/download?path=' + encodeURIComponent(selectedFile)); else alert("请先选中文件"); } |
| function actionCopy() { if (!selectedFile) return; clipboard = { action: 'copy', path: selectedFile }; document.getElementById('btn-paste').style.display = 'block'; document.getElementById('btn-copy').innerText = "已复制"; setTimeout(() => document.getElementById('btn-copy').innerText = "📋 复制", 2000); } |
| async function actionPaste() { |
| if (!clipboard.path) return; |
| const res = await fetch('/api/fs/action', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ action: clipboard.action, src: clipboard.path, dest: `${currentPath}/${clipboard.path.split('/').pop()}`.replace('//', '/') }) }); |
| const data = await res.json(); data.status === 'ok' ? loadDir(currentPath) : alert(data.msg); |
| } |
| async function actionDelete() { |
| if (!selectedFile || !confirm(`确认删除 ${selectedFile} 吗?`)) return; |
| await fetch('/api/fs/action', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ action: 'delete', src: selectedFile, dest: '' }) }); |
| loadDir(currentPath); |
| } |
| async function openEditor(path) { |
| const res = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`); const data = await res.json(); |
| if (data.status !== 'ok') return alert(data.msg || "无法读取"); |
| currentEditPath = path; document.getElementById('editor-title').innerText = path.split('/').pop(); document.getElementById('editor-panel').style.display = 'flex'; |
| cmEditor.setValue(data.content); setTimeout(() => cmEditor.refresh(), 100); |
| } |
| function closeEditor() { document.getElementById('editor-panel').style.display = 'none'; } |
| async function saveFile() { |
| if (!currentEditPath) return; |
| const res = await fetch('/api/fs/write', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ path: currentEditPath, content: cmEditor.getValue() }) }); |
| const data = await res.json(); |
| if(data.status === 'ok') { const title = document.getElementById('editor-title'); title.innerText = "✅ 保存成功!"; setTimeout(() => title.innerText = currentEditPath.split('/').pop(), 2000); } else alert("保存失败: " + data.msg); |
| } |
| loadDir('/app'); |
| </script> |
| </body> |
| </html> |
| """ |
|
|
| |
| |
| |
| MEMORY_CACHE = {} |
| INDEX_FILE = "/app/index.html" |
| REPORT_DIR = "/app/reviewreports" |
| os.makedirs(REPORT_DIR, exist_ok=True) |
|
|
| @app.get("/") |
| async def index(request: Request): |
| host = request.headers.get("x-proxy-host") or request.headers.get("host", "") |
| if "online.4z.autos" in host: |
| if 'index' not in MEMORY_CACHE: |
| if os.path.exists(INDEX_FILE): |
| with open(INDEX_FILE, 'rb') as f: |
| MEMORY_CACHE['index'] = f.read() |
| else: |
| return Response("Index File Not Found", status_code=404) |
| return Response(content=MEMORY_CACHE['index'], media_type="text/html", headers={'Cache-Control': 'public, max-age=3600'}) |
| return Response("Access Denied", status_code=403) |
|
|
| @app.get("/report/{filename}") |
| async def get_report(filename: str): |
| if filename.endswith(".json"): filename = filename[:-5] |
| if not re.match(r'^[a-zA-Z0-9]+$', filename): return Response("Bad Request", status_code=400) |
| filepath = os.path.join(REPORT_DIR, f"{filename}.json") |
| if not os.path.exists(filepath): return Response("File Not Found", status_code=404) |
| return FileResponse(filepath, media_type='application/json', headers={'Access-Control-Allow-Origin': '*','Cache-Control': 'public, max-age=31536000, immutable'}) |
|
|
| |
| |
| |
| @app.get("/admin") |
| async def admin_shell(request: Request): |
| |
| token = request.cookies.get("auth_token") |
| if token and secrets.compare_digest(token, PASSWORD): |
| return HTMLResponse(HTML_PAGE) |
| |
| return HTMLResponse(LOGIN_PAGE) |
|
|
| @app.post("/login") |
| async def do_login(password: str = Form(...)): |
| |
| if secrets.compare_digest(password, PASSWORD): |
| |
| response = RedirectResponse(url="/admin", status_code=302) |
| response.set_cookie(key="auth_token", value=PASSWORD, httponly=True, max_age=86400) |
| return response |
| |
| return HTMLResponse("<h2 style='color:red; text-align:center;'>密码错误,请返回重试</h2>", status_code=401) |
|
|
| @app.websocket("/ws") |
| async def ws_endpoint(websocket: WebSocket): |
| |
| token = websocket.cookies.get("auth_token") |
| if not token or not secrets.compare_digest(token, PASSWORD): |
| await websocket.close(code=1008) |
| return |
| |
| await websocket.accept() |
| pid, fd = pty.fork() |
| if pid == 0: |
| os.environ["TERM"] = "xterm-256color" |
| os.chdir("/app") |
| os.execvp("bash", ["bash"]) |
| else: |
| loop = asyncio.get_running_loop() |
| async def read_pty(): |
| while True: |
| try: |
| data = await loop.run_in_executor(None, os.read, fd, 1024) |
| if not data: break |
| await websocket.send_text(data.decode('utf-8', 'replace')) |
| except Exception: |
| break |
| async def read_ws(): |
| while True: |
| try: |
| data = await websocket.receive_text() |
| os.write(fd, data.encode('utf-8')) |
| except Exception: |
| break |
| await asyncio.gather(read_pty(), read_ws()) |
|
|
| |
|
|
| class FsAction(BaseModel): |
| action: str |
| src: str |
| dest: str |
|
|
| class FileContent(BaseModel): |
| path: str |
| content: str |
|
|
| @app.get("/api/fs/list") |
| def fs_list(path: str = "/app", username: str = Depends(verify_auth)): |
| try: |
| items = [] |
| for f in os.scandir(path): |
| items.append({"name": f.name, "is_dir": f.is_dir(), "size": f.stat().st_size}) |
| items.sort(key=lambda x: (not x['is_dir'], x['name'].lower())) |
| return {"status": "ok", "path": path, "items": items} |
| except Exception as e: |
| return {"status": "error", "msg": str(e)} |
|
|
| @app.post("/api/fs/action") |
| def fs_action(req: FsAction, username: str = Depends(verify_auth)): |
| try: |
| if req.action == 'delete': |
| if os.path.isdir(req.src): shutil.rmtree(req.src) |
| else: os.remove(req.src) |
| elif req.action == 'copy': |
| if os.path.isdir(req.src): shutil.copytree(req.src, req.dest) |
| else: shutil.copy2(req.src, req.dest) |
| return {"status": "ok"} |
| except Exception as e: |
| return {"status": "error", "msg": str(e)} |
|
|
| @app.post("/upload") |
| async def upload(path: str, files: list[UploadFile], username: str = Depends(verify_auth)): |
| for file in files: |
| file_path = os.path.join(path, file.filename) |
| with open(file_path, "wb") as f: |
| f.write(await file.read()) |
| return {"status": "ok"} |
|
|
| @app.get("/download") |
| def download(path: str, username: str = Depends(verify_auth)): |
| if os.path.exists(path): return FileResponse(path) |
| return {"error": "File not found"} |
|
|
| @app.get("/api/fs/read") |
| def fs_read(path: str, username: str = Depends(verify_auth)): |
| try: |
| with open(path, 'r', encoding='utf-8') as f: |
| return {"status": "ok", "content": f.read()} |
| except UnicodeDecodeError: |
| return {"status": "error", "msg": "不是文本文件,无法编辑"} |
| except Exception as e: |
| return {"status": "error", "msg": str(e)} |
|
|
| @app.post("/api/fs/write") |
| def fs_write(req: FileContent, username: str = Depends(verify_auth)): |
| try: |
| with open(req.path, 'w', encoding='utf-8') as f: |
| f.write(req.content) |
| return {"status": "ok"} |
| except Exception as e: |
| return {"status": "error", "msg": str(e)} |