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:
@@ -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 密钥。
|
||||
@@ -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 密钥、私钥内容或管理员密码。
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user