Control settings tabs, hide default creds hint after change, LAN passwordless login.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-30 13:04:07 +08:00
parent 8034464c2f
commit d46eaf43ab
9 changed files with 515 additions and 144 deletions
+2
View File
@@ -7,4 +7,6 @@ CONTROL_AUTH_TOKEN_VERSION=1
CONTROL_TOKEN_TTL_SEC=604800 CONTROL_TOKEN_TTL_SEC=604800
CONTROL_POLL_INTERVAL_SEC=8 CONTROL_POLL_INTERVAL_SEC=8
CONTROL_HTTP_TIMEOUT_SEC=12 CONTROL_HTTP_TIMEOUT_SEC=12
# 局域网免登录:1=开启(仅私网 IP),0=关闭
CONTROL_LAN_AUTH_BYPASS=0
# CONTROL_DB_PATH=/opt/eth_hedge_sim/control/data/control.db # CONTROL_DB_PATH=/opt/eth_hedge_sim/control/data/control.db
+64 -2
View File
@@ -3,12 +3,13 @@ from __future__ import annotations
import hmac import hmac
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ..auth import issue_token, require_control_user from ..auth import issue_token, require_control_user
from ..config import ControlSettings, get_control_settings 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"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -24,6 +25,46 @@ class ChangeCredentialsBody(BaseModel):
new_password: str = Field(min_length=6, max_length=128) 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") @router.post("/login")
async def login( async def login(
body: LoginBody, body: LoginBody,
@@ -51,6 +92,26 @@ async def me(
return { return {
"username": username, "username": username,
"poll_interval_sec": settings.control_poll_interval_sec, "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, "token": token,
"username": body.new_username.strip(), "username": body.new_username.strip(),
"expires_in": ttl, "expires_in": ttl,
"show_default_hint": settings2.is_default_credentials,
} }
+18
View File
@@ -33,6 +33,8 @@ class ControlSettings(BaseSettings):
control_poll_interval_sec: int = 8 control_poll_interval_sec: int = 8
control_http_timeout_sec: float = 12.0 control_http_timeout_sec: float = 12.0
control_port: int = 5160 control_port: int = 5160
# "1"/"0":局域网客户端免密登录
control_lan_auth_bypass: str = "0"
@property @property
def db_path(self) -> Path: def db_path(self) -> Path:
@@ -40,6 +42,22 @@ class ControlSettings(BaseSettings):
return Path(self.control_db_path) return Path(self.control_db_path)
return _control_root() / "data" / "control.db" 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 @lru_cache
def get_control_settings() -> ControlSettings: def get_control_settings() -> ControlSettings:
+1
View File
@@ -73,6 +73,7 @@ _DEPLOY_DEFAULTS: dict[str, str] = {
"CONTROL_POLL_INTERVAL_SEC": "8", "CONTROL_POLL_INTERVAL_SEC": "8",
"CONTROL_HTTP_TIMEOUT_SEC": "12", "CONTROL_HTTP_TIMEOUT_SEC": "12",
"CONTROL_AUTH_TOKEN_VERSION": "1", "CONTROL_AUTH_TOKEN_VERSION": "1",
"CONTROL_LAN_AUTH_BYPASS": "0",
} }
+29
View File
@@ -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)
+8 -2
View File
@@ -38,8 +38,14 @@ export async function apiFetch<T>(
data = { detail: text }; data = { detail: text };
} }
if (res.status === 401) { 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 = const detail =
typeof data === "object" && data && "detail" in data typeof data === "object" && data && "detail" in data
? String((data as { detail: unknown }).detail) ? String((data as { detail: unknown }).detail)
+75 -6
View File
@@ -1,13 +1,52 @@
import { FormEvent, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; 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() { export default function LoginPage() {
const nav = useNavigate(); const nav = useNavigate();
const [username, setUsername] = useState("admin"); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [err, setErr] = useState(""); const [err, setErr] = useState("");
const [loading, setLoading] = useState(false); 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<LoginMeta>("/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) { async function onSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -23,15 +62,43 @@ export default function LoginPage() {
} }
} }
if (lanTrying) {
return (
<div className="login-wrap">
<div className="login-box">
<h1>比特骆驼中控</h1>
<p className="meta">检测局域网免登录…</p>
</div>
</div>
);
}
return ( return (
<div className="login-wrap"> <div className="login-wrap">
<form className="login-box" onSubmit={onSubmit}> <form
className="login-box"
onSubmit={onSubmit}
autoComplete="off"
data-lpignore="true"
>
<h1>比特骆驼中控</h1> <h1>比特骆驼中控</h1>
<p className="meta">本地运维面板 · 默认 admin / admin123</p> <p className="meta">
本地运维面板
{showHint ? " · 默认 admin / admin123" : ""}
</p>
{err ? <div className="err">{err}</div> : null} {err ? <div className="err">{err}</div> : null}
<label> <label>
用户名 用户名
<input value={username} onChange={(e) => setUsername(e.target.value)} required /> <input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
name="control-login-user"
required
/>
</label> </label>
<label> <label>
密码 密码
@@ -39,6 +106,8 @@ export default function LoginPage() {
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
name="control-login-pass"
required required
/> />
</label> </label>
+246 -134
View File
@@ -1,7 +1,10 @@
import { FormEvent, useEffect, useState } from "react"; 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() { export default function SettingsPage() {
const [tab, setTab] = useState<Tab>("account");
const [nodes, setNodes] = useState<NodeCard[]>([]); const [nodes, setNodes] = useState<NodeCard[]>([]);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [baseUrl, setBaseUrl] = useState("https://"); const [baseUrl, setBaseUrl] = useState("https://");
@@ -11,19 +14,33 @@ export default function SettingsPage() {
null, null,
); );
const [newUsername, setNewUsername] = useState(getUsername() || "admin"); const [newUsername, setNewUsername] = useState("");
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [credBusy, setCredBusy] = useState(false); const [credBusy, setCredBusy] = useState(false);
const [showHint, setShowHint] = useState(false);
const [lanBypass, setLanBypass] = useState(false);
const [lanBusy, setLanBusy] = useState(false);
async function load() { async function load() {
const r = await apiFetch<{ nodes: NodeCard[] }>("/api/nodes/"); const r = await apiFetch<{ nodes: NodeCard[] }>("/api/nodes/");
setNodes(r.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(() => { 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) { async function onAdd(e: FormEvent) {
@@ -38,6 +55,7 @@ export default function SettingsPage() {
setName(""); setName("");
setOk("已添加策略机"); setOk("已添加策略机");
await load(); await load();
setTab("list");
} catch (ex) { } catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex)); setErr(ex instanceof Error ? ex.message : String(ex));
} }
@@ -53,21 +71,24 @@ export default function SettingsPage() {
} }
setCredBusy(true); setCredBusy(true);
try { try {
const r = await apiFetch<{ token: string; username: string }>( const r = await apiFetch<{
"/api/auth/change-credentials", token: string;
{ username: string;
method: "POST", show_default_hint?: boolean;
body: JSON.stringify({ }>("/api/auth/change-credentials", {
current_password: currentPassword, method: "POST",
new_username: newUsername.trim(), body: JSON.stringify({
new_password: newPassword, current_password: currentPassword,
}), new_username: newUsername.trim(),
}, new_password: newPassword,
); }),
});
setSession(r.token, r.username); setSession(r.token, r.username);
setCurrentPassword(""); setCurrentPassword("");
setNewPassword(""); setNewPassword("");
setConfirmPassword(""); setConfirmPassword("");
setNewUsername("");
setShowHint(!!r.show_default_hint);
setOk("中控账号已更新"); setOk("中控账号已更新");
} catch (ex) { } catch (ex) {
setErr(ex instanceof Error ? ex.message : String(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) { async function genToken(id: number) {
setErr(""); setErr("");
setOk(""); setOk("");
@@ -97,6 +143,7 @@ export default function SettingsPage() {
setErr(""); setErr("");
try { try {
await apiFetch(`/api/nodes/${id}`, { method: "DELETE" }); await apiFetch(`/api/nodes/${id}`, { method: "DELETE" });
setOk("已删除");
await load(); await load();
} catch (ex) { } catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex)); setErr(ex instanceof Error ? ex.message : String(ex));
@@ -109,133 +156,198 @@ export default function SettingsPage() {
<p className="meta"> <p className="meta">
中控不访问交易所。生成 Token 后登录策略机,在「登录账户」中保存同一 Token。 中控不访问交易所。生成 Token 后登录策略机,在「登录账户」中保存同一 Token。
</p> </p>
<div className="settings-tabs" role="tablist">
<button
type="button"
className={tab === "account" ? "tab active" : "tab"}
onClick={() => setTab("account")}
>
中控登录账户
</button>
<button
type="button"
className={tab === "add" ? "tab active" : "tab"}
onClick={() => setTab("add")}
>
添加策略机
</button>
<button
type="button"
className={tab === "list" ? "tab active" : "tab"}
onClick={() => setTab("list")}
>
策略机列表
</button>
</div>
{err ? <div className="err">{err}</div> : null} {err ? <div className="err">{err}</div> : null}
{ok ? <div className="ok">{ok}</div> : null} {ok ? <div className="ok">{ok}</div> : null}
<form className="add-form" onSubmit={onSaveCreds}> {tab === "account" ? (
<h3>中控登录账号</h3> <form className="add-form" onSubmit={onSaveCreds} autoComplete="off">
<p className="meta">默认 admin / admin123,建议首次登录后修改。</p> <h3>中控登录账号</h3>
<label> {showHint ? (
新用户名 <p className="meta">当前仍为默认账号,建议修改用户名与密码。</p>
<input ) : (
value={newUsername} <p className="meta">修改后旧会话将失效,需重新登录(局域网免登除外)。</p>
onChange={(e) => setNewUsername(e.target.value)} )}
required <label className="switch-row">
/> <span>
</label> 本地局域网免登录
<label> <span className="meta switch-hint">
当前密码 开启后,从 192.168/10/172.16 等私网访问可自动进入,无需密码
<input </span>
type="password" </span>
value={currentPassword} <button
onChange={(e) => setCurrentPassword(e.target.value)} type="button"
required className={`switch ${lanBypass ? "on" : ""}`}
/> disabled={lanBusy}
</label> aria-pressed={lanBypass}
<label> onClick={() => void onToggleLan(!lanBypass)}
新密码 >
<input <span className="switch-knob" />
type="password" </button>
value={newPassword} </label>
onChange={(e) => setNewPassword(e.target.value)} <label>
minLength={6} 新用户名
required <input
/> value={newUsername}
</label> onChange={(e) => setNewUsername(e.target.value)}
<label> autoComplete="off"
确认新密码 name="control-new-user"
<input placeholder="输入新用户名"
type="password" required
value={confirmPassword} />
onChange={(e) => setConfirmPassword(e.target.value)} </label>
minLength={6} <label>
required 当前密码
/> <input
</label> type="password"
<button className="btn" type="submit" disabled={credBusy}> value={currentPassword}
{credBusy ? "保存中…" : "保存账号"} onChange={(e) => setCurrentPassword(e.target.value)}
</button> autoComplete="new-password"
</form> name="control-cur-pass"
required
{lastToken ? ( />
<div className="token-box"> </label>
<div> <label>
节点 #{lastToken.id} 新 Token(只显示一次,请复制): 新密码
</div> <input
<code className="mono">{lastToken.token}</code> type="password"
<button value={newPassword}
type="button" onChange={(e) => setNewPassword(e.target.value)}
className="btn ghost" autoComplete="new-password"
onClick={() => { name="control-new-pass"
void navigator.clipboard.writeText(lastToken.token); minLength={6}
setOk("已复制到剪贴板"); required
}} />
> </label>
复制 <label>
确认新密码
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
name="control-confirm-pass"
minLength={6}
required
/>
</label>
<button className="btn" type="submit" disabled={credBusy}>
{credBusy ? "保存中…" : "保存账号"}
</button> </button>
</div> </form>
) : null} ) : null}
<form className="add-form" onSubmit={onAdd}> {tab === "add" ? (
<h3>添加策略机</h3> <form className="add-form" onSubmit={onAdd}>
<label> <h3>添加策略机</h3>
名称 <label>
<input 名称
value={name} <input
onChange={(e) => setName(e.target.value)} value={name}
placeholder="例如 云机-A" onChange={(e) => setName(e.target.value)}
required placeholder="例如 云机-A"
/> required
</label> />
<label> </label>
公网 Base URL <label>
<input 公网 Base URL
value={baseUrl} <input
onChange={(e) => setBaseUrl(e.target.value)} value={baseUrl}
placeholder="https://dc.hyf2.cc" onChange={(e) => setBaseUrl(e.target.value)}
required placeholder="https://dc.hyf2.cc"
/> required
</label> />
<button className="btn" type="submit"> </label>
添加 <button className="btn" type="submit">
</button> 添加
</form> </button>
</form>
) : null}
<div className="table-wrap"> {tab === "list" ? (
<table> <div>
<thead> {lastToken ? (
<tr> <div className="token-box">
<th>ID</th> <div>节点 #{lastToken.id} 新 Token(只显示一次,请复制):</div>
<th>名称</th> <code className="mono">{lastToken.token}</code>
<th>URL</th> <button
<th>Token</th> type="button"
<th>操作</th> className="btn ghost"
</tr> onClick={() => {
</thead> void navigator.clipboard.writeText(lastToken.token);
<tbody> setOk("已复制到剪贴板");
{nodes.map((n) => ( }}
<tr key={n.id}> >
<td>{n.id}</td> 复制
<td>{n.name}</td> </button>
<td className="mono">{n.base_url}</td> </div>
<td>{n.token_configured ? "已生成" : "无"}</td> ) : null}
<td className="row-actions"> <div className="table-wrap">
<button type="button" className="btn" onClick={() => void genToken(n.id)}> <table>
生成 Token <thead>
</button> <tr>
<button <th>ID</th>
type="button" <th>名称</th>
className="btn ghost" <th>URL</th>
onClick={() => void remove(n.id)} <th>Token</th>
> <th>操作</th>
删除 </tr>
</button> </thead>
</td> <tbody>
</tr> {nodes.map((n) => (
))} <tr key={n.id}>
</tbody> <td>{n.id}</td>
</table> <td>{n.name}</td>
</div> <td className="mono">{n.base_url}</td>
<td>{n.token_configured ? "已生成" : "无"}</td>
<td className="row-actions">
<button
type="button"
className="btn"
onClick={() => void genToken(n.id)}
>
生成 Token
</button>
<button
type="button"
className="btn ghost"
onClick={() => void remove(n.id)}
>
删除
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{!nodes.length ? <p className="meta">暂无策略机,请到「添加策略机」。</p> : null}
</div>
) : null}
</div> </div>
); );
} }
+72
View File
@@ -317,6 +317,78 @@ td {
word-break: break-all; 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 { .modal-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;