首次提交

This commit is contained in:
2026-07-18 20:09:26 +08:00
commit 521ad3b635
48 changed files with 7199 additions and 0 deletions
View File
+324
View File
@@ -0,0 +1,324 @@
"""
ACME 核心服务 - 集成 Let's Encrypt 证书申请与续签
支持 DNS-01 验证,通过 AliDNS API 自动添加 TXT 记录
"""
import json
import os
import time
import hashlib
import base64
import logging
import subprocess
import tempfile
from pathlib import Path
from datetime import datetime, timedelta
import requests
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from backend.config import get_settings
from backend.models import AcmeConfig, AcmeLog, Domain
logger = logging.getLogger(__name__)
class AliDNSClient:
"""AliDNS API 客户端,用于添加/删除 TXT 记录"""
def __init__(self, access_key: str, access_secret: str):
self.access_key = access_key
self.access_secret = access_secret
self.api_url = "https://alidns.aliyuncs.com"
def _sign_params(self, params: dict) -> dict:
"""生成阿里云 API 签名"""
params.update({
"Format": "JSON",
"Version": "2015-01-09",
"AccessKeyId": self.access_key,
"SignatureMethod": "HMAC-SHA1",
"Timestamp": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
"SignatureVersion": "1.0",
"SignatureNonce": str(int(time.time() * 1000)),
})
sorted_params = sorted(params.items())
query_string = "&".join(
f"{self._percent_encode(k)}={self._percent_encode(v)}" for k, v in sorted_params
)
string_to_sign = f"GET&{self._percent_encode('/')}&{self._percent_encode(query_string)}"
import hmac
sign = hmac.new(
(self.access_secret + "&").encode(),
string_to_sign.encode(),
hashlib.sha1,
).digest()
params["Signature"] = base64.b64encode(sign).decode()
return params
@staticmethod
def _percent_encode(s: str) -> str:
s = str(s)
return requests.utils.quote(s, safe="")
def _request(self, params: dict) -> dict:
signed = self._sign_params(params)
resp = requests.get(self.api_url, params=signed, timeout=10)
resp.raise_for_status()
return resp.json()
def add_txt_record(self, domain: str, value: str) -> str:
"""添加 _acme-challenge TXT 记录,返回 RecordId"""
# 提取主域名
parts = domain.split(".")
rr = f"_acme-challenge.{'.'.join(parts[:-2])}" if len(parts) > 2 else "_acme-challenge"
main_domain = ".".join(parts[-2:])
result = self._request({
"Action": "AddDomainRecord",
"DomainName": main_domain,
"RR": rr,
"Type": "TXT",
"Value": value,
})
record_id = result.get("RecordId", "")
logger.info(f"AliDNS: added TXT record {rr}.{main_domain} = {value}, RecordId={record_id}")
return str(record_id)
def delete_txt_record(self, record_id: str):
"""删除指定的 DNS 记录"""
self._request({
"Action": "DeleteDomainRecord",
"RecordId": record_id,
})
logger.info(f"AliDNS: deleted record {record_id}")
class AcmeService:
"""ACME 证书管理服务"""
def __init__(self, config: AcmeConfig, cert_dir: str):
self.config = config
self.cert_dir = Path(cert_dir)
self.cert_dir.mkdir(parents=True, exist_ok=True)
def _get_dns_client(self) -> AliDNSClient:
"""根据配置创建 DNS 客户端"""
creds = json.loads(self.config.dns_credentials)
if self.config.dns_provider == "aliyun":
return AliDNSClient(
access_key=creds.get("access_key", ""),
access_secret=creds.get("access_secret", ""),
)
raise ValueError(f"Unsupported DNS provider: {self.config.dns_provider}")
def _generate_account_key(self) -> rsa.RSAPrivateKey:
"""生成 ACME 账户私钥"""
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
def _load_or_create_account_key(self) -> rsa.RSAPrivateKey:
"""加载或创建账户私钥"""
if self.config.account_key:
return serialization.load_pem_private_key(
self.config.account_key.encode(), password=None
)
key = self._generate_account_key()
pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
self.config.account_key = pem.decode()
return key
def _generate_csr(self, domain: str, key_path: Path):
"""生成域名私钥和 CSR"""
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
# 保存私钥
key_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
key_path.write_bytes(key_pem)
# 生成 CSR
csr = (
x509.CertificateSigningRequestBuilder()
.subject_name(x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, domain)]))
.add_extension(
x509.SubjectAlternativeName([x509.DNSName(domain)]),
critical=False,
)
.sign(key, hashes.SHA256())
)
csr_pem = csr.public_bytes(serialization.Encoding.PEM)
return csr_pem
def _run_certbot(self, domain: str, action: str) -> tuple[bool, str]:
"""
使用 certbot 执行 ACME 操作
action: "certonly" (申请) 或 "renew" (续签)
泛域名处理:
- 输入 "*.zhzp.top" 自动同时申请 "*.zhzp.top" + "zhzp.top"
- 证书存储目录使用裸域名 "zhzp.top"
"""
creds = json.loads(self.config.dns_credentials)
# 泛域名:同时申请 *.example.com 和 example.com
# 存储目录使用裸域名
if domain.startswith("*."):
bare_domain = domain[2:]
certbot_domains = ["-d", domain, "-d", bare_domain]
store_dir = bare_domain
else:
certbot_domains = ["-d", domain]
store_dir = domain
domain_dir = self.cert_dir / store_dir
domain_dir.mkdir(parents=True, exist_ok=True)
# 写入临时凭据文件(certbot-dns-alicloud 需要 INI 格式)
creds_content = (
f"dns_alicloud_access_key = {creds.get('access_key', '')}\n"
f"dns_alicloud_access_key_secret = {creds.get('access_secret', '')}\n"
)
creds_file = tempfile.NamedTemporaryFile(
mode="w", suffix=".ini", prefix="certbot-dns-", delete=False
)
creds_file.write(creds_content)
creds_file.close()
cmd = [
"certbot", "certonly",
"--non-interactive",
"--agree-tos",
"--email", self.config.email,
"--authenticator", "dns-alicloud",
"--dns-alicloud-credentials", creds_file.name,
*certbot_domains,
"--cert-path", str(domain_dir / "fullchain.pem"),
"--key-path", str(domain_dir / "private.key"),
"--work-dir", str(self.cert_dir / ".certbot-work"),
"--config-dir", str(self.cert_dir / ".certbot-config"),
"--logs-dir", str(self.cert_dir / ".certbot-logs"),
]
if self.config.acme_server != "https://acme-v02.api.letsencrypt.org/directory":
cmd.extend(["--server", self.config.acme_server])
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=180,
)
output = result.stdout + "\n" + result.stderr
success = result.returncode == 0
return success, output
except subprocess.TimeoutExpired:
return False, "Certbot timeout after 180s"
except FileNotFoundError:
return False, "certbot not found, please install: pip install certbot certbot-dns-alicloud"
finally:
# 清理临时凭据文件
try:
os.unlink(creds_file.name)
except OSError:
pass
def issue_certificate(self, domain: str) -> tuple[bool, str]:
"""
申请证书(使用 certbot + DNS-01 验证)
返回 (成功?, 日志信息)
泛域名 *.example.com 会自动同时申请裸域名 example.com
"""
# 泛域名使用裸域名路径
store_dir = domain[2:] if domain.startswith("*.") else domain
domain_dir = self.cert_dir / store_dir
domain_dir.mkdir(parents=True, exist_ok=True)
success, output = self._run_certbot(domain, "certonly")
if success:
cert_path = domain_dir / "fullchain.pem"
if cert_path.exists():
cert_data = cert_path.read_bytes()
cert = x509.load_pem_x509_certificate(cert_data)
not_after = cert.not_valid_after_utc
san = []
try:
san_ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
san = san_ext.value.get_values_for_type(x509.DNSName)
except x509.ExtensionNotFound:
pass
return True, f"Certificate issued, expires: {not_after.strftime('%Y-%m-%d %H:%M:%S')}, SAN: {san}"
return success, output
def renew_certificate(self, domain: str) -> tuple[bool, str]:
"""续签证书"""
return self.issue_certificate(domain)
def check_expiry(self, domain: str) -> tuple[bool, int]:
"""检查证书到期天数,返回 (需要续签?, 剩余天数)"""
# 泛域名使用裸域名路径
store_dir = domain[2:] if domain.startswith("*.") else domain
cert_path = self.cert_dir / store_dir / "fullchain.pem"
if not cert_path.exists():
return True, 0
try:
cert = x509.load_pem_x509_certificate(cert_path.read_bytes())
not_after = cert.not_valid_after_utc
days_left = (not_after - datetime.utcnow()).days
return days_left <= self.config.renew_days, days_left
except Exception:
return True, 0
def get_cert_info(self, domain: str) -> dict | None:
"""获取证书详细信息"""
# 泛域名使用裸域名路径
store_dir = domain[2:] if domain.startswith("*.") else domain
cert_path = self.cert_dir / store_dir / "fullchain.pem"
if not cert_path.exists():
return None
try:
cert = x509.load_pem_x509_certificate(cert_path.read_bytes())
return {
"subject": cert.subject.rfc4514_string(),
"issuer": cert.issuer.rfc4514_string(),
"not_before": cert.not_valid_before_utc.isoformat(),
"not_after": cert.not_valid_after_utc.isoformat(),
"serial_number": str(cert.serial_number),
"san": [name.value for name in cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value],
}
except Exception:
return None
def auto_renew_all(self, domains: list[Domain]) -> list[dict]:
"""自动续签所有即将过期的证书"""
results = []
for d in domains:
need_renew, days_left = self.check_expiry(d.domain)
if need_renew:
success, msg = self.renew_certificate(d.domain)
results.append({
"domain": d.domain,
"action": "renew",
"success": success,
"message": msg,
"days_left": days_left,
})
else:
results.append({
"domain": d.domain,
"action": "skip",
"success": True,
"message": f"Expires in {days_left} days, no renewal needed",
"days_left": days_left,
})
return results
+56
View File
@@ -0,0 +1,56 @@
"""
首次启动引导:为 CertCenter 自身生成自签证书
当 BASE_URL 使用 HTTPS 但证书不存在时自动执行
"""
import subprocess
from pathlib import Path
from urllib.parse import urlparse
from datetime import datetime, timedelta
def ensure_self_signed_cert(base_url: str, cert_dir: str):
"""
如果 CertCenter 自身的域名没有证书,生成一份自签证书
证书存放在 cert_dir/{domain}/ 目录下
"""
parsed = urlparse(base_url)
if parsed.scheme != "https":
return # HTTP 模式不需要证书
hostname = parsed.hostname
if not hostname:
return
cert_path = Path(cert_dir) / hostname / "fullchain.pem"
key_path = Path(cert_dir) / hostname / "private.key"
if cert_path.exists() and key_path.exists():
return # 证书已存在
# 创建目录
cert_path.parent.mkdir(parents=True, exist_ok=True)
print(f"[bootstrap] 为 {hostname} 生成自签证书...")
# 使用 openssl 生成自签证书
try:
subprocess.run(
[
"openssl", "req", "-x509", "-newkey", "rsa:2048",
"-keyout", str(key_path),
"-out", str(cert_path),
"-days", "365",
"-nodes",
"-subj", f"/CN={hostname}",
"-addext", f"subjectAltName=DNS:{hostname}",
],
capture_output=True,
check=True,
)
print(f"[bootstrap] 自签证书已生成: {cert_path}")
print(f"[bootstrap] 客户端部署脚本将使用 curl -k 跳过 TLS 验证")
except FileNotFoundError:
print("[bootstrap] 未找到 openssl,跳过自签证书生成")
print("[bootstrap] 请手动配置 HTTPS 证书,或使用 HTTP 模式")
except subprocess.CalledProcessError as e:
print(f"[bootstrap] 生成自签证书失败: {e.stderr.decode()}")
+25
View File
@@ -0,0 +1,25 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
from pathlib import Path
# 项目根目录(backend/ 的上级目录)
PROJECT_ROOT = Path(__file__).parent.parent
ENV_FILE = PROJECT_ROOT / ".env"
class Settings(BaseSettings):
database_url: str = "sqlite+aiosqlite:///./certcenter.db"
secret_key: str = "change-me-to-a-random-string"
admin_username: str = "admin"
admin_password: str = "admin123"
cert_dir: str = "/srv/certs"
base_url: str = "https://cert.example.com"
class Config:
env_file = str(ENV_FILE)
env_file_encoding = "utf-8"
@lru_cache
def get_settings() -> Settings:
return Settings()
+27
View File
@@ -0,0 +1,27 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from backend.config import get_settings
settings = get_settings()
engine = create_async_engine(settings.database_url, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+122
View File
@@ -0,0 +1,122 @@
from contextlib import asynccontextmanager
from pathlib import Path
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from starlette.requests import Request
from backend.database import init_db
from backend.routers import api, admin
from backend.config import get_settings
from backend.bootstrap import ensure_self_signed_cert
from backend.routers.admin import (
login, logout, check_auth,
get_stats, list_servers, get_server, create_server, update_server, delete_server,
list_domains, get_domain, create_domain, update_domain, delete_domain,
list_logs, get_acme_config, update_acme_config,
issue_cert, renew_cert, auto_renew_all, list_acme_logs, get_cert_info,
)
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger("certcenter")
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
settings = get_settings()
ensure_self_signed_cert(settings.base_url, settings.cert_dir)
logger.info("=== 服务启动完成 ===")
# 打印所有已注册的路由
for route in app.routes:
methods = getattr(route, "methods", None)
path = getattr(route, "path", None)
if path:
logger.info(f" {methods} {path}")
else:
logger.info(f" [included router] {type(route).__name__}")
yield
app = FastAPI(title="CertCenter", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── 请求日志中间件 ──
@app.middleware("http")
async def log_request(request: Request, call_next):
logger.info(f">>> {request.method} {request.url.path}")
response = await call_next(request)
logger.info(f"<<< {request.method} {request.url.path} -> {response.status_code}")
return response
# ── 客户端 API ──
app.include_router(api.router, prefix="/api")
# ── 管理 API(直接注册)──
app.post("/admin/api/login")(login)
app.post("/admin/api/logout")(logout)
app.get("/admin/api/me")(check_auth)
app.get("/admin/api/stats")(get_stats)
app.get("/admin/api/servers")(list_servers)
app.get("/admin/api/servers/{server_id}")(get_server)
app.post("/admin/api/servers", status_code=201)(create_server)
app.put("/admin/api/servers/{server_id}")(update_server)
app.delete("/admin/api/servers/{server_id}")(delete_server)
app.get("/admin/api/domains")(list_domains)
app.get("/admin/api/domains/{domain_id}")(get_domain)
app.post("/admin/api/domains", status_code=201)(create_domain)
app.put("/admin/api/domains/{domain_id}")(update_domain)
app.delete("/admin/api/domains/{domain_id}")(delete_domain)
app.get("/admin/api/logs")(list_logs)
app.get("/admin/api/acme/config")(get_acme_config)
app.put("/admin/api/acme/config")(update_acme_config)
app.post("/admin/api/acme/issue/{domain_id}")(issue_cert)
app.post("/admin/api/acme/renew/{domain_id}")(renew_cert)
app.post("/admin/api/acme/auto-renew")(auto_renew_all)
app.get("/admin/api/acme/logs")(list_acme_logs)
app.get("/admin/api/cert-info/{domain_id}")(get_cert_info)
logger.info("=== 路由注册完成 ===")
for route in app.routes:
methods = getattr(route, "methods", None)
path = getattr(route, "path", None)
if path:
logger.info(f" {methods} {path}")
else:
logger.info(f" [included router] {type(route).__name__}")
# ── 前端静态文件 ──
frontend_dist = Path(__file__).parent.parent / "frontend" / "dist"
if frontend_dist.exists():
assets_dir = frontend_dist / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
index_file = frontend_dist / "index.html"
@app.middleware("http")
async def spa_fallback(request: Request, call_next):
response = await call_next(request)
if request.method == "GET" and response.status_code == 404:
path = request.url.path
if not path.startswith("/api/") and not path.startswith("/admin/api/") and not path.startswith("/assets/"):
return FileResponse(str(index_file))
return response
if __name__ == "__main__":
import uvicorn
uvicorn.run("backend.main:app", host="0.0.0.0", port=8000, reload=True)
+84
View File
@@ -0,0 +1,84 @@
from datetime import datetime
from sqlalchemy import Integer, String, DateTime, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.database import Base
class Server(Base):
__tablename__ = "servers"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
platform: Mapped[str] = mapped_column(String(20), nullable=False) # linux / windows
token: Mapped[str] = mapped_column(String(100), nullable=False)
ip: Mapped[str | None] = mapped_column(String(50), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
domains: Mapped[list["Domain"]] = relationship(back_populates="server", cascade="all, delete-orphan")
class Domain(Base):
__tablename__ = "domains"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
server_id: Mapped[int] = mapped_column(Integer, ForeignKey("servers.id"), nullable=False)
domain: Mapped[str] = mapped_column(String(200), nullable=False)
cert_dir: Mapped[str] = mapped_column(String(500), nullable=False)
check_cmd: Mapped[str] = mapped_column(String(200), nullable=False)
reload_cmd: Mapped[str] = mapped_column(String(200), nullable=False)
version: Mapped[str] = mapped_column(String(50), default="0")
cert_not_after: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
server: Mapped["Server"] = relationship(back_populates="domains")
logs: Mapped[list["DeployLog"]] = relationship(back_populates="domain")
class DeployLog(Base):
__tablename__ = "deploy_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
domain_id: Mapped[int] = mapped_column(Integer, ForeignKey("domains.id"), nullable=False)
server_id: Mapped[int] = mapped_column(Integer, ForeignKey("servers.id"), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False) # success / failed / skipped
message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
domain: Mapped["Domain"] = relationship(back_populates="logs")
class AcmeConfig(Base):
"""ACME 全局配置(单例)"""
__tablename__ = "acme_config"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
# ACME 服务器地址
acme_server: Mapped[str] = mapped_column(String(500), default="https://acme-v02.api.letsencrypt.org/directory")
# 邮箱
email: Mapped[str] = mapped_column(String(200), default="")
# DNS 提商类型
dns_provider: Mapped[str] = mapped_column(String(50), default="aliyun") # aliyun / cloudflare / manual
# DNS 提商凭据(JSON
dns_credentials: Mapped[str] = mapped_column(Text, default="{}")
# 账户私钥(PEM
account_key: Mapped[str | None] = mapped_column(Text, nullable=True)
# 续签提前天数
renew_days: Mapped[int] = mapped_column(Integer, default=30)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class AcmeLog(Base):
"""ACME 操作日志"""
__tablename__ = "acme_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
domain_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("domains.id"), nullable=True)
action: Mapped[str] = mapped_column(String(50), nullable=False) # issue / renew / revoke / register
status: Mapped[str] = mapped_column(String(20), nullable=False) # success / failed / pending
message: Mapped[str | None] = mapped_column(Text, nullable=True)
detail: Mapped[str | None] = mapped_column(Text, nullable=True) # 详细日志
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
domain: Mapped["Domain | None"] = relationship()
View File
+535
View File
@@ -0,0 +1,535 @@
from fastapi import APIRouter, Depends, HTTPException, Header, Cookie
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from pydantic import BaseModel
from datetime import datetime, timedelta
import hashlib
import hmac
import json
import time
from backend.database import get_db
from backend.models import Server, Domain, DeployLog, AcmeConfig, AcmeLog
from backend.config import get_settings
router = APIRouter(tags=["admin-api"])
# ──────────────── Auth ────────────────
def generate_token(username: str, secret_key: str) -> str:
"""生成简单的认证 token"""
expire = int(time.time()) + 86400 * 7 # 7 天过期
payload = f"{username}:{expire}"
signature = hmac.new(secret_key.encode(), payload.encode(), hashlib.sha256).hexdigest()[:16]
return f"{payload}:{signature}"
def verify_token(token: str, secret_key: str) -> bool:
"""验证 token"""
try:
parts = token.split(":")
if len(parts) != 3:
return False
username, expire_str, signature = parts
expire = int(expire_str)
if time.time() > expire:
return False
payload = f"{username}:{expire_str}"
expected = hmac.new(secret_key.encode(), payload.encode(), hashlib.sha256).hexdigest()[:16]
return hmac.compare_digest(signature, expected)
except Exception:
return False
async def require_auth(authorization: str = Header(None), admin_token: str = Cookie(None)):
"""认证依赖,检查 Header 或 Cookie 中的 token"""
settings = get_settings()
# 从 Header 获取
token = None
if authorization and authorization.startswith("Bearer "):
token = authorization[7:]
# 从 Cookie 获取
elif admin_token:
token = admin_token
if not token or not verify_token(token, settings.secret_key):
raise HTTPException(status_code=401, detail="未登录或登录已过期")
return True
class LoginRequest(BaseModel):
username: str
password: str
@router.post("/login")
async def login(data: LoginRequest):
settings = get_settings()
if data.username != settings.admin_username or data.password != settings.admin_password:
raise HTTPException(status_code=401, detail="用户名或密码错误")
token = generate_token(data.username, settings.secret_key)
response = JSONResponse(content={"ok": True, "token": token})
response.set_cookie(
key="admin_token",
value=token,
max_age=86400 * 7,
httponly=True,
samesite="lax",
)
return response
@router.post("/logout")
async def logout():
response = JSONResponse(content={"ok": True})
response.delete_cookie("admin_token")
return response
@router.get("/me")
async def check_auth(_: bool = Depends(require_auth)):
return {"ok": True}
# ──────────────── Schemas ────────────────
class ServerCreate(BaseModel):
name: str
platform: str = "linux"
token: str
ip: str | None = None
class ServerUpdate(BaseModel):
name: str | None = None
platform: str | None = None
token: str | None = None
ip: str | None = None
class DomainCreate(BaseModel):
server_id: int
domain: str
cert_dir: str
check_cmd: str = "nginx -t"
reload_cmd: str = "systemctl reload nginx"
class DomainUpdate(BaseModel):
server_id: int | None = None
domain: str | None = None
cert_dir: str | None = None
check_cmd: str | None = None
reload_cmd: str | None = None
version: str | None = None
# ──────────────── Stats ────────────────
@router.get("/stats")
async def get_stats(db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
"""仪表盘统计数据"""
server_count = (await db.execute(select(func.count(Server.id)))).scalar() or 0
domain_count = (await db.execute(select(func.count(Domain.id)))) .scalar() or 0
# 即将过期(7 天内)
now = datetime.utcnow()
soon = now.replace(day=now.day + 7) if now.day <= 24 else now.replace(month=now.month + 1, day=now.day + 7 - 30)
expiring = (await db.execute(
select(func.count(Domain.id)).where(
Domain.cert_not_after.isnot(None),
Domain.cert_not_after <= soon,
)
)).scalar() or 0
# 最近日志
result = await db.execute(
select(DeployLog)
.options(selectinload(DeployLog.domain), selectinload(DeployLog.domain).selectinload(Domain.server))
.order_by(DeployLog.created_at.desc())
.limit(10)
)
logs = result.scalars().all()
return {
"server_count": server_count,
"domain_count": domain_count,
"expiring_count": expiring,
"recent_logs": [
{
"id": log.id,
"domain": log.domain.domain if log.domain else None,
"server": log.domain.server.name if log.domain and log.domain.server else None,
"status": log.status,
"message": log.message,
"created_at": log.created_at.isoformat(),
}
for log in logs
],
}
# ──────────────── Servers ────────────────
@router.get("/servers")
async def list_servers(db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
result = await db.execute(select(Server).options(selectinload(Server.domains)).order_by(Server.id))
servers = result.scalars().all()
return [
{
"id": s.id,
"name": s.name,
"platform": s.platform,
"token": s.token,
"ip": s.ip,
"domain_count": len(s.domains),
"created_at": s.created_at.isoformat(),
}
for s in servers
]
@router.get("/servers/{server_id}")
async def get_server(server_id: int, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
result = await db.execute(select(Server).where(Server.id == server_id))
s = result.scalar_one_or_none()
if not s:
raise HTTPException(404, "Server not found")
return {"id": s.id, "name": s.name, "platform": s.platform, "token": s.token, "ip": s.ip}
@router.post("/servers", status_code=201)
async def create_server(data: ServerCreate, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
server = Server(**data.model_dump())
db.add(server)
await db.flush()
await db.refresh(server)
return {"id": server.id, "name": server.name}
@router.put("/servers/{server_id}")
async def update_server(server_id: int, data: ServerUpdate, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
result = await db.execute(select(Server).where(Server.id == server_id))
server = result.scalar_one_or_none()
if not server:
raise HTTPException(404, "Server not found")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(server, key, value)
return {"id": server.id}
@router.delete("/servers/{server_id}")
async def delete_server(server_id: int, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
result = await db.execute(select(Server).where(Server.id == server_id))
server = result.scalar_one_or_none()
if not server:
raise HTTPException(404, "Server not found")
await db.delete(server)
return {"ok": True}
# ──────────────── Domains ────────────────
@router.get("/domains")
async def list_domains(db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
result = await db.execute(
select(Domain).options(selectinload(Domain.server)).order_by(Domain.id)
)
domains = result.scalars().all()
return [
{
"id": d.id,
"server_id": d.server_id,
"server_name": d.server.name if d.server else None,
"domain": d.domain,
"cert_dir": d.cert_dir,
"check_cmd": d.check_cmd,
"reload_cmd": d.reload_cmd,
"version": d.version,
"cert_not_after": d.cert_not_after.isoformat() if d.cert_not_after else None,
}
for d in domains
]
@router.get("/domains/{domain_id}")
async def get_domain(domain_id: int, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
result = await db.execute(select(Domain).where(Domain.id == domain_id))
d = result.scalar_one_or_none()
if not d:
raise HTTPException(404, "Domain not found")
return {
"id": d.id,
"server_id": d.server_id,
"domain": d.domain,
"cert_dir": d.cert_dir,
"check_cmd": d.check_cmd,
"reload_cmd": d.reload_cmd,
"version": d.version,
}
@router.post("/domains", status_code=201)
async def create_domain(data: DomainCreate, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
domain = Domain(**data.model_dump())
db.add(domain)
await db.flush()
await db.refresh(domain)
return {"id": domain.id, "domain": domain.domain}
@router.put("/domains/{domain_id}")
async def update_domain(domain_id: int, data: DomainUpdate, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
result = await db.execute(select(Domain).where(Domain.id == domain_id))
domain = result.scalar_one_or_none()
if not domain:
raise HTTPException(404, "Domain not found")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(domain, key, value)
return {"id": domain.id}
@router.delete("/domains/{domain_id}")
async def delete_domain(domain_id: int, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
result = await db.execute(select(Domain).where(Domain.id == domain_id))
domain = result.scalar_one_or_none()
if not domain:
raise HTTPException(404, "Domain not found")
await db.delete(domain)
return {"ok": True}
# ──────────────── Logs ────────────────
@router.get("/logs")
async def list_logs(status: str | None = None, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
query = (
select(DeployLog)
.options(selectinload(DeployLog.domain), selectinload(DeployLog.domain).selectinload(Domain.server))
.order_by(DeployLog.created_at.desc())
.limit(100)
)
if status:
query = query.where(DeployLog.status == status)
result = await db.execute(query)
logs = result.scalars().all()
return [
{
"id": log.id,
"domain": log.domain.domain if log.domain else None,
"server": log.domain.server.name if log.domain and log.domain.server else None,
"status": log.status,
"message": log.message,
"created_at": log.created_at.isoformat(),
}
for log in logs
]
# ──────────────── ACME 配置 ────────────────
class AcmeConfigUpdate(BaseModel):
acme_server: str | None = None
email: str | None = None
dns_provider: str | None = None
dns_credentials: str | None = None
renew_days: int | None = None
@router.get("/acme/config")
async def get_acme_config(db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == 1))
config = result.scalar_one_or_none()
if not config:
# 返回默认配置
return {
"acme_server": "https://acme-v02.api.letsencrypt.org/directory",
"email": "",
"dns_provider": "aliyun",
"dns_credentials": "{}",
"renew_days": 30,
"has_account_key": False,
}
return {
"acme_server": config.acme_server,
"email": config.email,
"dns_provider": config.dns_provider,
"dns_credentials": config.dns_credentials,
"renew_days": config.renew_days,
"has_account_key": config.account_key is not None,
}
@router.put("/acme/config")
async def update_acme_config(data: AcmeConfigUpdate, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == 1))
config = result.scalar_one_or_none()
if not config:
config = AcmeConfig(id=1)
db.add(config)
for key, value in data.model_dump(exclude_unset=True).items():
setattr(config, key, value)
return {"ok": True}
# ──────────────── ACME 操作 ────────────────
@router.post("/acme/issue/{domain_id}")
async def issue_cert(domain_id: int, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
"""为指定域名申请证书"""
from backend.acme_service import AcmeService
from backend.config import get_settings
# 获取 ACME 配置
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == 1))
config = result.scalar_one_or_none()
if not config or not config.email:
raise HTTPException(400, "ACME not configured, please set email first")
# 获取域名
result = await db.execute(select(Domain).where(Domain.id == domain_id))
domain = result.scalar_one_or_none()
if not domain:
raise HTTPException(404, "Domain not found")
settings = get_settings()
service = AcmeService(config, settings.cert_dir)
# 记录开始
log = AcmeLog(domain_id=domain_id, action="issue", status="pending", message=f"Issuing certificate for {domain.domain}")
db.add(log)
await db.flush()
try:
success, msg = service.issue_certificate(domain.domain)
log.status = "success" if success else "failed"
log.message = msg
if success:
# 更新域名的证书到期时间
info = service.get_cert_info(domain.domain)
if info:
domain.cert_not_after = datetime.fromisoformat(info["not_after"])
domain.version = str(int(domain.version or "0") + 1)
await db.flush()
return {"success": success, "message": msg}
except Exception as e:
log.status = "failed"
log.message = str(e)
await db.flush()
raise HTTPException(500, str(e))
@router.post("/acme/renew/{domain_id}")
async def renew_cert(domain_id: int, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
"""续签指定域名的证书"""
return await issue_cert(domain_id, db)
@router.post("/acme/auto-renew")
async def auto_renew_all(db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
"""自动续签所有即将过期的证书"""
from backend.acme_service import AcmeService
from backend.config import get_settings
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == 1))
config = result.scalar_one_or_none()
if not config or not config.email:
raise HTTPException(400, "ACME not configured")
settings = get_settings()
service = AcmeService(config, settings.cert_dir)
# 获取所有域名
result = await db.execute(select(Domain))
domains = result.scalars().all()
results = []
for d in domains:
need_renew, days_left = service.check_expiry(d.domain)
if need_renew:
log = AcmeLog(domain_id=d.id, action="renew", status="pending", message=f"Auto-renewing {d.domain}")
db.add(log)
await db.flush()
success, msg = service.renew_certificate(d.domain)
log.status = "success" if success else "failed"
log.message = msg
if success:
info = service.get_cert_info(d.domain)
if info:
d.cert_not_after = datetime.fromisoformat(info["not_after"])
d.version = str(int(d.version or "0") + 1)
results.append({"domain": d.domain, "success": success, "message": msg, "days_left": days_left})
else:
results.append({"domain": d.domain, "success": True, "message": f"Skipped, {days_left} days left", "days_left": days_left})
return {"results": results}
# ──────────────── ACME 日志 ────────────────
@router.get("/acme/logs")
async def list_acme_logs(status: str | None = None, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
query = (
select(AcmeLog)
.options(selectinload(AcmeLog.domain))
.order_by(AcmeLog.created_at.desc())
.limit(100)
)
if status:
query = query.where(AcmeLog.status == status)
result = await db.execute(query)
logs = result.scalars().all()
return [
{
"id": log.id,
"domain": log.domain.domain if log.domain else None,
"action": log.action,
"status": log.status,
"message": log.message,
"detail": log.detail,
"created_at": log.created_at.isoformat(),
}
for log in logs
]
# ──────────────── 证书信息 ────────────────
@router.get("/cert-info/{domain_id}")
async def get_cert_info(domain_id: int, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
"""获取证书详细信息"""
from backend.acme_service import AcmeService
from backend.config import get_settings
result = await db.execute(select(Domain).where(Domain.id == domain_id))
domain = result.scalar_one_or_none()
if not domain:
raise HTTPException(404, "Domain not found")
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == 1))
config = result.scalar_one_or_none()
if not config:
config = AcmeConfig(id=1)
settings = get_settings()
service = AcmeService(config, settings.cert_dir)
info = service.get_cert_info(domain.domain)
need_renew, days_left = service.check_expiry(domain.domain)
return {
"domain": domain.domain,
"cert_info": info,
"need_renew": need_renew,
"days_left": days_left,
}
+144
View File
@@ -0,0 +1,144 @@
from fastapi import APIRouter, Depends, HTTPException, Header
from fastapi.responses import PlainTextResponse, FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from jinja2 import Environment, FileSystemLoader
from pathlib import Path
import os
from backend.database import get_db
from backend.models import Server, Domain, DeployLog
from backend.config import get_settings
router = APIRouter(tags=["client-api"])
settings = get_settings()
# Jinja2 环境,用于生成脚本
template_dir = Path(__file__).parent.parent / "templates_cert"
jinja_env = Environment(loader=FileSystemLoader(str(template_dir)))
async def verify_token(
authorization: str = Header(...),
db: AsyncSession = Depends(get_db),
):
"""验证 Bearer Token,返回对应的 Server 对象"""
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Invalid authorization header")
token = authorization[7:]
result = await db.execute(select(Server).where(Server.token == token))
server = result.scalar_one_or_none()
if not server:
raise HTTPException(status_code=401, detail="Invalid token")
return server
@router.get("/version/{domain}")
async def get_version(domain: str, db: AsyncSession = Depends(get_db)):
"""返回当前证书版本号(无需认证,方便客户端轻量检查)"""
result = await db.execute(select(Domain).where(Domain.domain == domain))
d = result.scalar_one_or_none()
if not d:
raise HTTPException(status_code=404, detail="Domain not found")
return PlainTextResponse(d.version)
@router.get("/cert/{domain}/fullchain")
async def get_fullchain(
domain: str,
server: Server = Depends(verify_token),
db: AsyncSession = Depends(get_db),
):
"""下载 fullchain.pem"""
result = await db.execute(
select(Domain).where(Domain.domain == domain, Domain.server_id == server.id)
)
d = result.scalar_one_or_none()
if not d:
raise HTTPException(status_code=404, detail="Domain not found for this server")
cert_path = Path(settings.cert_dir) / domain / "fullchain.pem"
if not cert_path.exists():
raise HTTPException(status_code=404, detail="Certificate file not found")
return FileResponse(str(cert_path), media_type="application/x-pem-file", filename="fullchain.pem")
@router.get("/cert/{domain}/private")
async def get_private_key(
domain: str,
server: Server = Depends(verify_token),
db: AsyncSession = Depends(get_db),
):
"""下载 private.key"""
result = await db.execute(
select(Domain).where(Domain.domain == domain, Domain.server_id == server.id)
)
d = result.scalar_one_or_none()
if not d:
raise HTTPException(status_code=404, detail="Domain not found for this server")
key_path = Path(settings.cert_dir) / domain / "private.key"
if not key_path.exists():
raise HTTPException(status_code=404, detail="Private key file not found")
return FileResponse(str(key_path), media_type="application/x-pem-file", filename="private.key")
@router.get("/script/{domain}")
async def generate_script(
domain: str,
server_name: str,
db: AsyncSession = Depends(get_db),
):
"""根据 server_name 生成对应的部署脚本"""
# 查找服务器
result = await db.execute(select(Server).where(Server.name == server_name))
server = result.scalar_one_or_none()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
# 查找域名配置
result = await db.execute(
select(Domain).where(Domain.domain == domain, Domain.server_id == server.id)
)
d = result.scalar_one_or_none()
if not d:
raise HTTPException(status_code=404, detail="Domain not found for this server")
# 选择模板
if server.platform == "windows":
template_name = "deploy-cert.ps1.j2"
else:
template_name = "deploy-cert.sh.j2"
tpl = jinja_env.get_template(template_name)
script = tpl.render(
domain=domain,
base_url=settings.base_url,
token=server.token,
cert_dir=d.cert_dir,
check_cmd=d.check_cmd,
reload_cmd=d.reload_cmd,
)
media_type = "text/plain" if server.platform == "windows" else "application/x-sh"
return PlainTextResponse(script, media_type=media_type)
@router.post("/report/{domain}")
async def report_deploy(
domain: str,
status: str,
message: str = "",
server: Server = Depends(verify_token),
db: AsyncSession = Depends(get_db),
):
"""客户端上报部署结果"""
result = await db.execute(
select(Domain).where(Domain.domain == domain, Domain.server_id == server.id)
)
d = result.scalar_one_or_none()
if not d:
raise HTTPException(status_code=404, detail="Domain not found for this server")
log = DeployLog(domain_id=d.id, server_id=server.id, status=status, message=message)
db.add(log)
return {"ok": True}
+49
View File
@@ -0,0 +1,49 @@
$ErrorActionPreference = "Stop"
# === 由 CertCenter 生成 ===
$Domain = "{{ domain }}"
$BaseUrl = "{{ base_url }}"
$Token = "{{ token }}"
$CertDir = "{{ cert_dir }}"
$CheckCmd = "{{ check_cmd }}"
$ReloadCmd = "{{ reload_cmd }}"
# ===========================
$TmpDir = "$env:TEMP\cert-sync-$Domain"
$VersionFile = "$CertDir\.version"
New-Item -ItemType Directory -Force -Path $CertDir, $TmpDir | Out-Null
$headers = @{ Authorization = "Bearer $Token" }
# 跳过 TLS 证书验证(CertCenter 初期可能使用自签证书)
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
# 1. 检查版本
$remote = (Invoke-WebRequest -Uri "$BaseUrl/api/version/$Domain" -Headers $headers -UseBasicParsing -SkipCertificateCheck).Content.Trim()
$local = if (Test-Path $VersionFile) { (Get-Content $VersionFile).Trim() } else { "0" }
if ($remote -eq $local) { exit 0 }
# 2. 下载证书
Invoke-WebRequest -Uri "$BaseUrl/api/cert/$Domain/fullchain" -Headers $headers -OutFile "$TmpDir\fullchain.pem" -UseBasicParsing -SkipCertificateCheck
Invoke-WebRequest -Uri "$BaseUrl/api/cert/$Domain/private" -Headers $headers -OutFile "$TmpDir\private.key" -UseBasicParsing -SkipCertificateCheck
# 3. 备份旧证书
Copy-Item "$CertDir\fullchain.pem" "$CertDir\fullchain.pem.bak" -ErrorAction SilentlyContinue
Copy-Item "$CertDir\private.key" "$CertDir\private.key.bak" -ErrorAction SilentlyContinue
# 4. 原子替换
Move-Item "$TmpDir\fullchain.pem" "$CertDir\fullchain.pem" -Force
Move-Item "$TmpDir\private.key" "$CertDir\private.key" -Force
# 5. 校验 & 重载
try {
Invoke-Expression $CheckCmd
$remote | Out-File -NoNewline -Encoding ascii $VersionFile
Invoke-Expression $ReloadCmd
Write-Host "[$(Get-Date -Format o)] updated: $Domain -> $remote"
} catch {
Move-Item "$CertDir\fullchain.pem.bak" "$CertDir\fullchain.pem" -Force -ErrorAction SilentlyContinue
Move-Item "$CertDir\private.key.bak" "$CertDir\private.key" -Force -ErrorAction SilentlyContinue
Write-Error "[$(Get-Date -Format o)] FAILED: $Domain, rolled back"
exit 1
}
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
# === 由 CertCenter 生成 ===
DOMAIN="{{ domain }}"
BASE_URL="{{ base_url }}"
TOKEN="{{ token }}"
CERT_DIR="{{ cert_dir }}"
CHECK_CMD="{{ check_cmd }}"
RELOAD_CMD="{{ reload_cmd }}"
# ===========================
VERSION_URL="${BASE_URL}/api/version/${DOMAIN}"
FULLCHAIN_URL="${BASE_URL}/api/cert/${DOMAIN}/fullchain"
PRIVATE_URL="${BASE_URL}/api/cert/${DOMAIN}/private"
TMP_DIR="/tmp/cert-sync-${DOMAIN}"
VERSION_FILE="${CERT_DIR}/.version"
mkdir -p "${CERT_DIR}" "${TMP_DIR}"
auth=(-H "Authorization: Bearer ${TOKEN}")
# -k 跳过 TLS 证书验证(CertCenter 初期可能使用自签证书)
curl_opts=(-fsSL -k --connect-timeout 5 --max-time 30)
# 1. 检查版本
REMOTE=$(curl "${curl_opts[@]}" --max-time 15 "${auth[@]}" "${VERSION_URL}")
LOCAL=$(cat "${VERSION_FILE}" 2>/dev/null || echo "0")
if [[ "${REMOTE}" == "${LOCAL}" ]]; then
exit 0
fi
# 2. 下载证书
curl "${curl_opts[@]}" "${auth[@]}" "${FULLCHAIN_URL}" -o "${TMP_DIR}/fullchain.pem"
curl "${curl_opts[@]}" "${auth[@]}" "${PRIVATE_URL}" -o "${TMP_DIR}/private.key"
chmod 600 "${TMP_DIR}/private.key"
# 3. 备份旧证书
cp -f "${CERT_DIR}/fullchain.pem" "${CERT_DIR}/fullchain.pem.bak" 2>/dev/null || true
cp -f "${CERT_DIR}/private.key" "${CERT_DIR}/private.key.bak" 2>/dev/null || true
# 4. 原子替换
mv -f "${TMP_DIR}/fullchain.pem" "${CERT_DIR}/fullchain.pem"
mv -f "${TMP_DIR}/private.key" "${CERT_DIR}/private.key"
# 5. 校验 & 重载
if ${CHECK_CMD}; then
echo "${REMOTE}" > "${VERSION_FILE}"
${RELOAD_CMD}
echo "[$(date -Is)] updated: ${DOMAIN} -> ${REMOTE}"
else
mv -f "${CERT_DIR}/fullchain.pem.bak" "${CERT_DIR}/fullchain.pem" 2>/dev/null || true
mv -f "${CERT_DIR}/private.key.bak" "${CERT_DIR}/private.key" 2>/dev/null || true
echo "[$(date -Is)] FAILED: ${DOMAIN}, rolled back" >&2
exit 1
fi