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_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
+64 -2
View File
@@ -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,
}
+18
View File
@@ -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:
+1
View File
@@ -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",
}
+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 };
}
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)
+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 { 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<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) {
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 (
<div className="login-wrap">
<form className="login-box" onSubmit={onSubmit}>
<form
className="login-box"
onSubmit={onSubmit}
autoComplete="off"
data-lpignore="true"
>
<h1>比特骆驼中控</h1>
<p className="meta">本地运维面板 · 默认 admin / admin123</p>
<p className="meta">
本地运维面板
{showHint ? " · 默认 admin / admin123" : ""}
</p>
{err ? <div className="err">{err}</div> : null}
<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>
密码
@@ -39,6 +106,8 @@ export default function LoginPage() {
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
name="control-login-pass"
required
/>
</label>
+246 -134
View File
@@ -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<Tab>("account");
const [nodes, setNodes] = useState<NodeCard[]>([]);
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() {
<p className="meta">
中控不访问交易所。生成 Token 后登录策略机,在「登录账户」中保存同一 Token。
</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}
{ok ? <div className="ok">{ok}</div> : null}
<form className="add-form" onSubmit={onSaveCreds}>
<h3>中控登录账号</h3>
<p className="meta">默认 admin / admin123,建议首次登录后修改。</p>
<label>
新用户名
<input
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
required
/>
</label>
<label>
当前密码
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
</label>
<label>
新密码
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
minLength={6}
required
/>
</label>
<label>
确认新密码
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
minLength={6}
required
/>
</label>
<button className="btn" type="submit" disabled={credBusy}>
{credBusy ? "保存中…" : "保存账号"}
</button>
</form>
{lastToken ? (
<div className="token-box">
<div>
节点 #{lastToken.id} 新 Token(只显示一次,请复制):
</div>
<code className="mono">{lastToken.token}</code>
<button
type="button"
className="btn ghost"
onClick={() => {
void navigator.clipboard.writeText(lastToken.token);
setOk("已复制到剪贴板");
}}
>
复制
{tab === "account" ? (
<form className="add-form" onSubmit={onSaveCreds} autoComplete="off">
<h3>中控登录账号</h3>
{showHint ? (
<p className="meta">当前仍为默认账号,建议修改用户名与密码。</p>
) : (
<p className="meta">修改后旧会话将失效,需重新登录(局域网免登除外)。</p>
)}
<label className="switch-row">
<span>
本地局域网免登录
<span className="meta switch-hint">
开启后,从 192.168/10/172.16 等私网访问可自动进入,无需密码
</span>
</span>
<button
type="button"
className={`switch ${lanBypass ? "on" : ""}`}
disabled={lanBusy}
aria-pressed={lanBypass}
onClick={() => void onToggleLan(!lanBypass)}
>
<span className="switch-knob" />
</button>
</label>
<label>
新用户名
<input
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
autoComplete="off"
name="control-new-user"
placeholder="输入新用户名"
required
/>
</label>
<label>
当前密码
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="new-password"
name="control-cur-pass"
required
/>
</label>
<label>
新密码
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
name="control-new-pass"
minLength={6}
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>
</div>
</form>
) : null}
<form className="add-form" onSubmit={onAdd}>
<h3>添加策略机</h3>
<label>
名称
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如 云机-A"
required
/>
</label>
<label>
公网 Base URL
<input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://dc.hyf2.cc"
required
/>
</label>
<button className="btn" type="submit">
添加
</button>
</form>
{tab === "add" ? (
<form className="add-form" onSubmit={onAdd}>
<h3>添加策略机</h3>
<label>
名称
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如 云机-A"
required
/>
</label>
<label>
公网 Base URL
<input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://dc.hyf2.cc"
required
/>
</label>
<button className="btn" type="submit">
添加
</button>
</form>
) : null}
<div className="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>名称</th>
<th>URL</th>
<th>Token</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{nodes.map((n) => (
<tr key={n.id}>
<td>{n.id}</td>
<td>{n.name}</td>
<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>
{tab === "list" ? (
<div>
{lastToken ? (
<div className="token-box">
<div>节点 #{lastToken.id} 新 Token(只显示一次,请复制):</div>
<code className="mono">{lastToken.token}</code>
<button
type="button"
className="btn ghost"
onClick={() => {
void navigator.clipboard.writeText(lastToken.token);
setOk("已复制到剪贴板");
}}
>
复制
</button>
</div>
) : null}
<div className="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>名称</th>
<th>URL</th>
<th>Token</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{nodes.map((n) => (
<tr key={n.id}>
<td>{n.id}</td>
<td>{n.name}</td>
<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>
);
}
+72
View File
@@ -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;