首次提交
This commit is contained in:
@@ -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
|
||||
@@ -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()}")
|
||||
@@ -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()
|
||||
@@ -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
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user