From d46eaf43ab7103d501a5725423dda0a62d716d78 Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 30 Jul 2026 13:04:07 +0800 Subject: [PATCH] Control settings tabs, hide default creds hint after change, LAN passwordless login. Co-authored-by: Cursor --- .env.control.example | 2 + control/backend/app/api/auth_routes.py | 66 +++- control/backend/app/config.py | 18 ++ control/backend/app/envfile.py | 1 + control/backend/app/lan.py | 29 ++ control/frontend/src/api.ts | 10 +- control/frontend/src/pages/Login.tsx | 81 ++++- control/frontend/src/pages/Settings.tsx | 380 +++++++++++++++--------- control/frontend/src/styles.css | 72 +++++ 9 files changed, 515 insertions(+), 144 deletions(-) create mode 100644 control/backend/app/lan.py diff --git a/.env.control.example b/.env.control.example index 54999ea..d18f436 100644 --- a/.env.control.example +++ b/.env.control.example @@ -7,4 +7,6 @@ CONTROL_AUTH_TOKEN_VERSION=1 CONTROL_TOKEN_TTL_SEC=604800 CONTROL_POLL_INTERVAL_SEC=8 CONTROL_HTTP_TIMEOUT_SEC=12 +# 局域网免登录:1=开启(仅私网 IP),0=关闭 +CONTROL_LAN_AUTH_BYPASS=0 # CONTROL_DB_PATH=/opt/eth_hedge_sim/control/data/control.db diff --git a/control/backend/app/api/auth_routes.py b/control/backend/app/api/auth_routes.py index b361bd6..f0d3199 100644 --- a/control/backend/app/api/auth_routes.py +++ b/control/backend/app/api/auth_routes.py @@ -3,12 +3,13 @@ from __future__ import annotations import hmac from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field from ..auth import issue_token, require_control_user from ..config import ControlSettings, get_control_settings -from ..envfile import update_control_credentials +from ..envfile import update_control_credentials, upsert_env_control +from ..lan import client_ip, is_lan_ip router = APIRouter(prefix="/api/auth", tags=["auth"]) @@ -24,6 +25,46 @@ class ChangeCredentialsBody(BaseModel): new_password: str = Field(min_length=6, max_length=128) +class LanBypassBody(BaseModel): + enabled: bool + + +@router.get("/login-meta") +async def login_meta( + request: Request, + settings: Annotated[ControlSettings, Depends(get_control_settings)], +) -> dict: + ip = client_ip(request) + lan = is_lan_ip(ip) + return { + "show_default_hint": settings.is_default_credentials, + "lan_bypass_enabled": settings.lan_auth_bypass, + "lan_client": lan, + "lan_login_available": bool(settings.lan_auth_bypass and lan), + "client_ip": ip or None, + } + + +@router.post("/lan-login") +async def lan_login( + request: Request, + settings: Annotated[ControlSettings, Depends(get_control_settings)], +) -> dict: + if not settings.lan_auth_bypass: + raise HTTPException(status_code=403, detail="未开启局域网免登录") + ip = client_ip(request) + if not is_lan_ip(ip): + raise HTTPException(status_code=403, detail="仅局域网地址可免登录") + user = settings.control_auth_username + token, ttl = issue_token(user, settings) + return { + "token": token, + "username": user, + "expires_in": ttl, + "via": "lan", + } + + @router.post("/login") async def login( body: LoginBody, @@ -51,6 +92,26 @@ async def me( return { "username": username, "poll_interval_sec": settings.control_poll_interval_sec, + "show_default_hint": settings.is_default_credentials, + "lan_bypass_enabled": settings.lan_auth_bypass, + } + + +@router.put("/lan-bypass") +async def put_lan_bypass( + body: LanBypassBody, + _user: Annotated[str, Depends(require_control_user)], +) -> dict: + upsert_env_control( + "CONTROL_LAN_AUTH_BYPASS", + "1" if body.enabled else "0", + overwrite=True, + ) + get_control_settings.cache_clear() + settings = get_control_settings() + return { + "ok": True, + "lan_bypass_enabled": settings.lan_auth_bypass, } @@ -78,4 +139,5 @@ async def change_credentials( "token": token, "username": body.new_username.strip(), "expires_in": ttl, + "show_default_hint": settings2.is_default_credentials, } diff --git a/control/backend/app/config.py b/control/backend/app/config.py index c789008..e004f47 100644 --- a/control/backend/app/config.py +++ b/control/backend/app/config.py @@ -33,6 +33,8 @@ class ControlSettings(BaseSettings): control_poll_interval_sec: int = 8 control_http_timeout_sec: float = 12.0 control_port: int = 5160 + # "1"/"0":局域网客户端免密登录 + control_lan_auth_bypass: str = "0" @property def db_path(self) -> Path: @@ -40,6 +42,22 @@ class ControlSettings(BaseSettings): return Path(self.control_db_path) return _control_root() / "data" / "control.db" + @property + def lan_auth_bypass(self) -> bool: + return self.control_lan_auth_bypass.strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + + @property + def is_default_credentials(self) -> bool: + return ( + self.control_auth_username.strip() == "admin" + and self.control_auth_password == "admin123" + ) + @lru_cache def get_control_settings() -> ControlSettings: diff --git a/control/backend/app/envfile.py b/control/backend/app/envfile.py index 690840c..d702ea3 100644 --- a/control/backend/app/envfile.py +++ b/control/backend/app/envfile.py @@ -73,6 +73,7 @@ _DEPLOY_DEFAULTS: dict[str, str] = { "CONTROL_POLL_INTERVAL_SEC": "8", "CONTROL_HTTP_TIMEOUT_SEC": "12", "CONTROL_AUTH_TOKEN_VERSION": "1", + "CONTROL_LAN_AUTH_BYPASS": "0", } diff --git a/control/backend/app/lan.py b/control/backend/app/lan.py new file mode 100644 index 0000000..8bba6c7 --- /dev/null +++ b/control/backend/app/lan.py @@ -0,0 +1,29 @@ +"""客户端 IP / 局域网判断。""" + +from __future__ import annotations + +import ipaddress + +from fastapi import Request + + +def client_ip(request: Request) -> str: + xff = request.headers.get("x-forwarded-for") or "" + if xff.strip(): + return xff.split(",")[0].strip() + if request.client and request.client.host: + return request.client.host + return "" + + +def is_lan_ip(ip: str) -> bool: + raw = (ip or "").strip() + if not raw: + return False + if raw in ("localhost", "::1"): + return True + try: + addr = ipaddress.ip_address(raw.split("%")[0]) + except ValueError: + return False + return bool(addr.is_loopback or addr.is_private) diff --git a/control/frontend/src/api.ts b/control/frontend/src/api.ts index 9314ab5..f083a98 100644 --- a/control/frontend/src/api.ts +++ b/control/frontend/src/api.ts @@ -38,8 +38,14 @@ export async function apiFetch( data = { detail: text }; } if (res.status === 401) { - // 仅中控自身鉴权失败才清会话;勿把策略机错误当成掉登录 - clearSession(); + // 登录页公开接口失败不要清会话文案;仅受保护接口掉登录 + const publicAuth = + path.startsWith("/api/auth/login") || + path.startsWith("/api/auth/login-meta") || + path.startsWith("/api/auth/lan-login"); + if (!publicAuth) { + clearSession(); + } const detail = typeof data === "object" && data && "detail" in data ? String((data as { detail: unknown }).detail) diff --git a/control/frontend/src/pages/Login.tsx b/control/frontend/src/pages/Login.tsx index 958f783..589a108 100644 --- a/control/frontend/src/pages/Login.tsx +++ b/control/frontend/src/pages/Login.tsx @@ -1,13 +1,52 @@ -import { FormEvent, useState } from "react"; +import { FormEvent, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { login } from "../api"; +import { apiFetch, getToken, login, setSession } from "../api"; + +type LoginMeta = { + show_default_hint: boolean; + lan_login_available: boolean; +}; export default function LoginPage() { const nav = useNavigate(); - const [username, setUsername] = useState("admin"); + const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [err, setErr] = useState(""); const [loading, setLoading] = useState(false); + const [showHint, setShowHint] = useState(false); + const [lanTrying, setLanTrying] = useState(true); + + useEffect(() => { + if (getToken()) { + nav("/monitor", { replace: true }); + return; + } + let cancelled = false; + (async () => { + try { + const meta = await apiFetch("/api/auth/login-meta"); + if (cancelled) return; + setShowHint(!!meta.show_default_hint); + if (meta.lan_login_available) { + const res = await apiFetch<{ token: string; username: string }>( + "/api/auth/lan-login", + { method: "POST", body: "{}" }, + ); + if (cancelled) return; + setSession(res.token, res.username); + nav("/monitor", { replace: true }); + return; + } + } catch { + /* 免登失败则走普通登录 */ + } finally { + if (!cancelled) setLanTrying(false); + } + })(); + return () => { + cancelled = true; + }; + }, [nav]); async function onSubmit(e: FormEvent) { e.preventDefault(); @@ -23,15 +62,43 @@ export default function LoginPage() { } } + if (lanTrying) { + return ( +
+
+

比特骆驼中控

+

检测局域网免登录…

+
+
+ ); + } + return (
-
+

比特骆驼中控

-

本地运维面板 · 默认 admin / admin123

+

+ 本地运维面板 + {showHint ? " · 默认 admin / admin123" : ""} +

{err ?
{err}
: null} diff --git a/control/frontend/src/pages/Settings.tsx b/control/frontend/src/pages/Settings.tsx index ba85aa2..2f7a6df 100644 --- a/control/frontend/src/pages/Settings.tsx +++ b/control/frontend/src/pages/Settings.tsx @@ -1,7 +1,10 @@ import { FormEvent, useEffect, useState } from "react"; -import { apiFetch, getUsername, setSession, type NodeCard } from "../api"; +import { apiFetch, setSession, type NodeCard } from "../api"; + +type Tab = "account" | "add" | "list"; export default function SettingsPage() { + const [tab, setTab] = useState("account"); const [nodes, setNodes] = useState([]); const [name, setName] = useState(""); const [baseUrl, setBaseUrl] = useState("https://"); @@ -11,19 +14,33 @@ export default function SettingsPage() { null, ); - const [newUsername, setNewUsername] = useState(getUsername() || "admin"); + const [newUsername, setNewUsername] = useState(""); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [credBusy, setCredBusy] = useState(false); + const [showHint, setShowHint] = useState(false); + const [lanBypass, setLanBypass] = useState(false); + const [lanBusy, setLanBusy] = useState(false); async function load() { const r = await apiFetch<{ nodes: NodeCard[] }>("/api/nodes/"); setNodes(r.nodes || []); } + async function loadMe() { + const m = await apiFetch<{ + show_default_hint?: boolean; + lan_bypass_enabled?: boolean; + }>("/api/auth/me"); + setShowHint(!!m.show_default_hint); + setLanBypass(!!m.lan_bypass_enabled); + } + useEffect(() => { - load().catch((ex) => setErr(ex instanceof Error ? ex.message : String(ex))); + Promise.all([load(), loadMe()]).catch((ex) => + setErr(ex instanceof Error ? ex.message : String(ex)), + ); }, []); async function onAdd(e: FormEvent) { @@ -38,6 +55,7 @@ export default function SettingsPage() { setName(""); setOk("已添加策略机"); await load(); + setTab("list"); } catch (ex) { setErr(ex instanceof Error ? ex.message : String(ex)); } @@ -53,21 +71,24 @@ export default function SettingsPage() { } setCredBusy(true); try { - const r = await apiFetch<{ token: string; username: string }>( - "/api/auth/change-credentials", - { - method: "POST", - body: JSON.stringify({ - current_password: currentPassword, - new_username: newUsername.trim(), - new_password: newPassword, - }), - }, - ); + const r = await apiFetch<{ + token: string; + username: string; + show_default_hint?: boolean; + }>("/api/auth/change-credentials", { + method: "POST", + body: JSON.stringify({ + current_password: currentPassword, + new_username: newUsername.trim(), + new_password: newPassword, + }), + }); setSession(r.token, r.username); setCurrentPassword(""); setNewPassword(""); setConfirmPassword(""); + setNewUsername(""); + setShowHint(!!r.show_default_hint); setOk("中控账号已更新"); } catch (ex) { setErr(ex instanceof Error ? ex.message : String(ex)); @@ -76,6 +97,31 @@ export default function SettingsPage() { } } + async function onToggleLan(next: boolean) { + setErr(""); + setOk(""); + setLanBusy(true); + try { + const r = await apiFetch<{ lan_bypass_enabled: boolean }>( + "/api/auth/lan-bypass", + { + method: "PUT", + body: JSON.stringify({ enabled: next }), + }, + ); + setLanBypass(!!r.lan_bypass_enabled); + setOk( + next + ? "已开启局域网免登录(仅私网 IP 生效)" + : "已关闭局域网免登录", + ); + } catch (ex) { + setErr(ex instanceof Error ? ex.message : String(ex)); + } finally { + setLanBusy(false); + } + } + async function genToken(id: number) { setErr(""); setOk(""); @@ -97,6 +143,7 @@ export default function SettingsPage() { setErr(""); try { await apiFetch(`/api/nodes/${id}`, { method: "DELETE" }); + setOk("已删除"); await load(); } catch (ex) { setErr(ex instanceof Error ? ex.message : String(ex)); @@ -109,133 +156,198 @@ export default function SettingsPage() {

中控不访问交易所。生成 Token 后登录策略机,在「登录账户」中保存同一 Token。

+ +
+ + + +
+ {err ?
{err}
: null} {ok ?
{ok}
: null} - -

中控登录账号

-

默认 admin / admin123,建议首次登录后修改。

- - - - - - - - {lastToken ? ( -
-
- 节点 #{lastToken.id} 新 Token(只显示一次,请复制): -
- {lastToken.token} - + + + + + + -
+ ) : null} -
-

添加策略机

- - - -
+ {tab === "add" ? ( +
+

添加策略机

+ + + +
+ ) : null} -
- - - - - - - - - - - - {nodes.map((n) => ( - - - - - - - - ))} - -
ID名称URLToken操作
{n.id}{n.name}{n.base_url}{n.token_configured ? "已生成" : "无"} - - -
-
+ {tab === "list" ? ( +
+ {lastToken ? ( +
+
节点 #{lastToken.id} 新 Token(只显示一次,请复制):
+ {lastToken.token} + +
+ ) : null} +
+ + + + + + + + + + + + {nodes.map((n) => ( + + + + + + + + ))} + +
ID名称URLToken操作
{n.id}{n.name}{n.base_url}{n.token_configured ? "已生成" : "无"} + + +
+
+ {!nodes.length ?

暂无策略机,请到「添加策略机」。

: null} +
+ ) : null}
); } diff --git a/control/frontend/src/styles.css b/control/frontend/src/styles.css index 6e82e9a..f3fe1fc 100644 --- a/control/frontend/src/styles.css +++ b/control/frontend/src/styles.css @@ -317,6 +317,78 @@ td { word-break: break-all; } +.settings-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 12px 0 16px; +} + +.settings-tabs .tab { + border: 1px solid var(--line); + background: transparent; + color: var(--muted); + padding: 8px 12px; + border-radius: 8px; + cursor: pointer; + font: inherit; +} + +.settings-tabs .tab.active, +.settings-tabs .tab:hover { + color: var(--text); + background: var(--panel); + border-color: #3d5a73; +} + +.switch-row { + display: flex !important; + flex-direction: row !important; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 0; + border-bottom: 1px solid var(--line); + margin-bottom: 8px; +} + +.switch-hint { + display: block; + margin-top: 4px; + font-weight: 400; +} + +.switch { + position: relative; + width: 48px; + height: 28px; + border-radius: 999px; + border: 0; + background: #3a4654; + cursor: pointer; + flex-shrink: 0; + padding: 0; +} + +.switch.on { + background: var(--ok); +} + +.switch-knob { + position: absolute; + top: 3px; + left: 3px; + width: 22px; + height: 22px; + border-radius: 50%; + background: #fff; + transition: transform 0.15s ease; +} + +.switch.on .switch-knob { + transform: translateX(20px); +} + .modal-backdrop { position: fixed; inset: 0;