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;