BounCA 0.4.8 常用 CLI 命令与工具详解
manage.py 子命令全集真机实测(site/verify_email/send_expire_email/changepassword/dbshell/dumpdata)+ 证书签发/下载/吊销/CRL 全生命周期 API 操作 + pg_dump 备份恢复灾难演练,含 sendtestemail 的 crispy_forms 缺陷与无口令证书吊销 500 两则排障实录。
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 包恢复),数据库灌回即完全等价恢复;恢复演练建议每季度在预发环境做一次。
八、注意事项
- 以
www-data运行所有manage.py命令。虚拟环境与日志目录属主均为www-data,用其他用户跑会在pip/写日志时遇到Permission denied(安装篇 FAQ-1 已实测)。 - API 口令字段名因操作而异:创建/吊销用
passphrase_issuer,CRL 生成用passphrase_in,写错会被静默忽略后报出误导性的错误(如 6.2 节的Passphrase incorrect)。 sendtestemail在 0.4.8 不可用(崩溃 + locmem 假发,见 5.3 节),验证邮件链路请走真实业务邮件。- 吊销无口令证书会 500(见 6.4 节),建 CA 时就应设置私钥口令;用 ORM 删除是最后手段,绕过了吊销语义(不进 CRL)。
dumpdata与pg_dump产物含全部私钥明文,备份文件要加密、限权限、异地存放。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。
十、参考链接
- 软件定位、架构与同类对比:《BounCA 介绍与选型指南》
- 从零安装(apt 依赖、PostgreSQL、uWSGI、Nginx):《Ubuntu 22.04 安装 BounCA 0.4.8 指南》
- services.yaml 参数、口令策略与 CRL 天数:《BounCA 0.4.8 配置参数详解》