Harden admin deployment and local acceptance

Enforce mTLS and least-privilege runtime boundaries while adding repeatable MySQL 8.4 and Docker smoke checks that require no production secrets.
This commit is contained in:
Rocky
2026-08-17 15:20:46 +08:00
parent 1a9c518f96
commit 405a2cfc0f
17 changed files with 1480 additions and 37 deletions
+86
View File
@@ -0,0 +1,86 @@
# 管理端 mTLS 部署
`account.osglab.com` 在同一个 TLS `server` 中同时承载移动端 API 和管理端。
由于 TLS 握手发生在 HTTP 路径匹配之前,配置必须使用 server 级
`ssl_verify_client optional`:普通客户端不提供证书时仍可正常访问,只有
`/admin``/admin/``/v1/admin` 和其子路径要求验证成功。
## CA 与证书
1. 为管理客户端创建独立私有 CA,不要复用公网服务端证书 CA 或其他内部 CA。
2. CA 私钥离线保存;不要放入仓库、OpenResty 主机或容器镜像。
3. 管理客户端证书使用短有效期和唯一密钥,并限制为 TLS Client Authentication
(`clientAuth`) 用途。
4. 仅将 CA 证书链(不含任何私钥)部署到:
`/www/server/openresty/conf/mtls/admin-client-ca.pem`
5. CA 文件由 OpenResty 运行用户只读,目录不可由应用进程或非特权用户写入。
6. 更新 CA 文件后先运行 `openresty -t`,成功后再平滑重载。若需要立即吊销证书,
应另外配置并维护 `ssl_crl`;当前配置只依据证书链和有效期验证。
不要把客户端证书、客户端私钥、CA 私钥或生产证书标识提交到仓库。
计划轮换与紧急处置步骤见 [ROTATION.md](ROTATION.md)。
## 上游信任边界
OpenResty 仅在管理路径且 `$ssl_client_verify = SUCCESS` 时向 Ktor 设置固定头:
```text
X-OSG-mTLS-Verified: SUCCESS
```
客户端传入的同名头会被覆盖;其他路径会删除该头。Ktor 只能把这个头作为“边缘已验证”
信号,不能信任客户端提供的证书相关头,也不能用 DN、CN 或证书正文做隐式授权。
后端端口必须继续只监听 `127.0.0.1:18080`,否则攻击者可绕过边缘伪造该头。
mTLS 只证明客户端持有受信证书,管理接口仍应执行应用层身份认证、授权和审计。
## 一次性管理员 Bootstrap
首次部署前运行 `./gradlew generateAdminCredentials`,将生成的 runtime 文件仅临时写入
1Panel/Compose 环境,并同时设置:
```text
ADMIN_ENABLED=true
ADMIN_BOOTSTRAP_ENABLED=true
```
确认初始管理员已创建且可以登录后,必须将 `ADMIN_BOOTSTRAP_ENABLED` 改回 `false`
并从 1Panel、Compose 环境和部署文件中永久删除
`ADMIN_BOOTSTRAP_OPERATOR_ID``ADMIN_BOOTSTRAP_USERNAME`
`ADMIN_BOOTSTRAP_PASSWORD_HASH``ADMIN_BOOTSTRAP_TOTP_SECRET_BASE32`
日常运行只保留 `ADMIN_ENABLED=true`。重启后再次验证登录,确保服务不再依赖 Bootstrap
秘密。
## 验证
将测试域名解析到目标边缘后执行:
```sh
# 无证书:管理路径必须是 404。
curl -i https://account.osglab.com/admin
curl -i https://account.osglab.com/v1/admin
# 有效管理证书:请求应到达 Ktor,状态码由管理接口决定。
curl -i --cert admin-client.pem --key admin-client-key.pem \
https://account.osglab.com/v1/admin
# 无证书的普通移动端 API:响应应与变更前一致。
curl -i https://account.osglab.com/health
# 即使客户端伪造信任头,无证书访问管理路径仍必须是 404。
curl -i -H 'X-OSG-mTLS-Verified: SUCCESS' \
https://account.osglab.com/v1/admin
```
还应使用由非管理 CA 签发或已过期的客户端证书确认返回 404,并在 Ktor 测试端点确认:
管理请求只收到固定值 `SUCCESS`,普通 API 不收到 `X-OSG-mTLS-Verified`
部署后可在受信设备运行不含登录凭据的自动验收:
```sh
ADMIN_CLIENT_CERT=/secure/path/admin-client.pem \
ADMIN_CLIENT_KEY=/secure/path/admin-client-key.pem \
bash deploy/verify-admin.sh
```
脚本验证公开健康检查、无证书隐藏、伪造边缘头拦截、有效证书访问,以及 HSTS/CSP
安全响应头;它不会读取或传输管理员密码和 TOTP 密钥。
+44
View File
@@ -0,0 +1,44 @@
# 管理端 mTLS 证书轮换
管理端客户端证书应至少每 180 天轮换一次;设备丢失、人员离职或私钥疑似泄露时立即轮换。
当前配置未启用 CRL,因此单个客户端证书无法被可靠撤销。出现紧急事件时必须轮换整个管理端
CA,不能只删除本地 `.p12` 文件。
## 零中断轮换
1. 在离线受信设备上生成一套新的 CA 和客户端证书,输出到带日期的新目录。不要覆盖现有目录。
2. 校验新证书的有效期、用途和私钥匹配关系:
```shell
openssl verify -CAfile admin-client-ca.pem admin-client.pem
openssl x509 -in admin-client.pem -noout -dates -subject -issuer
openssl pkcs12 -in admin-client.p12 -info -noout
```
3. 将旧 CA 与新 CA 的公开证书按顺序合并为临时信任包。信任包不得包含任何 CA 私钥:
```shell
cp current-admin-client-ca.pem admin-client-ca-transition.pem
printf '\n' >> admin-client-ca-transition.pem
openssl x509 -in new-admin-client-ca.pem -outform PEM >> admin-client-ca-transition.pem
```
4. 通过 1Panel 将临时信任包替换到 OpenResty 配置引用的
`/www/server/openresty/conf/mtls/admin-client-ca.pem`,先执行 `openresty -t`,成功后再平滑重载。
5. 分别使用旧客户端证书和新客户端证书验证 `/admin/` 与 `/v1/admin/auth/session`。
6. 通过受控的点对点渠道交付新的 `.p12` 与独立密码。不得使用工单附件、群聊、Git 或公开网盘。
7. 确认所有获准设备完成安装后,将服务器信任包替换为仅包含新 CA 的证书,再次执行
`nginx -t` 和平滑重载。
8. 验证新证书成功、旧证书返回 404,然后安全销毁旧客户端私钥和旧 CA 私钥。
## 验收记录
每次轮换必须在安全审计记录中保存以下非敏感信息:
- 轮换时间、执行人与复核人
- 新 CA 和客户端证书的 SHA-256 指纹与失效时间
- 新旧证书切换验证结果
- 旧证书停止信任的准确时间
- 触发原因:计划轮换、设备丢失、人员变更或疑似泄露
不得记录 `.p12` 密码、TOTP 密钥、私钥内容或管理员密码。
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Generate a dedicated admin client CA and one short-lived client certificate."""
from __future__ import annotations
import argparse
import base64
import datetime as dt
import os
from pathlib import Path
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.serialization import pkcs12
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
def private_write(path: Path, value: bytes) -> None:
path.write_bytes(value)
path.chmod(0o600)
def public_write(path: Path, value: bytes) -> None:
path.write_bytes(value)
path.chmod(0o644)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("output_directory", type=Path)
parser.add_argument("--client-name", default="osg-admin-owner")
args = parser.parse_args()
output = args.output_directory.expanduser().resolve()
if output.exists():
raise SystemExit("output_directory_already_exists")
output.mkdir(mode=0o700, parents=False)
now = dt.datetime.now(dt.timezone.utc)
ca_key = ec.generate_private_key(ec.SECP384R1())
ca_subject = x509.Name(
[
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "OSG Admin"),
x509.NameAttribute(NameOID.COMMON_NAME, "OSG Admin Client CA"),
]
)
ca_certificate = (
x509.CertificateBuilder()
.subject_name(ca_subject)
.issuer_name(ca_subject)
.public_key(ca_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - dt.timedelta(minutes=5))
.not_valid_after(now + dt.timedelta(days=3650))
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
.add_extension(
x509.KeyUsage(
digital_signature=False,
content_commitment=False,
key_encipherment=False,
data_encipherment=False,
key_agreement=False,
key_cert_sign=True,
crl_sign=True,
encipher_only=False,
decipher_only=False,
),
critical=True,
)
.add_extension(x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), False)
.sign(ca_key, hashes.SHA384())
)
client_key = ec.generate_private_key(ec.SECP256R1())
client_subject = x509.Name(
[
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "OSG Admin"),
x509.NameAttribute(NameOID.COMMON_NAME, args.client_name),
]
)
client_certificate = (
x509.CertificateBuilder()
.subject_name(client_subject)
.issuer_name(ca_certificate.subject)
.public_key(client_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - dt.timedelta(minutes=5))
.not_valid_after(now + dt.timedelta(days=180))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(
x509.KeyUsage(
digital_signature=True,
content_commitment=False,
key_encipherment=False,
data_encipherment=False,
key_agreement=False,
key_cert_sign=False,
crl_sign=False,
encipher_only=False,
decipher_only=False,
),
critical=True,
)
.add_extension(
x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]),
critical=True,
)
.add_extension(
x509.SubjectKeyIdentifier.from_public_key(client_key.public_key()),
critical=False,
)
.add_extension(
x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()),
critical=False,
)
.sign(ca_key, hashes.SHA256())
)
password = base64.urlsafe_b64encode(os.urandom(24)).rstrip(b"=")
private_write(
output / "admin-client-ca-key.pem",
ca_key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
),
)
public_write(
output / "admin-client-ca.pem",
ca_certificate.public_bytes(serialization.Encoding.PEM),
)
private_write(
output / "admin-client-key.pem",
client_key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
),
)
public_write(
output / "admin-client.pem",
client_certificate.public_bytes(serialization.Encoding.PEM),
)
private_write(
output / "admin-client.p12",
pkcs12.serialize_key_and_certificates(
args.client_name.encode(),
client_key,
client_certificate,
[ca_certificate],
serialization.BestAvailableEncryption(password),
),
)
private_write(output / "admin-client-p12-password.txt", password + b"\n")
print("certificates_created=true")
if __name__ == "__main__":
main()