diff --git a/.env.example b/.env.example index b41dc32..5d433f0 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,8 @@ APP_NAME=VPS 资产管理系统 # 写操作(POST/PUT/DELETE)鉴权密钥 -# 留空 = 不校验(适用于纯 Tailscale 内网环境) -# 配置后,所有写操作需在请求头携带 X-API-Key: <此密钥> +# Tailscale 内网(100.64.0.0/10,如 100.89.x.x)与本机来源自动放行,无需携带; +# 外部来源必须携带 X-API-Key: <此密钥>;留空则外部来源一律拒绝(仅内网可用) API_KEY= # Agent 上报鉴权密钥(可选) diff --git a/app/core/config.py b/app/core/config.py index daddf5c..13ce77b 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,7 +1,8 @@ """应用配置 从项目根目录的 .env 文件加载配置(若存在)。 -API_KEY 留空表示不启用写操作鉴权(适用于纯内网环境)。 +API_KEY 用于保护写操作:Tailscale 内网/本机来源放行,外部来源必须携带正确密钥; +留空则外部来源一律拒绝(仅内网可用)。 """ import os @@ -17,7 +18,8 @@ class Settings: """应用配置项""" APP_NAME: str = os.getenv("APP_NAME", "VPS 资产管理系统") - # 写操作(POST/PUT/DELETE)鉴权密钥;留空则不校验 + # 写操作(POST/PUT/DELETE)鉴权密钥;Tailscale 内网/本机来源忽略校验, + # 外部来源必须携带 X-API-Key(留空则外部来源直接拒绝) API_KEY: str = os.getenv("API_KEY", "") # Agent 上报鉴权密钥;配置后 Agent 上报需携带 X-Agent-Key AGENT_KEY: str = os.getenv("AGENT_KEY", "") diff --git a/app/core/security.py b/app/core/security.py index 30ce499..2630190 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -1,17 +1,26 @@ """API Key 认证依赖 用于保护写操作(POST/PUT/DELETE)。 -- 若 settings.API_KEY 为空,则放行所有请求(纯内网场景)。 -- 若已配置,则要求请求头携带正确的 X-API-Key,否则返回 401。 +- 来自可信内网(Tailscale 100.64.0.0/10、本机回环)的请求直接放行; +- 其余来源:API_KEY 已配置则校验 X-API-Key,未配置则拒绝(防止外部裸奔)。 """ import hmac +import ipaddress from typing import Optional -from fastapi import Header, HTTPException, status +from fastapi import Header, HTTPException, Request, status from app.core.config import settings +# 可信来源网段:Tailscale 使用 CGNAT 100.64.0.0/10 分配内网 IP(如 100.89.x.x); +# 回环地址覆盖 tailscale serve 代理转发与本地开发场景。 +TRUSTED_NETWORKS = [ + ipaddress.ip_network("100.64.0.0/10"), + ipaddress.ip_network("127.0.0.0/8"), + ipaddress.ip_network("::1/128"), +] + def _key_matches(provided: Optional[str], expected: str) -> bool: """常量时间比较密钥,避免时序旁路泄露密钥长度/前缀信息""" @@ -20,12 +29,44 @@ def _key_matches(provided: Optional[str], expected: str) -> bool: return hmac.compare_digest(provided.encode(), expected.encode()) +def _client_ip(request: Request) -> str: + """获取客户端真实 IP + + tailscale serve 转发到本机时 request.client 为回环地址,真实来源在 + X-Forwarded-For 头中;仅信任来自回环的 XFF,防止外部直连时伪造 + X-Forwarded-For 绕过鉴权。 + """ + host = request.client.host if request.client else "" + if host in ("127.0.0.1", "::1"): + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",")[0].strip() + return host + + +def _is_trusted(ip: str) -> bool: + """判断来源 IP 是否属于可信内网(Tailscale 网段 / 本机回环)""" + if not ip: + return False + try: + addr = ipaddress.ip_address(ip) + except ValueError: + return False + return any(addr in net for net in TRUSTED_NETWORKS) + + async def require_api_key( + request: Request, x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"), ) -> None: - """校验 API Key(可选启用)""" - if not settings.API_KEY: + """校验 API Key:Tailscale 内网/本机放行,外部来源必须携带有效 Key""" + if _is_trusted(_client_ip(request)): return + if not settings.API_KEY: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="外部访问需要 API Key,请先在 .env 配置 API_KEY 并设置到前端", + ) if not _key_matches(x_api_key, settings.API_KEY): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/app/services/account_service.py b/app/services/account_service.py index b81e1f8..5e0020b 100644 --- a/app/services/account_service.py +++ b/app/services/account_service.py @@ -9,6 +9,7 @@ from typing import Dict, List from fastapi import HTTPException, status from sqlalchemy import func +from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select from app.core import crypto @@ -53,8 +54,15 @@ def _norm_platform(platform: str | None) -> str: def _check_name_taken(session: Session, name: str, platform: str, exclude_id: int | None = None) -> None: - """校验 (platform, name) 联合唯一:同平台内不允许重名,跨平台可复用""" - stmt = select(Account).where(Account.name == name, Account.platform == platform) + """校验 (platform, name) 联合唯一:同平台内不允许重名,跨平台可复用 + + platform 用 coalesce 归一化匹配:历史数据的 NULL 与空串视为同一平台, + 避免出现同名账号在不同"空平台"上各存一条。 + """ + stmt = select(Account).where( + Account.name == name, + func.coalesce(Account.platform, "") == platform, + ) if exclude_id is not None: stmt = stmt.where(Account.id != exclude_id) if session.exec(stmt).first(): @@ -73,8 +81,16 @@ def create_account(session: Session, data: AccountCreate) -> AccountRead: account = Account(name=name, platform=platform, remark=data.remark, login_user=data.login_user) account.login_password_encrypted = crypto.encrypt(data.login_password) account.api_config_encrypted = crypto.encrypt(data.api_config) - session.add(account) - session.commit() + try: + session.add(account) + session.commit() + except IntegrityError: + # 并发创建同名账号时唯一约束兜底:检查与提交之间存在竞态窗口 + session.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"该平台下账号已存在:{name}", + ) session.refresh(account) return _to_read(account, _asset_counts(session)) @@ -101,8 +117,16 @@ def update_account(session: Session, account_id: int, data: AccountUpdate) -> Ac account.login_password_encrypted = crypto.encrypt(data.login_password) if data.api_config is not None: account.api_config_encrypted = crypto.encrypt(data.api_config) - session.add(account) - session.commit() + try: + session.add(account) + session.commit() + except IntegrityError: + # 重命名撞上已有账号时唯一约束兜底,与 _check_name_taken 存在竞态窗口 + session.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"该平台下账号已存在:{account.name}", + ) session.refresh(account) return _to_read(account, _asset_counts(session)) diff --git a/static/js/modals.js b/static/js/modals.js index 1355c4a..1cb6a63 100644 --- a/static/js/modals.js +++ b/static/js/modals.js @@ -231,7 +231,7 @@ const AccountModal = {

修改账号标识后,引用该账号的资产会自动同步更新。

- +
diff --git a/static/js/store.js b/static/js/store.js index 62296ee..ed54a32 100644 --- a/static/js/store.js +++ b/static/js/store.js @@ -26,6 +26,7 @@ const store = reactive({ assetModal: { show: false, editing: null, form: null }, providerModal: { show: false, editing: null, form: null }, accountModal: { show: false, editing: null, form: null }, + accountSaving: false, // 账号保存中锁,防止双击/重复提交触发后端 400 重名 accountsModal: { show: false, providerSlug: null }, // 账号查看弹窗:providerSlug 为 null 时看全部 }); @@ -181,7 +182,10 @@ function openAccountEdit(a) { store.accountModal = { show: true, editing: a.id, form: { name: a.name, platform: a.platform || '', remark: a.remark || '', login_user: a.login_user || '', login_password: '', api_config: '' } }; } async function saveAccount() { + // 提交锁:双击/网络慢时重复点击会发出两次请求,第二次必然重名 400 + if (store.accountSaving) return; store.error = ''; + store.accountSaving = true; const f = store.accountModal.form; const payload = { name: f.name, platform: f.platform || null, remark: f.remark || null, @@ -196,6 +200,7 @@ async function saveAccount() { // 重命名会同步更新资产端引用,需一并刷新资产 await Promise.all([loadAccounts(), loadAssets()]); } catch (e) { store.error = e.message; } + finally { store.accountSaving = false; } } async function deleteAccount(a) { let msg = '确认删除账号「' + a.name + '」?';