feat: 强化打包配置与服务安全

This commit is contained in:
shen 2026-07-18 13:30:56 +08:00
parent d1f070b251
commit a48909f3bc
26 changed files with 779 additions and 203 deletions

View File

@ -2,17 +2,21 @@
# 复制此文件为 .env 并修改为实际值
# ---- 目录配置 ----
# 分支源码根目录,每个分支一个子目录(如 ReadoorBranches/main、ReadoorBranches/dev
# 源码根目录;未配置远程仓库时,每个分支一个子目录
GIT_SOURCE_BASE=./ReadoorBranches
# 配置远程仓库时使用的唯一共享源码工作目录;服务会串行切分支、清理并复制快照
GIT_SOURCE_DIR=./ReadoorBranches/workspace
# 打包输出基础目录(每次打包会在此目录下创建子目录)
BUILD_BASE_DIR=./build
# ---- 分支源码管理 ----
# Git 远程仓库地址,分支目录不存在时自动 clone
GIT_REMOTE_URL=https://pineapple.readoor.cn:9080/trn/triapp.git
GIT_USERNAME=shenlei
GIT_PASSWORD='&s9U5fbreA'
# Git 远程仓库地址;配置后所有分支共用 GIT_SOURCE_DIR
GIT_REMOTE_URL=https://git.example.com/group/repo.git
# 私有仓库:使用只读 Personal Access Token不要使用账号登录密码。
GIT_USERNAME=git-readonly-user
GIT_PASSWORD=cc774880b9d9b368c5f24a3821ee745dd5e4c949
# ---- 服务端口 ----
BACKEND_PORT=5002
@ -20,11 +24,23 @@ FRONTEND_PORT=5999
# ---- 管理员账号 ----
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
ADMIN_PASSWORD=change-this-before-starting
# ---- JWT 认证 ----
# JWT 签名密钥(生产环境务必修改为随机字符串)
JWT_SECRET=ios-build-server-secret-key-change-in-production
JWT_SECRET=replace-with-a-random-string-of-at-least-32-characters
# ---- 公网部署 ----
# production 会拒绝默认密钥、通配 CORS 和未配置的可信主机。
APP_ENV=production
# Uvicorn 只监听本机,由 Nginx/Caddy 提供 HTTPS 和公网访问。
BIND_HOST=127.0.0.1
CORS_ALLOWED_ORIGINS=https://build.example.com
TRUSTED_HOSTS=build.example.com
# 登录失败 5 次后锁定 15 分钟。
LOGIN_RATE_LIMIT_MAX_ATTEMPTS=5
LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
LOGIN_RATE_LIMIT_LOCKOUT_SECONDS=900
# ---- 并发与清理 ----
# 最大并行打包数

35
AGENTS.md Normal file
View File

@ -0,0 +1,35 @@
# iOS Build Server
FastAPI + Vue 3 iOS app auto-packaging service. Supports Ad_Hoc and App_Store builds with web UI, real-time logs, multi-user auth, and health monitoring.
## Tech Stack
- **Backend**: Python 3.9+, FastAPI, SQLAlchemy (SQLite), JWT auth
- **Frontend**: Vue 3, Vite, Vue Router
- **Tests**: pytest (backend), vitest (frontend)
- **Deploy**: macOS launchd, deploy.sh management script
## Key Commands
```bash
./start.sh # Dev mode (backend + frontend hot reload)
./deploy.sh build # Install deps + build frontend
./deploy.sh start/stop/restart/status
./deploy.sh test # Run all tests (backend + frontend)
```
## Architecture
- `backend/routers/` — API routes: auth, users, config, apps, tasks
- `backend/services/` — build_service (packaging), build_queue (async queue), log_streamer (WebSocket)
- `backend/deps.py` — JWT auth dependency used by all protected routes
- `backend/config.py` — loads .env, defines all config constants
- `frontend/src/views/` — BuildView, HistoryView, ConfigView
- `config.json` — runtime config (apps, schemes, servers, upload, branches) managed via web UI
## Conventions
- All backend routes require JWT auth except `/api/auth/login` and `/api/health`
- Admin-only routes: user management (`/api/users/*`)
- Config stored in `config.json` (apps/schemes/servers), `.env` (infra), SQLite `build_config` table (build settings)
- Chinese UI and documentation throughout

View File

@ -7,6 +7,7 @@
- [环境要求](#环境要求)
- [快速开始](#快速开始)
- [配置说明](#配置说明)
- [公网部署](#公网部署)
- [服务管理](#服务管理)
- [多用户管理](#多用户管理)
- [Watchdog 健康监测](#watchdog-健康监测)
@ -72,11 +73,11 @@ ADMIN_PASSWORD=your_secure_password
启动后访问 `http://<服务器IP>:8000` 即可使用。
### 4.(可选)初始化分支源码目录
### 4.(可选)初始化源码目录
系统现在默认直接从分支源码目录打包,每个分支对应 `GIT_SOURCE_BASE` 下的一个子目录
配置 `GIT_REMOTE_URL` 后,系统默认使用唯一的共享源码目录 `GIT_SOURCE_DIR`。任务会串行执行拉取、切分支、清理和复制,复制完成后在各自的打包目录中并行构建
如果已配置 `GIT_REMOTE_URL`,首次打包某个分支时会自动 clone如果未配置则需要先手动准备分支目录:
未配置远程仓库时,需要继续手动准备每个分支目录:
```bash
# 创建分支源码根目录
@ -86,7 +87,7 @@ mkdir -p /path/to/ReadoorBranches
git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
```
如果已配置 `GIT_REMOTE_URL`,其他分支无需手动 clone首次打包时会自动拉取
配置远程仓库时,无需手动 clone首次打包会初始化共享目录
### 5.(可选)启用健康监测
@ -110,9 +111,12 @@ git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
| 变量 | 示例 | 说明 |
|------|------|------|
| `GIT_SOURCE_BASE` | `/Users/shen/Work/Code/iOSBuildServer/ReadoorBranches` | 分支源码根目录,每个分支一个子目录,仅提供 iOS 工程源码 |
| `GIT_SOURCE_BASE` | `/Users/shen/Work/Code/iOSBuildServer/ReadoorBranches` | 人工维护模式下的分支源码根目录 |
| `GIT_SOURCE_DIR` | `/Users/shen/Work/Code/iOSBuildServer/ReadoorBranches/workspace` | 配置远程仓库时唯一的共享源码工作目录 |
| `BUILD_BASE_DIR` | `/Users/shen/Work/Code/iOSBuildServer/build` | 打包产物输出基础目录 |
| `GIT_REMOTE_URL` | `git@github.com:org/repo.git` | 远程仓库地址,分支目录不存在时自动 clone |
| `GIT_REMOTE_URL` | `git@github.com:org/repo.git` | 远程仓库地址,首次构建时自动初始化共享源码目录 |
私有仓库请创建仅有 `read_repository` 权限的 Personal Access Token并配置为 `GIT_PASSWORD``GIT_USERNAME` 使用该 Token 所属账号。不要在 `.env`、`config.json` 或 Git 仓库中保存账号登录密码。
### 服务配置
@ -123,6 +127,25 @@ git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
| `ADMIN_PASSWORD` | `admin123` | 初始管理员密码 |
| `JWT_SECRET` | `ios-build-server-secret-key-change-in-production` | JWT 签名密钥(生产环境务必修改) |
## 公网部署
不要将 Uvicorn 端口直接暴露到公网。使用 Nginx 或 Caddy 提供 HTTPS服务仅监听 `127.0.0.1`。项目提供了 [nginx.conf.example](/Users/shen/Work/Code/iOSBuildServer/deploy/nginx.conf.example) 作为模板。
公网 `.env` 至少需要如下配置:
```bash
APP_ENV=production
BIND_HOST=127.0.0.1
JWT_SECRET=<至少 32 位随机字符串>
ADMIN_PASSWORD=<至少 12 位强密码>
CORS_ALLOWED_ORIGINS=https://build.example.com
TRUSTED_HOSTS=build.example.com
GIT_USERNAME=<只读 Token 所属账号>
GIT_PASSWORD=< read_repository 权限的 Personal Access Token>
```
设置完成后执行 `chmod 600 .env`,并在云安全组和系统防火墙中只开放 `80/443`。`APP_ENV=production` 会在默认 JWT、弱管理员密码、通配 CORS 或未设置可信域名时拒绝启动。
### 打包配置
| 变量 | 默认值 | 说明 |
@ -541,22 +564,24 @@ cp .env.example .env
### Q: 如何按分支打包
1. 在 `.env` 中配置 `GIT_SOURCE_BASE``GIT_REMOTE_URL`
1. 在 `.env` 中配置 `GIT_SOURCE_BASE`、`GIT_SOURCE_DIR``GIT_REMOTE_URL`
2. 在管理页面「分支管理」中添加需要打包的分支(默认已有 `main`
3. 打包时在「代码分支」下拉框中选择分支
4. 首次打包某个分支时会自动从远程 clone后续打包会自动 `git pull` 更新
5. 每个分支有独立的源码目录,支持并行打包不同分支
4. 首次打包会初始化共享源码目录;每次任务会串行 `git fetch`、切换目标分支、清理并复制源码快照
5. 快照复制完成后,依赖安装和 Xcode 打包仍在独立目录中并行执行
### Q: 分支目录占满磁盘怎么办
### Q: 源码目录占满磁盘怎么办
```bash
# 查看各分支目录大小
du -sh /path/to/ReadoorBranches/*
# 远程仓库模式下只有一个共享工作目录
du -sh /path/to/ReadoorBranches/workspace
# 删除不用的分支目录
rm -rf /path/to/ReadoorBranches/old-branch
# 打包目录由 BUILD_DIR_RETENTION_HOURS 自动清理;可查看其占用
du -sh /path/to/build/build_readoor_*
```
共享工作目录由服务在每次打包前自动同步和清理。请勿在服务运行期间手动删除或修改该目录。
### Q: 如何运行测试
```bash

View File

@ -47,10 +47,11 @@ SKINS_DIR = DATA_DIR / "skins"
# 打包基础目录
BUILD_BASE_DIR = _resolve_server_path(os.getenv("BUILD_BASE_DIR", str(SERVER_ROOT / "build")))
# Git 分支源码目录
# GIT_SOURCE_BASE: 分支源码的根目录,每个分支一个子目录
# GIT_REMOTE_URL: 远程仓库地址,分支目录不存在时自动 clone
# Git 源码目录
# 配置 GIT_REMOTE_URL 时,所有分支共用 GIT_SOURCE_DIR构建前会串行切换分支并复制快照。
# 未配置远程仓库时,继续兼容 GIT_SOURCE_BASE/<branch> 的人工维护目录。
GIT_SOURCE_BASE = _resolve_server_path(os.getenv("GIT_SOURCE_BASE", str(SERVER_ROOT / "ReadoorBranches")))
GIT_SOURCE_DIR = _resolve_server_path(os.getenv("GIT_SOURCE_DIR", str(GIT_SOURCE_BASE / "workspace")))
GIT_REMOTE_URL = os.getenv("GIT_REMOTE_URL", "")
GIT_USERNAME = os.getenv("GIT_USERNAME", "")
GIT_PASSWORD = os.getenv("GIT_PASSWORD", "")
@ -89,10 +90,15 @@ def mask_git_remote_url(url: str) -> str:
def get_source_dir(branch: str) -> Path:
"""获取指定分支的源码目录"""
"""获取人工维护模式下指定分支的源码目录"""
return GIT_SOURCE_BASE / branch
def get_shared_source_dir() -> Path:
"""获取配置远程仓库时使用的唯一共享源码工作目录。"""
return GIT_SOURCE_DIR
# 需要拷贝的目录和文件
COPY_ITEMS = [
"readoor",
@ -108,6 +114,14 @@ COPY_ITEMS = [
# 服务端口
BACKEND_PORT = int(os.getenv("BACKEND_PORT", "8000"))
# 公网部署安全配置。开发环境保持低门槛,生产环境会在启动时强制校验关键项。
APP_ENV = os.getenv("APP_ENV", "development").lower()
CORS_ALLOWED_ORIGINS = [origin.strip() for origin in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") if origin.strip()]
TRUSTED_HOSTS = [host.strip() for host in os.getenv("TRUSTED_HOSTS", "localhost,127.0.0.1,testserver").split(",") if host.strip()]
LOGIN_RATE_LIMIT_MAX_ATTEMPTS = int(os.getenv("LOGIN_RATE_LIMIT_MAX_ATTEMPTS", "5"))
LOGIN_RATE_LIMIT_WINDOW_SECONDS = int(os.getenv("LOGIN_RATE_LIMIT_WINDOW_SECONDS", "300"))
LOGIN_RATE_LIMIT_LOCKOUT_SECONDS = int(os.getenv("LOGIN_RATE_LIMIT_LOCKOUT_SECONDS", "900"))
# 数据库路径。测试可通过 DATABASE_URL 注入临时 SQLite避免影响运行库。
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{Path(__file__).parent / 'build_server.db'}")
@ -131,3 +145,23 @@ ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123")
JWT_SECRET = os.getenv("JWT_SECRET", "ios-build-server-secret-key-change-in-production")
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = 24
def validate_production_security() -> None:
"""阻止带默认凭据或无访问边界的生产服务启动。"""
if APP_ENV != "production":
return
errors = []
if (JWT_SECRET == "ios-build-server-secret-key-change-in-production"
or JWT_SECRET.startswith("replace-") or len(JWT_SECRET) < 32):
errors.append("JWT_SECRET 必须设置为至少 32 位的随机字符串")
if (ADMIN_PASSWORD == "admin123" or ADMIN_PASSWORD.startswith("change-")
or len(ADMIN_PASSWORD) < 12):
errors.append("ADMIN_PASSWORD 必须设置为至少 12 位的强密码")
if not CORS_ALLOWED_ORIGINS or "*" in CORS_ALLOWED_ORIGINS:
errors.append("CORS_ALLOWED_ORIGINS 必须设置为实际 HTTPS 前端域名,且不能为 *")
if not os.getenv("TRUSTED_HOSTS") or "*" in TRUSTED_HOSTS:
errors.append("TRUSTED_HOSTS 必须设置为实际服务域名")
if errors:
raise RuntimeError("生产环境安全配置不完整: " + "; ".join(errors))

View File

@ -38,13 +38,13 @@ def _seed_admin():
"""初始化管理员账号(从 .env 配置)"""
from .models import User
from .config import ADMIN_USERNAME, ADMIN_PASSWORD
import hashlib
from .security import hash_password
db = SessionLocal()
try:
if not db.query(User).filter(User.username == ADMIN_USERNAME).first():
pw_hash = hashlib.sha256(ADMIN_PASSWORD.encode()).hexdigest()
db.add(User(id="admin-001", username=ADMIN_USERNAME, password_hash=pw_hash, is_admin=True))
db.add(User(id="admin-001", username=ADMIN_USERNAME,
password_hash=hash_password(ADMIN_PASSWORD), is_admin=True))
db.commit()
finally:
db.close()

View File

@ -8,9 +8,8 @@ from .config import JWT_SECRET, JWT_ALGORITHM
security = HTTPBearer()
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
"""验证 JWT token返回用户信息"""
token = credentials.credentials
def decode_current_user(token: str) -> dict:
"""验证 JWT token返回用户身份。"""
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return {"username": payload["sub"], "is_admin": payload.get("is_admin", False)}
@ -18,3 +17,15 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(securit
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期,请重新登录")
except jwt.InvalidTokenError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭据")
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
"""验证 HTTP Bearer JWT token返回用户信息。"""
return decode_current_user(credentials.credentials)
def require_admin(user: dict = Depends(get_current_user)) -> dict:
"""限制仅管理员可访问的管理和敏感配置接口。"""
if not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
return user

View File

@ -1,15 +1,17 @@
"""FastAPI 主入口"""
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from .database import init_db
from .routers import config, apps, tasks, auth, users
from .services.log_streamer import log_streamer
from .config import BACKEND_PORT
from .config import BACKEND_PORT, CORS_ALLOWED_ORIGINS, TRUSTED_HOSTS, validate_production_security
from .deps import decode_current_user
STATIC_DIR = Path(__file__).parent / "static"
@ -17,6 +19,7 @@ STATIC_DIR = Path(__file__).parent / "static"
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期"""
validate_production_security()
init_db()
yield
@ -27,15 +30,18 @@ app = FastAPI(
lifespan=lifespan,
)
# CORS
# 仅接受显式允许的跨域前端;同域部署不需要 CORS 配置。
if CORS_ALLOWED_ORIGINS:
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_origins=CORS_ALLOWED_ORIGINS,
allow_credentials=False,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type"],
)
app.add_middleware(TrustedHostMiddleware, allowed_hosts=TRUSTED_HOSTS)
# 路由
app.include_router(auth.router)
app.include_router(users.router)
@ -46,8 +52,19 @@ app.include_router(tasks.router)
@app.websocket("/ws/tasks/{task_id}")
async def websocket_logs(websocket: WebSocket, task_id: str):
"""WebSocket 实时日志"""
await websocket.accept()
"""经 JWT 鉴权的 WebSocket 实时日志。"""
protocol = websocket.headers.get("sec-websocket-protocol", "")
token_protocol = next((item.strip() for item in protocol.split(",") if item.strip().startswith("jwt.")), "")
if not token_protocol:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
try:
decode_current_user(token_protocol.removeprefix("jwt."))
except Exception:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
await websocket.accept(subprotocol=token_protocol)
try:
async for msg in log_streamer.subscribe(task_id):
await websocket.send_json(msg)

View File

@ -5,12 +5,31 @@ from .config import load_config
router = APIRouter(prefix="/api", tags=["apps"])
# 每个 App 都只能使用明确允许的 Scheme。
_APP_SCHEME_RULES = {
"申学": ("readoorShenXue",),
"申学APP": ("readoorShenXue",),
"英汉大词典测试": ("readoorDict",),
"英汉大词典": ("readoorDict",),
}
_DEFAULT_SCHEME_NAMES = ("readoor31", "readoor31OtherPay")
def get_allowed_scheme_names(app: dict) -> tuple[str, ...]:
"""返回 App 可使用的 Scheme 名称。"""
return _APP_SCHEME_RULES.get(app.get("name", ""), _DEFAULT_SCHEME_NAMES)
@router.get("/apps")
async def get_apps_for_build():
"""获取 apps 列表(供打包选择)"""
config = load_config()
return config.get("apps", {})
apps = {}
for app_id, app in config.get("apps", {}).items():
app_data = app.copy()
app_data["allowed_scheme_names"] = list(get_allowed_scheme_names(app))
apps[app_id] = app_data
return apps
@router.get("/schemes")

View File

@ -1,16 +1,24 @@
"""认证 API"""
import jwt
import threading
import time
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from ..schemas import LoginRequest, LoginResponse
from ..config import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS
from ..config import (
JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS,
LOGIN_RATE_LIMIT_MAX_ATTEMPTS, LOGIN_RATE_LIMIT_WINDOW_SECONDS, LOGIN_RATE_LIMIT_LOCKOUT_SECONDS,
)
from ..deps import get_current_user
from ..database import get_db
from ..models import User
from ..security import hash_password, verify_password
router = APIRouter(prefix="/api/auth", tags=["auth"])
_login_attempts: dict[str, tuple[int, float, float]] = {}
_login_attempts_lock = threading.Lock()
def _create_token(username: str, is_admin: bool) -> str:
@ -23,19 +31,49 @@ def _create_token(username: str, is_admin: bool) -> str:
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def _verify_password(password: str, password_hash: str) -> bool:
"""校验密码"""
import hashlib
return hashlib.sha256(password.encode()).hexdigest() == password_hash
def _check_login_rate_limit(client_ip: str) -> None:
"""按来源 IP 限制登录失败次数,降低在线暴力破解风险。"""
now = time.monotonic()
with _login_attempts_lock:
attempts, first_failure, locked_until = _login_attempts.get(client_ip, (0, now, 0))
if locked_until > now:
raise HTTPException(status_code=429, detail="登录失败次数过多,请稍后再试")
if now - first_failure > LOGIN_RATE_LIMIT_WINDOW_SECONDS:
_login_attempts.pop(client_ip, None)
def _record_login_failure(client_ip: str) -> None:
now = time.monotonic()
with _login_attempts_lock:
attempts, first_failure, _ = _login_attempts.get(client_ip, (0, now, 0))
if now - first_failure > LOGIN_RATE_LIMIT_WINDOW_SECONDS:
attempts, first_failure = 0, now
attempts += 1
locked_until = now + LOGIN_RATE_LIMIT_LOCKOUT_SECONDS if attempts >= LOGIN_RATE_LIMIT_MAX_ATTEMPTS else 0
_login_attempts[client_ip] = (attempts, first_failure, locked_until)
def _clear_login_failures(client_ip: str) -> None:
with _login_attempts_lock:
_login_attempts.pop(client_ip, None)
@router.post("/login", response_model=LoginResponse)
async def login(request: LoginRequest, db: Session = Depends(get_db)):
async def login(credentials: LoginRequest, request: Request, db: Session = Depends(get_db)):
"""用户登录"""
user = db.query(User).filter(User.username == request.username).first()
if not user or not _verify_password(request.password, user.password_hash):
client_ip = request.client.host if request.client else "unknown"
_check_login_rate_limit(client_ip)
user = db.query(User).filter(User.username == credentials.username).first()
valid, needs_upgrade = verify_password(credentials.password, user.password_hash) if user else (False, False)
if not user or not valid:
_record_login_failure(client_ip)
raise HTTPException(status_code=401, detail="用户名或密码错误")
if needs_upgrade:
user.password_hash = hash_password(credentials.password)
db.commit()
_clear_login_failures(client_ip)
token = _create_token(user.username, user.is_admin)
return LoginResponse(token=token, username=user.username, is_admin=user.is_admin)

View File

@ -6,7 +6,7 @@ import shutil
import zipfile
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from sqlalchemy.orm import Session
from ..database import get_db
@ -21,9 +21,18 @@ from ..config import (
DEFAULT_BUILD_DIR_RETENTION_HOURS,
SKINS_DIR,
)
from ..deps import get_current_user
from ..deps import get_current_user, require_admin
router = APIRouter(prefix="/api/config", tags=["config"], dependencies=[Depends(get_current_user)])
def _require_config_permission(request: Request, user: dict = Depends(get_current_user)) -> dict:
"""普通用户仅可管理 Apps其余配置及凭据只允许管理员访问。"""
path = request.url.path.rstrip("/")
if path == "/api/config/apps" or path.startswith("/api/config/apps/"):
return user
return require_admin(user)
router = APIRouter(prefix="/api/config", tags=["config"], dependencies=[Depends(_require_config_permission)])
_config_lock = asyncio.Lock()

View File

@ -34,6 +34,20 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
app = apps[task.app_id]
scheme = schemes[task.scheme_id]
scheme_name = scheme.get("name", "")
if task.build_type not in {"Ad_Hoc", "App_Store"}:
raise HTTPException(status_code=400, detail="不支持的打包类型")
if not app.get("certificates", {}).get(task.build_type):
raise HTTPException(status_code=400, detail=f"该 App 未配置 {task.build_type} 证书")
from .apps import get_allowed_scheme_names
allowed_scheme_names = get_allowed_scheme_names(app)
if allowed_scheme_names and scheme_name not in allowed_scheme_names:
raise HTTPException(
status_code=400,
detail=f"该 App 只能使用 Scheme: {', '.join(allowed_scheme_names)}",
)
task_id = str(uuid.uuid4())
db_task = Task(
@ -42,7 +56,7 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
app_name=app.get("name", ""),
build_type=task.build_type,
scheme_id=task.scheme_id,
scheme_name=scheme.get("displayName") or scheme.get("name", ""),
scheme_name=scheme.get("displayName") or scheme_name,
obfuscation=task.obfuscation,
branch=task.branch,
status="pending",

View File

@ -1,12 +1,12 @@
"""用户管理 API仅管理员"""
import uuid
import hashlib
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import User
from ..deps import get_current_user
from ..security import hash_password
router = APIRouter(prefix="/api/users", tags=["users"], dependencies=[Depends(get_current_user)])
@ -16,10 +16,6 @@ def _require_admin(user: dict):
raise HTTPException(status_code=403, detail="需要管理员权限")
def _hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
@router.get("")
async def list_users(user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""获取用户列表"""
@ -39,15 +35,15 @@ async def create_user(data: dict, user: dict = Depends(get_current_user), db: Se
if not username or not password:
raise HTTPException(status_code=400, detail="用户名和密码不能为空")
if len(password) < 6:
raise HTTPException(status_code=400, detail="密码至少 6")
if len(password) < 12:
raise HTTPException(status_code=400, detail="密码至少 12")
if db.query(User).filter(User.username == username).first():
raise HTTPException(status_code=400, detail="用户名已存在")
new_user = User(
id=str(uuid.uuid4()),
username=username,
password_hash=_hash_password(password),
password_hash=hash_password(password),
is_admin=is_admin,
)
db.add(new_user)
@ -65,10 +61,10 @@ async def change_password(user_id: str, data: dict, user: dict = Depends(get_cur
raise HTTPException(status_code=404, detail="用户不存在")
new_password = data.get("password", "").strip()
if not new_password or len(new_password) < 6:
raise HTTPException(status_code=400, detail="密码至少 6")
if not new_password or len(new_password) < 12:
raise HTTPException(status_code=400, detail="密码至少 12")
target.password_hash = _hash_password(new_password)
target.password_hash = hash_password(new_password)
db.commit()
return {"message": "密码已更新"}

19
backend/security.py Normal file
View File

@ -0,0 +1,19 @@
"""认证相关的密码散列与兼容迁移工具。"""
import hashlib
import hmac
import bcrypt
def hash_password(password: str) -> str:
"""使用 bcrypt 保存密码,避免快速散列被离线撞库。"""
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(password: str, stored_hash: str) -> tuple[bool, bool]:
"""返回 (密码是否正确, 是否需要将旧 SHA-256 散列升级为 bcrypt)。"""
if stored_hash.startswith("$2"):
return bcrypt.checkpw(password.encode("utf-8"), stored_hash.encode("utf-8")), False
legacy_hash = hashlib.sha256(password.encode("utf-8")).hexdigest()
return hmac.compare_digest(legacy_hash, stored_hash), True

View File

@ -16,6 +16,7 @@ from ..config import (
SKINS_DIR,
AUTOMATION_DIR,
get_source_dir,
get_shared_source_dir,
get_git_remote_url,
mask_git_remote_url,
)
@ -33,6 +34,11 @@ class BuildError(Exception):
self.detail = detail
# 共享源码目录只能同时被一个任务切换、清理和复制。
# 后续在各自 build_dir 中执行的配置、依赖安装和编译不受此锁限制。
source_prepare_lock = asyncio.Lock()
# 错误分类规则:(关键词列表, category, 友好提示)
_ERROR_RULES = [
(["No signing certificate", "Signing certificate \"", "Code Signing Error",
@ -136,18 +142,18 @@ def _db_update(db, task, **fields):
async def update_source(task_id: str, source_dir: Path, branch: str):
"""确保分支源码目录存在且为最新"""
"""将共享源码工作目录强制同步到指定远程分支。"""
remote_url = get_git_remote_url(with_credentials=True)
remote_url_masked = mask_git_remote_url(remote_url)
if not source_dir.exists():
# 首次:从远程 clone
cloned = not source_dir.exists()
if cloned:
if not remote_url:
raise Exception(f"源码目录不存在且未配置 GIT_REMOTE_URL: {source_dir}")
source_dir.parent.mkdir(parents=True, exist_ok=True)
await log_streamer.emit(task_id, f"克隆仓库: {remote_url_masked}{branch}")
await log_streamer.emit(task_id, f"初始化共享源码目录: {remote_url_masked}")
process = await asyncio.create_subprocess_exec(
"git", "clone", "--depth", "1", "-b", branch, "--single-branch", remote_url, str(source_dir),
"git", "clone", remote_url, str(source_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
@ -157,25 +163,32 @@ async def update_source(task_id: str, source_dir: Path, branch: str):
await log_streamer.emit(task_id, decoded)
await process.wait()
if process.returncode != 0:
raise Exception(f"git clone 失败: {branch}")
else:
# 已存在fetch + checkout + pull
await log_streamer.emit(task_id, f"更新分支源码: {branch}")
raise Exception("git clone 失败")
# git clone 成功即保证目录有效;已有目录则额外拦截误配置路径。
if not cloned and not (source_dir / ".git").exists():
raise Exception(f"共享源码目录不是 Git 仓库: {source_dir}")
await log_streamer.emit(task_id, f"同步共享源码到分支: {branch}")
if remote_url:
process = await asyncio.create_subprocess_exec(
"git", "remote", "set-url", "origin", remote_url,
cwd=str(source_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=str(source_dir), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
)
await process.wait()
if process.returncode != 0:
raise Exception("git remote set-url 失败")
# fetch
commands = [
(("git", "fetch", "origin"), "git fetch 失败"),
(("git", "checkout", "-B", branch, f"origin/{branch}"), f"git checkout {branch} 失败"),
(("git", "reset", "--hard", f"origin/{branch}"), f"git reset {branch} 失败"),
(("git", "clean", "-ffdx"), "git clean 失败"),
]
for command, error_message in commands:
process = await asyncio.create_subprocess_exec(
"git", "fetch", "origin",
cwd=str(source_dir),
stdout=asyncio.subprocess.PIPE,
*command, cwd=str(source_dir), stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
async for line in process.stdout:
@ -184,41 +197,47 @@ async def update_source(task_id: str, source_dir: Path, branch: str):
await log_streamer.emit(task_id, decoded)
await process.wait()
if process.returncode != 0:
raise Exception("git fetch 失败")
# checkout
process = await asyncio.create_subprocess_exec(
"git", "checkout", "-B", branch, f"origin/{branch}",
cwd=str(source_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
async for line in process.stdout:
decoded = line.decode("utf-8", errors="replace").strip()
if decoded:
await log_streamer.emit(task_id, decoded)
await process.wait()
if process.returncode != 0:
raise Exception(f"git checkout {branch} 失败")
# pull
process = await asyncio.create_subprocess_exec(
"git", "pull", "origin", branch,
cwd=str(source_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
async for line in process.stdout:
decoded = line.decode("utf-8", errors="replace").strip()
if decoded:
await log_streamer.emit(task_id, decoded)
await process.wait()
if process.returncode != 0:
raise Exception(f"git pull {branch} 失败")
raise Exception(error_message)
await log_streamer.emit(task_id, f"源码已就绪: {source_dir}")
async def get_source_commit(source_dir: Path) -> str:
"""读取当前共享源码快照的 commit SHA。"""
process = await asyncio.create_subprocess_exec(
"git", "rev-parse", "HEAD", cwd=str(source_dir),
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
)
output = await process.stdout.read()
await process.wait()
if process.returncode != 0:
raise Exception("读取源码 commit 失败")
return output.decode("utf-8", errors="replace").strip()
async def prepare_source_snapshot(task_id: str, task) -> tuple[Path, str]:
"""准备任务独立源码快照;远程仓库模式下此过程全局串行。"""
if not get_git_remote_url():
source_dir = get_source_dir(task.branch)
if not source_dir.exists():
raise Exception(
f"分支源码目录不存在: {source_dir},请先在 GIT_SOURCE_BASE 下准备分支代码,或配置 GIT_REMOTE_URL"
)
await log_streamer.emit(task_id, f"使用分支源码: {source_dir}")
return await copy_source_code(task_id, task, source_dir), ""
source_dir = get_shared_source_dir()
await log_streamer.emit(task_id, "等待共享源码准备队列...")
async with source_prepare_lock:
await log_streamer.emit(task_id, "开始准备共享源码快照")
await update_source(task_id, source_dir, task.branch)
commit = await get_source_commit(source_dir)
await log_streamer.emit(task_id, f"源码版本: {task.branch} @ {commit}")
build_dir = await copy_source_code(task_id, task, source_dir)
await log_streamer.emit(task_id, "共享源码快照已就绪,开始并行打包")
return build_dir, commit
async def run_build_task(task_id: str):
"""执行打包任务"""
from ..database import SessionLocal
@ -251,25 +270,16 @@ async def run_build_task(task_id: str):
async def _do_build():
nonlocal build_dir
# 1. 更新分支源码并拷贝
source_dir = get_source_dir(task.branch)
await asyncio.to_thread(_db_update, db, task, current_step="copy")
if get_git_remote_url():
# 配置了远程仓库,从分支目录获取源码
await update_source(task_id, source_dir, task.branch)
elif source_dir.exists():
# 分支目录已存在,直接使用
await log_streamer.emit(task_id, f"使用分支源码: {source_dir}")
else:
raise Exception(
f"分支源码目录不存在: {source_dir},请先在 GIT_SOURCE_BASE 下准备分支代码,或配置 GIT_REMOTE_URL"
)
build_dir = await copy_source_code(task_id, task, source_dir)
# 1. 串行准备源码快照;后续步骤在任务独立目录中并行执行。
await asyncio.to_thread(_db_update, db, task, current_step="source_prepare")
build_dir, source_commit = await prepare_source_snapshot(task_id, task)
await asyncio.to_thread(_db_update, db, task, build_dir=str(build_dir))
# 2. 生成配置
await asyncio.to_thread(_db_update, db, task, current_step="config")
config_data = await generate_config(task_id, task, build_dir)
config_data["SOURCE_BRANCH"] = task.branch
config_data["SOURCE_COMMIT"] = source_commit
await asyncio.to_thread(_db_update, db, task,
config_json=json.dumps(config_data, ensure_ascii=False))
@ -661,14 +671,12 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path)
# 清理
await log_streamer.emit(task_id, "清理项目...")
clean_cmd = (
f"xcodebuild clean -workspace {workspace_path.name} "
f"-scheme {scheme} "
f"-configuration Release "
f"-derivedDataPath {export_path / 'derived_data'}"
)
process = await asyncio.create_subprocess_shell(
clean_cmd,
process = await asyncio.create_subprocess_exec(
"xcodebuild", "clean",
"-workspace", workspace_path.name,
"-scheme", scheme,
"-configuration", "Release",
"-derivedDataPath", str(export_path / "derived_data"),
cwd=str(build_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
@ -681,17 +689,16 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path)
# Archive
await log_streamer.emit(task_id, "开始 Archive...")
archive_cmd = (
f"xcodebuild archive -workspace {workspace_path.name} "
f"-scheme {scheme} "
f"-configuration Release "
f"-archivePath {archive_path} "
f"-derivedDataPath {export_path / 'derived_data'} "
f"-destination generic/platform=ios -quiet"
)
archive_output = []
process = await asyncio.create_subprocess_shell(
archive_cmd,
process = await asyncio.create_subprocess_exec(
"xcodebuild", "archive",
"-workspace", workspace_path.name,
"-scheme", scheme,
"-configuration", "Release",
"-archivePath", str(archive_path),
"-derivedDataPath", str(export_path / "derived_data"),
"-destination", "generic/platform=ios",
"-quiet",
cwd=str(build_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
@ -710,15 +717,12 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path)
# 导出 IPA
await log_streamer.emit(task_id, "导出 IPA...")
export_plist = build_dir / "exportOptions.plist"
export_cmd = (
f"xcodebuild -exportArchive "
f"-archivePath {archive_path} "
f"-exportPath {export_path} "
f"-exportOptionsPlist {export_plist}"
)
export_output = []
process = await asyncio.create_subprocess_shell(
export_cmd,
process = await asyncio.create_subprocess_exec(
"xcodebuild", "-exportArchive",
"-archivePath", str(archive_path),
"-exportPath", str(export_path),
"-exportOptionsPlist", str(export_plist),
cwd=str(build_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,

View File

@ -18,6 +18,7 @@ if [ -f .env ]; then
fi
BACKEND_PORT="${BACKEND_PORT:-8000}"
BIND_HOST="${BIND_HOST:-127.0.0.1}"
WATCHDOG_INTERVAL="${WATCHDOG_INTERVAL:-30}" # 健康检查间隔(秒)
WATCHDOG_TIMEOUT="${WATCHDOG_TIMEOUT:-10}" # 健康检查超时(秒)
MAX_RESTART_ATTEMPTS="${MAX_RESTART_ATTEMPTS:-3}" # 连续重启上限,超过则冷却
@ -159,10 +160,12 @@ do_start() {
mkdir -p "$LOG_DIR"
info "启动后端服务 (端口: $BACKEND_PORT)..."
info "启动后端服务 ($BIND_HOST:$BACKEND_PORT)..."
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
--host 0.0.0.0 \
--host "$BIND_HOST" \
--port "$BACKEND_PORT" \
--proxy-headers \
--forwarded-allow-ips 127.0.0.1 \
--workers 1 \
> "$LOG_DIR/server.log" 2>&1 &
@ -170,11 +173,8 @@ do_start() {
sleep 1
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
local access_host
access_host=$(ipconfig getifaddr en0 2>/dev/null || true)
access_host="${access_host:-localhost}"
info "服务已启动 (PID: $(cat "$PID_FILE"))"
info "访问地址: http://$access_host:$BACKEND_PORT"
info "访问地址: http://$BIND_HOST:$BACKEND_PORT"
else
error "启动失败,请查看日志: $LOG_DIR/server.log"
rm -f "$PID_FILE"
@ -382,8 +382,10 @@ do_watchdog_loop() {
setup_venv
mkdir -p "$LOG_DIR"
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
--host 0.0.0.0 \
--host "$BIND_HOST" \
--port "$BACKEND_PORT" \
--proxy-headers \
--forwarded-allow-ips 127.0.0.1 \
--workers 1 \
>> "$LOG_DIR/server.log" 2>&1 &
echo $! > "$PID_FILE"

40
deploy/nginx.conf.example Normal file
View File

@ -0,0 +1,40 @@
# 将 build.example.com、证书路径和端口替换为实际值。
limit_req_zone $binary_remote_addr zone=build_login:10m rate=5r/m;
server {
listen 80;
server_name build.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name build.example.com;
ssl_certificate /etc/letsencrypt/live/build.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/build.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 50m;
location = /api/auth/login {
limit_req zone=build_login burst=5 nodelay;
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
}

View File

@ -6,7 +6,7 @@
<nav v-if="isLoggedIn" class="nav">
<router-link to="/build">打包</router-link>
<router-link to="/history">历史</router-link>
<router-link v-if="isAdmin" to="/admin">管理</router-link>
<router-link to="/admin">管理</router-link>
</nav>
<div v-if="!isLoggedIn" class="user-info">
<button class="btn-login" @click="showLogin = true">登录</button>

View File

@ -0,0 +1,96 @@
import { describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import BuildView from '../views/BuildView.vue'
const createTestRouter = () => createRouter({
history: createMemoryHistory(),
routes: [{ path: '/build', component: BuildView }],
})
const mountBuildView = async () => {
const router = createTestRouter()
await router.push('/build')
await router.isReady()
global.fetch = vi.fn((url) => {
const responses = {
'/api/apps': {
'1': { name: '测试阅读', server: '测试环境' },
'2': { name: '正式阅读', server: '正式环境' },
'3': { name: '测试词典', server: '测试环境' },
},
'/api/schemes': { '1': { name: 'readoor31' } },
'/api/branches': ['main'],
'/api/tasks': [],
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(responses[url] || {}) })
})
const wrapper = mount(BuildView, {
global: {
plugins: [router],
provide: { getToken: () => '' },
},
})
await flushPromises()
return wrapper
}
describe('BuildView server environment filter', () => {
it('filters Apps by the selected server environment', async () => {
const wrapper = await mountBuildView()
const selects = wrapper.findAll('select')
const serverSelect = selects[0]
const appSelect = selects[1]
expect(appSelect.attributes('disabled')).toBeDefined()
await serverSelect.setValue('测试环境')
expect(appSelect.text()).toContain('测试阅读')
expect(appSelect.text()).toContain('测试词典')
expect(appSelect.text()).not.toContain('正式阅读')
})
it('clears the selected App when switching environments', async () => {
const wrapper = await mountBuildView()
const selects = wrapper.findAll('select')
const serverSelect = selects[0]
const appSelect = selects[1]
await serverSelect.setValue('测试环境')
await appSelect.setValue('1')
expect(wrapper.vm.form.app_id).toBe('1')
await serverSelect.setValue('正式环境')
expect(wrapper.vm.form.app_id).toBe('')
expect(appSelect.text()).toContain('正式阅读')
expect(appSelect.text()).not.toContain('测试阅读')
})
it('only offers configured build types and special App Schemes', async () => {
const wrapper = await mountBuildView()
await wrapper.setData({
apps: {
'1': {
name: '英汉大词典',
server: '测试环境',
certificates: { App_Store: { name: 'com.dictionary.app' } },
allowed_scheme_names: ['readoorDict'],
},
},
schemes: {
'1': { name: 'readoor31' },
'2': { name: 'readoorDict' },
},
})
const selects = wrapper.findAll('select')
await selects[0].setValue('测试环境')
await selects[1].setValue('1')
expect(selects[2].text()).toContain('App_Store')
expect(selects[2].text()).not.toContain('Ad_Hoc')
expect(selects[3].text()).toContain('readoorDict')
expect(selects[3].text()).not.toContain('readoor31')
})
})

View File

@ -5,25 +5,37 @@
<div class="config-panel">
<h2>打包配置</h2>
<div class="form-group">
<label>选择 App</label>
<select v-model="form.app_id">
<label>选择服务器环境</label>
<select v-model="selectedServer">
<option value="">请选择...</option>
<option v-for="(app, id) in apps" :key="id" :value="id">
{{ app.server }} - {{ app.name }}
<option v-for="server in serverEnvironments" :key="server" :value="server">
{{ server }}
</option>
</select>
</div>
<div class="form-group">
<label>选择 App</label>
<select v-model="form.app_id" :disabled="!selectedServer">
<option value="">{{ selectedServer ? '请选择...' : '请先选择服务器环境' }}</option>
<option v-for="[id, app] in filteredApps" :key="id" :value="id">
{{ app.name }}
</option>
</select>
</div>
<div class="form-group">
<label>打包类型</label>
<select v-model="form.build_type">
<option value="Ad_Hoc">Ad_Hoc</option>
<option value="App_Store">App_Store</option>
<option value="" disabled>请选择...</option>
<option v-for="buildType in availableBuildTypes" :key="buildType" :value="buildType">
{{ buildType }}
</option>
</select>
</div>
<div class="form-group">
<label>选择 Scheme</label>
<select v-model="form.scheme_id">
<option v-for="(scheme, id) in schemes" :key="id" :value="id">
<option value="" disabled>请选择...</option>
<option v-for="[id, scheme] in filteredSchemes" :key="id" :value="id">
{{ scheme.displayName || scheme.name }}
</option>
</select>
@ -138,7 +150,7 @@
</template>
<script setup>
import { ref, onMounted, nextTick, watch, onUnmounted, inject } from 'vue'
import { computed, ref, onMounted, nextTick, watch, onUnmounted, inject } from 'vue'
import { useRoute } from 'vue-router'
const getToken = inject('getToken')
@ -155,10 +167,28 @@ const apps = ref({})
const schemes = ref({})
const branches = ref(['main'])
const tasks = ref([])
const selectedServer = ref('')
const serverEnvironments = computed(() => {
return [...new Set(Object.values(apps.value).map(app => app.server).filter(Boolean))]
})
const filteredApps = computed(() => {
return Object.entries(apps.value).filter(([, app]) => app.server === selectedServer.value)
})
const selectedApp = computed(() => apps.value[form.value.app_id] || null)
const availableBuildTypes = computed(() => {
const certificates = selectedApp.value?.certificates || {}
return ['Ad_Hoc', 'App_Store'].filter(buildType => Boolean(certificates[buildType]))
})
const filteredSchemes = computed(() => {
const allowedNames = selectedApp.value?.allowed_scheme_names || []
return Object.entries(schemes.value).filter(([, scheme]) => {
return !allowedNames.length || allowedNames.includes(scheme.name)
})
})
const form = ref({
app_id: '',
build_type: 'Ad_Hoc',
scheme_id: '1',
build_type: '',
scheme_id: '',
obfuscation: false,
branch: 'main',
})
@ -224,7 +254,9 @@ const connectWs = (taskId) => {
completedTask.value = null
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`)
const token = getToken()
if (!token) return
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`, [`jwt.${token}`])
activeWs = ws
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
@ -258,6 +290,15 @@ watch(() => currentTaskId.value, (newId) => {
connectWs(newId)
})
watch(selectedServer, () => {
form.value.app_id = ''
})
watch(() => form.value.app_id, () => {
form.value.scheme_id = filteredSchemes.value[0]?.[0] || ''
form.value.build_type = availableBuildTypes.value[0] || ''
})
onUnmounted(() => {
if (activeWs) {
activeWs.close()

View File

@ -1,22 +1,22 @@
<template>
<div class="container">
<div v-if="!isAdmin" class="access-denied">
<h3>需要管理员权限</h3>
<p>{{ isLoggedIn ? '当前账号无管理员权限' : '请先登录管理员账号' }}</p>
<button v-if="!isLoggedIn" class="btn-login" @click="showLogin = true">登录管理员账号</button>
<div v-if="!isLoggedIn" class="access-denied">
<h3>请先登录</h3>
<p>登录后可管理 Apps 配置</p>
<button class="btn-login" @click="showLogin = true">登录</button>
</div>
<div v-else class="admin-page">
<div class="sidebar">
<h3>配置管理</h3>
<ul class="sidebar-menu">
<li :class="{ active: tab === 'users' }" @click="tab = 'users'">用户管理</li>
<li :class="{ active: tab === 'servers' }" @click="tab = 'servers'">服务器环境</li>
<li v-if="isAdmin" :class="{ active: tab === 'users' }" @click="tab = 'users'">用户管理</li>
<li v-if="isAdmin" :class="{ active: tab === 'servers' }" @click="tab = 'servers'">服务器环境</li>
<li :class="{ active: tab === 'apps' }" @click="tab = 'apps'">Apps 配置</li>
<li :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
<li :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</li>
<li :class="{ active: tab === 'upload' }" @click="tab = 'upload'">上传配置</li>
<li :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li>
<li :class="{ active: tab === 'json' }" @click="tab = 'json'">JSON 编辑</li>
<li v-if="isAdmin" :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
<li v-if="isAdmin" :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</li>
<li v-if="isAdmin" :class="{ active: tab === 'upload' }" @click="tab = 'upload'">上传配置</li>
<li v-if="isAdmin" :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li>
<li v-if="isAdmin" :class="{ active: tab === 'json' }" @click="tab = 'json'">JSON 编辑</li>
</ul>
</div>
<div class="main-content">
@ -405,12 +405,13 @@
</div>
<div class="form-group">
<label>服务器环境 *</label>
<select v-model="appForm.server" @change="onServerChange">
<select v-if="isAdmin" v-model="appForm.server" @change="onServerChange">
<option value="">请选择环境</option>
<option v-for="(server, name) in servers" :key="name" :value="name">
{{ name }} - {{ server.api }}
</option>
</select>
<input v-else v-model="appForm.server" type="text" placeholder="例如:测试环境">
</div>
</div>
<div class="form-row">
@ -479,7 +480,7 @@
</div>
<div class="form-row">
<div class="form-group">
<label>Provisioning Profile 路径</label>
<label>Provisioning Profile 名称</label>
<input v-model="appForm.certificates[certType].pro" type="text" placeholder="/Users/.../xxx.mobileprovision">
</div>
<div class="form-group">
@ -563,7 +564,7 @@ const showLogin = inject('showLogin')
const getToken = inject('getToken')
const isLoggedIn = inject('isLoggedIn')
const isAdmin = inject('isAdmin')
const tab = ref('users')
const tab = ref('apps')
//
const users = ref([])
@ -610,6 +611,12 @@ onMounted(async () => {
})
const loadData = async () => {
if (!isAdmin.value) {
const appsRes = await authFetch('/api/config/apps')
if (appsRes.ok) apps.value = await appsRes.json()
return
}
const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes, versionsRes, usersRes] = await Promise.all([
authFetch('/api/config/apps'),
authFetch('/api/config/schemes'),

View File

@ -262,7 +262,9 @@ const viewLogs = async (taskId) => {
const connectLogWs = (taskId) => {
if (logWs) { logWs.close(); logWs = null }
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`)
const token = getToken()
if (!token) return
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`, [`jwt.${token}`])
logWs = ws
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)

View File

@ -4,6 +4,7 @@ sqlalchemy>=2.0.0
pydantic>=2.0.0
python-multipart>=0.0.6
PyJWT>=2.8.0
bcrypt>=4.1.0
httpx>=0.25.0
oss2>=2.19.0
qrcode[pil]>=7.4.0

View File

@ -51,7 +51,7 @@ def tmp_config(tmp_path, monkeypatch):
}
},
"schemes": {
"1": {"name": "testScheme", "ossFloder": "test"}
"1": {"name": "readoor31", "ossFloder": "test"}
},
"servers": {
"测试环境": {"api": "https://test.api.com", "assDom": "applinks:test.com", "universalLink": "https://test.com"}

View File

@ -1,4 +1,5 @@
"""任务接口测试"""
import json
from unittest.mock import patch, AsyncMock
@ -22,7 +23,7 @@ def test_create_task(mock_queue, client, tmp_config):
data = resp.json()
assert data["status"] == "pending"
assert data["app_name"] == "测试App"
assert data["scheme_name"] == "testScheme"
assert data["scheme_name"] == "readoor31"
assert data["branch"] == "main"
assert data["build_type"] == "Ad_Hoc"
@ -61,6 +62,60 @@ def test_create_task_invalid_scheme(mock_queue, client, tmp_config):
assert resp.status_code == 400
@patch("backend.services.build_queue.build_queue")
def test_create_task_rejects_unconfigured_certificate_type(mock_queue, client, tmp_config):
config = json.loads(tmp_config.read_text())
config["apps"]["1"]["certificates"] = {
"Ad_Hoc": {"name": "com.test.app"},
}
tmp_config.write_text(json.dumps(config))
resp = client.post("/api/tasks", json={
"app_id": "1", "build_type": "App_Store", "scheme_id": "1", "branch": "main",
})
assert resp.status_code == 400
assert "未配置 App_Store 证书" in resp.json()["detail"]
@patch("backend.services.build_queue.build_queue")
def test_create_task_rejects_disallowed_special_app_scheme(mock_queue, client, tmp_config):
config = json.loads(tmp_config.read_text())
config["apps"]["1"].update({
"name": "英汉大词典",
"certificates": {"Ad_Hoc": {"name": "com.dictionary.app"}},
})
config["schemes"] = {
"1": {"name": "readoor31"},
"2": {"name": "readoorDict"},
}
tmp_config.write_text(json.dumps(config))
resp = client.post("/api/tasks", json={
"app_id": "1", "build_type": "Ad_Hoc", "scheme_id": "1", "branch": "main",
})
assert resp.status_code == 400
assert "readoorDict" in resp.json()["detail"]
@patch("backend.services.build_queue.build_queue")
def test_create_task_rejects_non_default_scheme_for_regular_app(mock_queue, client, tmp_config):
config = json.loads(tmp_config.read_text())
config["schemes"] = {
"1": {"name": "readoor31"},
"2": {"name": "readoorDict"},
}
tmp_config.write_text(json.dumps(config))
resp = client.post("/api/tasks", json={
"app_id": "1", "build_type": "Ad_Hoc", "scheme_id": "2", "branch": "main",
})
assert resp.status_code == 400
assert "readoor31OtherPay" in resp.json()["detail"]
@patch("backend.services.build_queue.build_queue")
def test_list_tasks_after_create(mock_queue, client, tmp_config):
mock_queue.submit = AsyncMock()

View File

@ -13,6 +13,8 @@ import pytest
from backend.services.build_service import (
update_source,
copy_source_code,
prepare_source_snapshot,
build_project,
generate_config,
_cleanup_old_builds,
)
@ -69,10 +71,8 @@ async def test_update_source_clone(tmp_path, log_streamer):
with patch("backend.services.build_service.get_git_remote_url", return_value="git@github.com:test/repo.git"):
with patch("asyncio.create_subprocess_exec", return_value=_make_mock_process()) as mock_exec:
await update_source("t1", source_dir, "main")
mock_exec.assert_called_once()
args = mock_exec.call_args[0]
assert "git" in args
assert "clone" in args
assert mock_exec.call_count == 6 # clone, set-url, fetch, checkout, reset, clean
assert mock_exec.call_args_list[0].args[:2] == ("git", "clone")
async def test_update_source_clone_no_remote(tmp_path, log_streamer):
@ -84,8 +84,8 @@ async def test_update_source_clone_no_remote(tmp_path, log_streamer):
await update_source("t1", source_dir, "main")
async def test_update_source_pull_existing(tmp_path, log_streamer):
"""目录已存在时执行 fetch + checkout + pull"""
async def test_update_source_existing(tmp_path, log_streamer):
"""目录已存在时执行 fetch + checkout + reset + clean"""
source_dir = tmp_path / "branches" / "dev"
source_dir.parent.mkdir(parents=True)
source_dir.mkdir()
@ -102,7 +102,7 @@ async def test_update_source_pull_existing(tmp_path, log_streamer):
with patch("asyncio.create_subprocess_exec", side_effect=mock_exec):
await update_source("t1", source_dir, "dev")
assert call_count == 3 # fetch, checkout, pull
assert call_count == 4 # fetch, checkout, reset, clean
async def test_update_source_fetch_failure(tmp_path, log_streamer):
@ -110,6 +110,7 @@ async def test_update_source_fetch_failure(tmp_path, log_streamer):
source_dir = tmp_path / "branches" / "dev"
source_dir.parent.mkdir(parents=True)
source_dir.mkdir()
(source_dir / ".git").mkdir()
async def mock_exec(*args, **kwargs):
return _make_mock_process(returncode=1, output=b"error\n")
@ -120,6 +121,22 @@ async def test_update_source_fetch_failure(tmp_path, log_streamer):
await update_source("t1", source_dir, "dev")
async def test_prepare_source_snapshot_serializes_shared_source(tmp_dirs, log_streamer):
"""共享源码模式下,更新和复制必须在同一把锁内完成。"""
source_dir, build_dir_parent = tmp_dirs
task = MagicMock(branch="main")
with patch("backend.services.build_service.get_git_remote_url", return_value="git@github.com:test/repo.git"), \
patch("backend.services.build_service.get_shared_source_dir", return_value=source_dir), \
patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent), \
patch("backend.services.build_service.update_source", new_callable=AsyncMock), \
patch("backend.services.build_service.get_source_commit", new_callable=AsyncMock, return_value="abc123"):
build_dir, commit = await prepare_source_snapshot("t1", task)
assert build_dir.exists()
assert commit == "abc123"
# ---- copy_source_code ----
async def test_copy_source_code(tmp_dirs, log_streamer):
@ -170,6 +187,24 @@ async def test_copy_source_code_overwrites_existing(tmp_dirs, log_streamer):
assert (result / "readoor").exists()
async def test_build_project_passes_scheme_as_exec_argument(tmp_path, log_streamer):
"""Scheme 中的 shell 特殊字符只能作为 xcodebuild 参数,不能被执行。"""
build_dir = tmp_path / "build"
build_dir.mkdir()
task = MagicMock()
config_data = {"SCHEME": "App; touch /tmp/should-not-run"}
with patch("asyncio.create_subprocess_exec", return_value=_make_mock_process()) as mock_exec, \
patch("asyncio.create_subprocess_shell") as mock_shell:
with pytest.raises(Exception, match="未找到 IPA 文件"):
await build_project("t1", task, config_data, build_dir)
assert mock_shell.call_count == 0
archive_args = mock_exec.call_args_list[1].args
assert archive_args[0:2] == ("xcodebuild", "archive")
assert "App; touch /tmp/should-not-run" in archive_args
# ---- generate_config ----
async def test_generate_config(tmp_path, log_streamer):

60
tests/test_security.py Normal file
View File

@ -0,0 +1,60 @@
"""公网部署安全回归测试。"""
import pytest
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from backend import config
from backend.main import app
from backend.security import hash_password, verify_password
def test_passwords_use_bcrypt_and_verify():
password_hash = hash_password("a-strong-password")
assert password_hash.startswith("$2")
assert verify_password("a-strong-password", password_hash) == (True, False)
assert verify_password("wrong-password", password_hash) == (False, False)
def test_websocket_rejects_connection_without_jwt():
with TestClient(app) as unauthenticated_client:
with pytest.raises(WebSocketDisconnect) as exc_info:
with unauthenticated_client.websocket_connect("/ws/tasks/task-1"):
pass
assert exc_info.value.code == 1008
def test_production_rejects_default_security_settings(monkeypatch):
monkeypatch.setattr(config, "APP_ENV", "production")
monkeypatch.setattr(config, "JWT_SECRET", "ios-build-server-secret-key-change-in-production")
monkeypatch.setattr(config, "ADMIN_PASSWORD", "admin123")
monkeypatch.setattr(config, "CORS_ALLOWED_ORIGINS", ["*"])
monkeypatch.setattr(config, "TRUSTED_HOSTS", ["*"])
monkeypatch.setenv("TRUSTED_HOSTS", "*")
with pytest.raises(RuntimeError, match="生产环境安全配置不完整"):
config.validate_production_security()
def test_config_routes_limit_regular_users_to_apps(client, tmp_config):
"""普通账号只能管理 Apps不能读取或修改包含密钥的配置。"""
response = client.post("/api/users", json={
"username": "builder",
"password": "builder-password-123",
"is_admin": False,
})
assert response.status_code == 200
login = client.post("/api/auth/login", json={
"username": "builder",
"password": "builder-password-123",
})
token = login.json()["token"]
headers = {"Authorization": f"Bearer {token}"}
assert client.get("/api/config/apps", headers=headers).status_code == 200
assert client.post("/api/config/apps", json={"name": "普通用户 App"}, headers=headers).status_code == 200
assert client.get("/api/config", headers=headers).status_code == 403
assert client.get("/api/config/servers", headers=headers).status_code == 403
assert client.put("/api/config/upload", json={"mode": "oss"}, headers=headers).status_code == 403