BounCA 0.4.8 常用 CLI 命令与工具详解

manage.py 子命令全集真机实测(site/verify_email/send_expire_email/changepassword/dbshell/dumpdata)+ 证书签发/下载/吊销/CRL 全生命周期 API 操作 + pg_dump 备份恢复灾难演练,含 sendtestemail 的 crispy_forms 缺陷与无口令证书吊销 500 两则排障实录。

BounCABounCAPKI 证书信息安全2026/10/28

BounCA 0.4.8 常用 CLI 命令与工具详解

目标:把 BounCA 0.4.8 日常运维要用的每一条命令讲透——Django 管理命令全集、账号与邮件命令、证书签发/下载/吊销/CRL 全生命周期 API 操作、备份与恢复演练,全部在真机上逐条执行,回显照抄。软件背景见《BounCA 介绍与选型指南》,部署过程见《Ubuntu 22.04 安装 BounCA 0.4.8 指南》,配置项含义见《BounCA 0.4.8 配置参数详解》。

一、环境信息

实测机与安装篇相同:Ubuntu 22.04.5 LTS,BounCA 0.4.8 发行包装在 /srv/www/bounca(自带的 Python 虚拟环境 env/,Django 5.2.11),PostgreSQL 14.24(库名/角色均为 bounca),Nginx 1.18.0 + uWSGI 2.0.20 对外提供服务,服务器内网地址 10.89.70.10。应用以 www-data 身份运行,因此所有 manage.py 命令都应在 /srv/www/bounca 目录下以 www-data 用户执行:

$ cd /srv/www/bounca
$ env/bin/python manage.py <子命令> [参数]

提示:非交互终端下管道喂入密码时会看到 GetPassWarning: Can not control echo on the terminal 与 Warning: Password input may be echoed. 告警,属正常现象,不影响命令执行。

实测库中已有 6 张证书(1 张 ed25519 测试根、正式链 Root/Issuing/服务器/客户端)与 2 个普通用户(ops01@lab.internal、u3@lab.internal)和 1 个超级用户 admin(口令 q1w2e3r4),下文操作均基于该现状。

二、前置条件

  • 已完成《Ubuntu 22.04 安装 BounCA 0.4.8 指南》全流程,http://10.89.70.10/ 可访问;
  • PostgreSQL 与 uWSGI 服务运行中(systemctl is-active uwsgi 返回 active);
  • 根 CA(id=2)创建时未设私钥口令,中间 CA「Lab Issuing CA」(id=3)私钥口令为 Tr0ut-Kiln-42xQ(下文签发/吊销都要用它,生产环境务必换成强口令并妥善保管)。

三、命令行工具总览

BounCA 的命令行入口只有一个 Django 管理脚本 manage.py,外加少量外围工具(openssl / psql / pg_dump)。manage.py help 列出全部子命令(真机回显):

$ env/bin/python manage.py help

Type 'manage.py help <subcommand>' for help on a specific subcommand.

Available subcommands:

[account]
    account_unsetmultipleprimaryemails

[auth]
    changepassword
    createsuperuser

[authtoken]
    drf_create_token

[bounca]
    site
    verify_email

[contenttypes]
    remove_stale_contenttypes

[django]
    check
    compilemessages
    createcachetable
    dbshell
    diffsettings
    dumpdata
    flush
    inspectdb
    loaddata
    makemessages
    makemigrations
    migrate
    optimizemigration
    sendtestemail
    shell
    showmigrations
    sqlflush
    sqlmigrate
    sqlsequencereset
    squashmigrations
    startapp
    startproject
    test
    testserver

[drf_yasg]
    generate_swagger

[rest_framework]
    generateschema

[sessions]
    clearsessions

[staticfiles]
    collectstatic
    findstatic
    runserver

[x509_pki]
    send_expire_email

BounCA 自有的命令只有三个:site([bounca] 组,设置站点域名)、verify_email([bounca] 组,人工确认用户邮箱)、send_expire_email([x509_pki] 组,发送临期证书提醒邮件);其余都是 Django/第三方库标准命令。运维高频命令与用途对照:

命令用途本文实测
help [子命令]查看总列表/单命令帮助4.1
check配置体检4.2
showmigrations / migrate查看与执行数据库迁移4.2
collectstatic收集静态文件(升级后必跑)4.3
site <域名>设置站点域名4.4
createsuperuser / changepassword建管理员/改口令4.5
verify_email <邮箱>人工确认用户邮箱5.1
send_expire_email发送临期证书提醒5.2
sendtestemail测试邮件(0.4.8 有缺陷,见 5.3)5.3
dbshell / dumpdata / shell数据库直连/JSON 导出/ORM 交互4.6
REST API + curl签发/下载/吊销/CRL六
pg_dump / psql备份与恢复七

makemigrations、startapp、startproject 等开发向命令对运维无意义,且误用可能破坏与上游迁移的一致性,日常禁用。

四、系统维护命令详解

4.1 help:查看命令帮助

单个子命令的帮助用 manage.py help <子命令>。BounCA 三个自有命令的参数都很简单:

$ env/bin/python manage.py help site
positional arguments:
  host                  Base host URI of frontend

$ env/bin/python manage.py help verify_email
positional arguments:
  email                 Email of the account

$ env/bin/python manage.py help send_expire_email
(无位置参数,仅 -h/--verbosity 等通用选项)

4.2 check / showmigrations / migrate:配置体检与迁移

check 做只读体检,输出 System check identified no issues (0 silenced). 即健康:

$ env/bin/python manage.py check
System check identified no issues (0 silenced).

showmigrations 列出全部迁移及应用状态(每行 [X] 表示已应用)。0.4.8 的 x509_pki 应用把前 15 个迁移压缩(squash)成了 1 个,真机回显节选:

$ env/bin/python manage.py showmigrations
account
 [X] 0001_initial
 [X] 0002_email_max_length
 (……省略 0003~0009……)
auth
 [X] 0001_initial
 (……共 12 个……)
x509_pki
 [X] 0001_squashed_0015_keystore_p12 (15 squashed migrations)
 [X] 0002_keystore_fingerprint
 [X] 0003_alter_keystore_fingerprint
 [X] 0004_alter_certificate_type
 [X] 0005_alter_certificate_crl_distribution_url
 [X] 0006_keystore_p12_legacy
 [X] 0007_certificate_expire_warning_mail_sent_at

migrate 用于升级版本或新建库时执行迁移(全新空库全量执行的完整日志见安装篇第四章)。日常升级后按提示执行即可;重复执行显示 No migrations to apply.。

4.3 collectstatic:收集静态文件

$ env/bin/python manage.py collectstatic --noinput

0 static files copied to '/srv/www/bounca/media/static', 1013 unmodified.

第二次执行时全部文件已同步,故显示 0 copied, 1013 unmodified;升级版本后此数字会变为新拷贝数。目标目录由配置决定(见配置篇 4.3 节),Nginx 从这里直接服务静态文件。

4.4 site:设置站点域名

安装篇已执行 env/bin/python manage.py site bounca.lab.internal。该命令写入 Django 的 django_site 表,影响激活邮件里的站点名等展示。查看当前值:

$ env/bin/python manage.py shell -c "
from django.contrib.sites.models import Site
s = Site.objects.get_current()
print(s.domain, s.name)
"
bounca.lab.internal bounca.lab.internal

shell -c "…" 是免交互执行 ORM 片段的最佳途径;首次运行会打印一条 19 objects imported automatically (use -v 2 for details). 提示,属 BounCA 预载行为,可忽略。

4.5 createsuperuser / changepassword:管理员管理

非交互创建管理员(安装篇实测回显,口令经环境变量传入以跳过交互):

$ DJANGO_SUPERUSER_PASSWORD=q1w2e3r4 env/bin/python manage.py createsuperuser --noinput --username admin --email admin@example.com

修改口令用 changepassword(两次输入;下例给普通用户 u3 换口令,非交互终端的 echo 告警可忽略):

$ printf 'NewPass-2026-x\nNewPass-2026-x\n' | env/bin/python manage.py changepassword u3
Password:
Password (again):
Changing password for user 'u3'
Password changed successfully for user 'u3'

Django 口令强度校验对 CLI 同样生效:过于常见(如 q1w2e3r4)、纯数字或与用户名雷同的口令会被拒。q1w2e3r4 能通过仅因 --noinput 跳过交互校验,生产环境应换强口令。

4.6 dbshell / dumpdata / shell:数据库三件套

dbshell 直连 PostgreSQL(连接参数取自 /etc/bounca/services.yaml 的 psql 段),可执行任意 SQL。列出全部数据表:

$ echo '\dt' | env/bin/python manage.py dbshell
                    List of relations
 Schema |             Name              | Type  | Owner
--------+-------------------------------+-------+--------
 public | account_emailaddress          | table | bounca
 public | account_emailconfirmation     | table | bounca
 public | api_authorisedapp             | table | bounca
 public | auth_group                    | table | bounca
 public | auth_group_permissions        | table | bounca
 public | auth_permission               | table | bounca
 public | auth_user                     | table | bounca
 public | auth_user_groups              | table | bounca
 public | auth_user_user_permissions    | table | bounca
 public | authtoken_token               | table | bounca
 public | django_admin_log              | table | bounca
 public | django_content_type           | table | bounca
 public | django_migrations             | table | bounca
 public | django_session                | table | bounca
 public | django_site                   | table | bounca
 public | socialaccount_socialaccount   | table | bounca
 public | socialaccount_socialapp       | table | bounca
 public | socialaccount_socialapp_sites | table | bounca
 public | socialaccount_socialtoken     | table | bounca
 public | x509_pki_certificate          | table | bounca
 public | x509_pki_crlstore             | table | bounca
 public | x509_pki_distinguishedname    | table | bounca
 public | x509_pki_keystore             | table | bounca
(23 rows)

核心业务数据在 x509_pki_* 四张表:certificate(证书元数据)、keystore(PEM 证书与加密私钥,全部证书材料都存库)、distinguishedname(DN 字段)、crlstore(CRL)。

dumpdata 把全库导出为 JSON(含证书私钥,注意落盘权限):

$ env/bin/python manage.py dumpdata --indent 2 > /tmp/dump_test.json
$ wc -c /tmp/dump_test.json
82286 /tmp/dump_test.json

shell 支持 ORM 直查。列出证书模型全部字段:

$ env/bin/python manage.py shell -c "
from x509_pki.models import Certificate
print([f.name for f in Certificate._meta.get_fields()])
"
['certificate', 'keystore', 'crlstore', 'id', 'type', 'name', 'dn', 'parent', 'crl_distribution_url', 'ocsp_distribution_host', 'created_at', 'expires_at', 'revoked_at', 'revoked_uuid', 'expire_warning_mail_sent_at', 'serial', 'owner']

列出证书清单(type:R=Root,I=Intermediate,S=Server,C=Client):

$ env/bin/python manage.py shell -c "
from x509_pki.models import Certificate
for c in Certificate.objects.all():
    print(c.pk, '|', c.name, '|', c.type, '| expires:', c.expires_at, '| revoked_at:', c.revoked_at)
"
2 | Lab Root CA | R | expires: 2036-10-25 | revoked_at: None
1 | TestRootEd25519_revoked-2026-10-28T04:46:15.144228+00:00 | R | expires: 2036-10-25 | revoked_at: 2026-10-28 04:46:15.144228+00:00
4 | gitlab.lab.internal | S | expires: 2027-11-29 | revoked_at: None
5 | zhangsan-client_revoked-2026-10-28T04:51:37.011772+00:00 | C | expires: 2027-11-29 | revoked_at: 2026-10-28 04:51:37.011772+00:00
3 | Lab Issuing CA | I | expires: 2031-10-27 | revoked_at: None

证书名后缀 _revoked-<时间戳> 是 BounCA 吊销后的改名行为;revoked_at 非空即已吊销。

五、账号与邮件命令详解

5.1 verify_email:人工确认用户邮箱

BounCA 注册用户需邮箱验证后才能登录 API。当 SMTP 链路不可用(用户收不到验证邮件)时,管理员可用此命令人工置位。实测:

$ env/bin/python manage.py verify_email ops01@lab.internal
Set email verification flag of: ops01@lab.internal
User email verified: ops01@lab.internal

置位前后对比(ORM 复查):

$ env/bin/python manage.py shell -c "
from allauth.account.models import EmailAddress
for e in EmailAddress.objects.all():
    print(e.email, '| verified:', e.verified, '| primary:', e.primary)
"
u3@lab.internal | verified: False | primary: True
ops01@lab.internal | verified: True | primary: True

ops01@lab.internal 的 verified 已由 False 变为 True,该用户即刻可登录。

5.2 send_expire_email:临期证书提醒

扫描全部有效证书,对临近过期(默认提前 30 天,可配)的证书属主发提醒邮件:

$ env/bin/python manage.py send_expire_email
$

当前库中无 30 天内到期证书,故命令静默退出(退出码 0),一封邮件都不发——这不是故障。适合放进 cron 每日跑一次,例如 /etc/cron.d/bounca-expire:

0 8 * * * www-data  cd /srv/www/bounca && env/bin/python manage.py send_expire_email >/dev/null 2>&1

5.3 sendtestemail:0.4.8 的坑,慎用

按 Django 惯例应能测试 SMTP 链路,但真机实测直接崩:

$ env/bin/python manage.py sendtestemail ops01@lab.internal
Traceback (most recent call last):
  File "/srv/www/bounca/manage.py", line 20, in <module>
    main()
  (……中间省略 django.setup() 若干帧……)
  File "<frozen importlib._bootstrap>", line 1004, in _find_and_load_unlocked
ModuleNotFoundError: No module named 'crispy_forms'

根因在 /srv/www/bounca/bounca/settings.py 第 11 行——用子串匹配判断是否处于单元测试环境:

IS_UNITTEST = "test" in sys.argv or any(["test" in arg for arg in sys.argv])

sendtestemail 的命令名里恰好含 test,被误判成单测环境,于是第 301 行条件触发 INSTALLED_APPS += ["crispy_forms", "vuetifyforms"],而这两个包不在发行包 requirements.txt 里(仅开发环境安装),加载即崩。临时修复:往虚拟环境补装缺失包(vuetifyforms 就在应用目录内,无需安装):

$ env/bin/pip install django-crispy-forms
Installing collected packages: django-crispy-forms
Successfully installed django-crispy-forms-2.7

修复后再跑不崩了,但依然静默、且邮件不会真正发出——因为 IS_UNITTEST=True 还会把 EMAIL_BACKEND 切换成内存后端(settings.py 第 293 行):

if IS_UNITTEST:
    EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"

实测回显(无任何输出,退出码 0,SMTP 队列无邮件):

$ env/bin/python manage.py sendtestemail ops01@lab.internal
$ echo $?
0

结论:0.4.8 上 sendtestemail 名不副实——装包前崩、装包后假发。验证 SMTP 请改用真实业务邮件(如注册激活流程),或临时用 manage.py shell 手动 send_mail()。该问题已具备两个副作用(崩溃 + locmem 假发),排障时不要在错误方向上浪费时间。

六、证书生命周期操作(REST API + openssl)

证书的签发、下载、吊销、CRL 全部通过 REST API 完成(CLI 无对应子命令),HTTP 客户端用 curl、解析用 openssl。认证方式:POST /api/v1/auth/login/ 换取 Token,请求头 Authorization: Token <key>。

6.1 登录取 Token

$ TOKEN=$(curl -s -X POST http://127.0.0.1/api/v1/auth/login/ \
    -H "Content-Type: application/json" \
    -d '{"username":"admin","password":"q1w2e3r4"}' \
    | python3 -c 'import sys,json;print(json.load(sys.stdin)["key"])')
$ echo "token: ${TOKEN:0:12}..."
token: adb2e74ad4ea...

常见 401 排障:响应头必须带 Token 前缀(含空格),只写裸 token 值会 401。

6.2 签发服务器证书

创建服务器证书(type=S)挂到中间 CA(id=3)下,SAN 含域名与 IP。请求字段是 passphrase_issuer(父 CA 私钥口令)与 passphrase_out(新证书私钥口令),且 SAN 列表写裸域名/IP(不能带 DNS: 前缀,原因见配置篇排障 Q5):

$ curl -s -w '\nHTTP:%{http_code}\n' -X POST http://127.0.0.1/api/v1/certificates \
    -H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
    -d '{
      "name": "wiki.lab.internal",
      "type": "S",
      "parent": 3,
      "days_valid": 730,
      "expires_at": "2028-10-27",
      "passphrase_issuer": "Tr0ut-Kiln-42xQ",
      "passphrase_out": "Vault-Otter-77zK",
      "dn": {
        "countryName": "CN",
        "stateOrProvinceName": "Beijing",
        "localityName": "Beijing",
        "organizationName": "Lab",
        "organizationalUnitName": "IT",
        "commonName": "wiki.lab.internal",
        "subjectAltNames": ["wiki.lab.internal", "10.89.70.10"]
      }
    }'
{"id":6,"name":"wiki.lab.internal","parent":3,"type":"S","dn":{"commonName":"wiki.lab.internal","countryName":"CN","stateOrProvinceName":"Beijing","localityName":"Beijing","organizationName":"Lab","organizationalUnitName":"IT","emailAddress":null,"subjectAltNames":["wiki.lab.internal","10.89.70.10"]},"keystore":{"fingerprint":"D3:71:AC:39:76:C7:BC:8D:75:BF:45:DF:F5:EF:E2:23:82:44:10:BB"},"serial":"aa6e17ef-22c3-40d8-bff7-ca6d05b64554","created_at":"2026-10-28","expires_at":"2028-10-27","revoked_at":null,"days_valid":730,"expired":false,"revoked":false,"crl_distribution_url":null,"ocsp_distribution_host":null,"passphrase_issuer":null,"passphrase_out":null,"passphrase_out_confirmation":null}
HTTP:201

HTTP 201、新证书 id=6。三个实测踩过的 400,请对号入座:

现象(回显节选)根因修正
{"non_field_errors":["Passphrase incorrect. Not allowed to revoke your certificate"]}字段名写成 passphrase_in——该字段不存在于序列化器,被静默忽略后父口令缺失用 passphrase_issuer(父 CA 口令)
{"non_field_errors":["Date has wrong format. Use one of these formats instead: YYYY-MM-DD."]}expires_at 缺失或格式不对补 YYYY-MM-DD 日期,且与 days_valid 一致
{"dn":{...}} 返回 DN 字段缺失服务器证书受 DN 策略约束R 策略必填 countryName/stateOrProvinceName/organizationName/commonName

passphrase_out 受 Django 口令强度校验,弱口令(如 q1w2e3r4)会被 400 拒绝。

6.3 下载证书与私钥

私钥端点 /api/v1/certificates/6/key 返回的是 JSON 包装({"text": "-----BEGIN ..."}),需剥壳后落盘;证书 ZIP 包走 /download 端点:

$ curl -s http://127.0.0.1/api/v1/certificates/6/key -H "Authorization: Token $TOKEN" \
    | python3 -c 'import sys,json;open("/tmp/wiki.key","w").write(json.load(sys.stdin)["text"])'
$ openssl rsa -in /tmp/wiki.key -check -noout -passin pass:Vault-Otter-77zK
RSA key ok

RSA key ok 证明私钥可用且 passphrase_out 正确。下载整套证书包(ZIP 含叶子/中间/根/全链 PEM、PKCS#12 与私钥):

$ curl -s -o /tmp/wiki.zip -w 'download HTTP:%{http_code}\n' \
    http://127.0.0.1/api/v1/certificates/6/download -H "Authorization: Token $TOKEN"
download HTTP:200
$ python3 -c 'import zipfile; zipfile.ZipFile("/tmp/wiki.zip").extractall("/tmp/wiki_pkg")'
$ ls /tmp/wiki_pkg
intermediate.pem          rootca.pem                wiki.lab.internal.legacy.p12
intermediate_root-chain.pem  wiki.lab.internal-chain.pem  wiki.lab.internal.p12
                          wiki.lab.internal.key     wiki.lab.internal.pem

openssl 验证证书内容与信任链:

$ openssl x509 -in /tmp/wiki_pkg/wiki.lab.internal.pem -noout -subject -ext subjectAltName
subject=C = CN, ST = Beijing, L = Beijing, O = Lab, OU = IT, CN = wiki.lab.internal
X509v3 Subject Alternative Name:
    DNS:wiki.lab.internal, IP Address:10.89.70.10
$ openssl verify -CAfile /tmp/wiki_pkg/rootca.pem \
    -untrusted /tmp/wiki_pkg/intermediate.pem /tmp/wiki_pkg/wiki.lab.internal.pem
wiki.lab.internal.pem: OK

6.4 吊销证书

DELETE 同一资源端点 + passphrase_issuer(吊销 R 证书用它自身的口令,吊销 I/S/C 用其父证书口令):

$ curl -s -w '\nHTTP:%{http_code}\n' -X DELETE http://127.0.0.1/api/v1/certificates/6 \
    -H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
    -d '{"passphrase_issuer":"Tr0ut-Kiln-42xQ"}'

HTTP:204

HTTP 204 即吊销生效。ORM 复查可见证书已改名并打上吊销时间(见 4.6 节末尾的证书清单,id=6 已带 _revoked- 后缀)。

无口令证书的吊销 500:若证书(或其父)创建时未设私钥口令,吊销请求即使传 "passphrase_issuer": null 也会 500,服务端日志报 TypeError: Password was given but private key is not encrypted——这是 0.4.8 的缺陷(口令校验对无加密私钥传 None 也会走解密分支)。绕过:经 manage.py shell 直接 ORM 删除(实测返回 deleted: True),或规划阶段就给 CA 设口令。

6.5 生成与下载 CRL

前提:中间 CA 配置了 crl_distribution_url(见配置篇 5.7 节)。PUT 触发生成/续签(传父证书口令,字段名是 passphrase_in——注意与创建/吊销的 passphrase_issuer 不同),GET 下载 PEM:

$ curl -s -w 'HTTP:%{http_code}\n' -X PUT http://127.0.0.1/api/v1/certificates/3/crl \
    -H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
    -d '{"passphrase_in":"Tr0ut-Kiln-42xQ"}'
HTTP:204
$ curl -s -o /tmp/issuing.crl -w 'crl HTTP:%{http_code}\n' \
    http://127.0.0.1/api/v1/certificates/3/crl -H "Authorization: Token $TOKEN"
crl HTTP:200
$ openssl crl -in /tmp/issuing.crl -noout -text | grep -A3 'Revoked Certificates\|Serial Number\|Next Update'
        Issuer: C = CN, ST = Shanghai, L = Shanghai, O = Lab, OU = PKI, CN = Lab Issuing CA, emailAddress = issuing@lab.internal
        Last Update: Oct 28 04:52:40 2026 GMT
        Next Update: Oct 28 05:08:50 2027 GMT
Revoked Certificates:
    Serial Number: 1BB905C8D8F74D2EAC3B1106EBA33424
        Revocation Date: Oct 28 04:51:37 2026 GMT
    Serial Number: AA6E17EF22C340D8BFF7CA6D05B64554
        Revocation Date: Oct 28 05:08:50 2026 GMT

CRL 里两条吊销记录分别对应此前吊销的客户端证书(id=5)与刚吊销的 wiki.lab.internal(id=6,序列号 aa6e17ef-... 转 HEX 后即 AA6E17EF...);Next Update 距签发正好 +365 天,与配置篇的 CRL_UPDATE_DAYS_FUTURE=365 一致。

七、备份与恢复实测

BounCA 的证书、私钥、用户全部存 PostgreSQL;需要单独保护的还有 /etc/bounca/services.yaml(应用配置)与 /srv/www/bounca/media(静态文件,可由 collectstatic 重建,一并打包更省事)。应用代码与虚拟环境可随时从发行包重装(见安装篇),无需备份。

7.1 备份

$ export PGPASSWORD='<psql.password>'
$ pg_dump -U bounca -h 127.0.0.1 bounca > /tmp/bounca-backup.sql
$ wc -c /tmp/bounca-backup.sql
167842 /tmp/bounca-backup.sql
$ grep -c '^COPY' /tmp/bounca-backup.sql
23
$ tar czf /tmp/bounca-conf-media.tar.gz -C / etc/bounca srv/www/bounca/media
$ ls -la /tmp/bounca-backup.sql /tmp/bounca-conf-media.tar.gz
-rw-r--r-- 1 www-data www-data  167842 Oct 28 05:09 /tmp/bounca-backup.sql
-rw-r--r-- 1 www-data www-data 7431790 Oct 28 05:09 /tmp/bounca-conf-media.tar.gz

23 条 COPY 对应 23 张表的全量数据。SQL 文件里含 PEM 私钥明文段,务必加密存储(如 gpg -c)并限制权限。

7.2 灾难演练:删库重建后恢复

先停应用释放数据库连接,然后删库、重建、灌回备份:

$ systemctl stop uwsgi
$ psql -U bounca -h 127.0.0.1 -d postgres -c "DROP DATABASE bounca;"
DROP DATABASE
$ psql -U bounca -h 127.0.0.1 -d postgres -c "CREATE DATABASE bounca OWNER bounca;"
CREATE DATABASE
$ psql -U bounca -h 127.0.0.1 -d bounca -f /tmp/bounca-backup.sql
(……输出数百行 CREATE TABLE / COPY……)
ALTER TABLE
ALTER TABLE
ALTER TABLE
$ systemctl start uwsgi
$ systemctl is-active uwsgi
active

7.3 恢复后完整性验证

$ curl -s -o /dev/null -w 'web /: %{http_code}\n' http://127.0.0.1/
web /: 200

API 登录成功(用户表恢复),证书清单完整回到 6 张、吊销状态原样保留:

$ TOKEN=$(curl -s -X POST http://127.0.0.1/api/v1/auth/login/ -H "Content-Type: application/json" \
    -d '{"username":"admin","password":"q1w2e3r4"}' \
    | python3 -c 'import sys,json;print(json.load(sys.stdin)["key"])')
$ curl -s http://127.0.0.1/api/v1/certificates -H "Authorization: Token $TOKEN" \
    | python3 -c '
import sys, json
for c in json.load(sys.stdin):
    print(c["id"], "|", c["name"], "|", c["type"], "| revoked:", c["revoked"])'
6 | wiki.lab.internal_revoked-2026-10-28T05:08:50.362415+00:00 | S | revoked: True
5 | zhangsan-client_revoked-2026-10-28T04:51:37.011772+00:00 | C | revoked: True
4 | gitlab.lab.internal | S | revoked: False
3 | Lab Issuing CA | I | revoked: False
2 | Lab Root CA | R | revoked: False
1 | TestRootEd25519_revoked-2026-10-28T04:46:15.144228+00:00 | R | revoked: True

要点:只要 /etc/bounca/services.yaml 还在(或已从 tar 包恢复),数据库灌回即完全等价恢复;恢复演练建议每季度在预发环境做一次。

八、注意事项

  1. 以 www-data 运行所有 manage.py 命令。虚拟环境与日志目录属主均为 www-data,用其他用户跑会在 pip/写日志时遇到 Permission denied(安装篇 FAQ-1 已实测)。
  2. API 口令字段名因操作而异:创建/吊销用 passphrase_issuer,CRL 生成用 passphrase_in,写错会被静默忽略后报出误导性的错误(如 6.2 节的 Passphrase incorrect)。
  3. sendtestemail 在 0.4.8 不可用(崩溃 + locmem 假发,见 5.3 节),验证邮件链路请走真实业务邮件。
  4. 吊销无口令证书会 500(见 6.4 节),建 CA 时就应设置私钥口令;用 ORM 删除是最后手段,绕过了吊销语义(不进 CRL)。
  5. dumpdata 与 pg_dump 产物含全部私钥明文,备份文件要加密、限权限、异地存放。
  6. days_valid 与 expires_at 必须成对提供且一致,只给其一会被 400 拒绝。

九、常见问题排障实录

Q1:manage.py sendtestemail 报 ModuleNotFoundError: No module named 'crispy_forms' 根因与修复见 5.3 节:settings.py 用 "test" in sys.argv 子串匹配误判环境。临时修复 env/bin/pip install django-crispy-forms;彻底方案是升级版本或给上游提 issue。

Q2:创建证书报 Passphrase incorrect. Not allowed to revoke your certificate 九成是把字段写成了 passphrase_in。创建证书的正确字段是 passphrase_issuer(值 = 父证书私钥口令)。若父证书无口令,该字段仍必须存在但可传 null 之外的空串?——实测传空串与不传等价,无口令父证书应直接不传 passphrase_issuer(序列化器对 None 走「无加密」分支)。拿不准时用 ORM 验证:

$ env/bin/python manage.py shell -c "
from x509_pki.models import Certificate
c = Certificate.objects.get(pk=3)
print(c.is_passphrase_valid('Tr0ut-Kiln-42xQ'))"
True

Q3:dbshell 报 GetPassWarning 或卡在密码输入 非交互终端管道喂 SQL 即可:echo '\dt' | env/bin/python manage.py dbshell。若提示口令错误,核对 /etc/bounca/services.yaml 的 psql 段(修改后无需重启 uwsgi,manage.py 每次执行都重新读配置)。

Q4:恢复后页面 502 psql -f 灌库时若应用仍在运行会因连接占用失败或部分对象未恢复。规范顺序:systemctl stop uwsgi → 恢复 → systemctl start uwsgi(见 7.2 节)。

Q5:吊销返回 500 且服务端日志出现 TypeError: Password was given but private key is not encrypted 见 6.4 节注:0.4.8 对无口令证书的吊销缺陷。要么重建 CA 时设置口令,要么 manage.py shell 里 ORM 删除(代价是不产生 CRL 记录)。

Q6:collectstatic 后页面样式仍缺失 确认 Nginx 静态根路径与配置一致(media/static),并检查文件数:find /srv/www/bounca/media/static -type f | wc -l 应为 1013(0.4.8 基线);不一致时重跑 collectstatic --noinput --clear。

十、参考链接