1. 开发环境为什么非得上 HTTPS:从一个回调调试说起
先说结论:django-sslserver这个包解决的是一个非常具体、非常窄的问题——让 Django 自带的开发服务器runserver能跑在 HTTPS 上。它不是什么生产级方案,也不是什么安全加固工具,它的定位就是一个开发期的便利工具:让你在本机用python manage.py runsslserver一条命令起一个带 TLS 的开发服务器,省掉配 Nginx、配 Caddy、配证书链那一整套流程。
我第一次真正需要它,是在对接一个第三方开放平台的授权回调。对方要求回调地址必须是https://开头,本地怎么调都调不通,只能临时去找各种绕路方案。后来又陆续碰到几类场景:浏览器navigator.clipboard在 http 下直接返回 undefined、Service Worker 注册被拒、SameSite=None的 Cookie 被浏览器丢弃、地理位置 API 静默失败、支付类 SDK 的沙箱环境强制要求安全上下文。这些问题的共同点是:它们不是你的代码写错了,而是浏览器认为你这个页面"不安全",直接不给权限。
所以这篇文章适合三类人看:一是正在对接外部回调、本地没法联调的同学;二是需要在手机真机上测试 PWA、剪贴板、定位这类敏感 API 的同学;三是想搞清楚"证书、SAN、信任链"这些概念到底怎么回事,但又不想一上来就啃 Nginx 配置手册的同学。哪怕你用的是 Flask、FastAPI,前面几节关于证书生成的思路也是通用的,只是启动命令要换。
需要提前把丑话说在前面:django-sslserver只用于开发环境。它内置的那套自签名证书是公开在代码仓库里的,任何人都能拿到私钥;它也没有做任何 TLS 加固、没有 OCSP、没有会话复用优化。把它放到公网上跑,等于把门锁挂在门上但没插进锁孔。生产环境该用 WSGI/ASGI 服务器加反向代理,这个后面会专门说。
1.1 安全上下文:浏览器划的那条线到底在哪
很多人对 HTTPS 的理解停留在"加密传输"这一层,觉得本地开发数据不出网卡,加不加密无所谓。这个理解只对了一半。现代浏览器对"安全上下文"(Secure Context)的定义,直接决定了一大批 Web API 你根本调不到。判断规则大致是:协议是https:,或者 host 是localhost/127.0.0.1/[::1]这类回环地址,才被认定为安全上下文。注意这里有个很多人踩过的坑:只有回环地址有豁免,局域网 IP 没有。你用http://192.168.1.20:8000在手机上访问,那就不算安全上下文,剪贴板、定位、Service Worker 全部歇菜。
这条规则带来的直接影响是:只要你的调试场景里出现了"真实设备 + 局域网 IP",HTTPS 就从"可选项"变成了"必选项"。而一旦上了 HTTPS,新的问题立刻冒出来——证书不被信任、Cookie 带不过去、CSRF 校验失败、静态资源被判为混合内容被拦。这一连串连锁反应,才是真正耗时间的地方。
再补充一个容易被忽略的点:Secure属性的 Cookie 只能在 HTTPS 下写入。如果你在settings.py里把SESSION_COOKIE_SECURE和CSRF_COOKIE_SECURE打开了,但用 http 访问,现象是"登录接口返回 200,但一刷新就掉登录态",因为 Set-Cookie 被浏览器静默丢弃了。这个现象极具迷惑性,我第一次遇到时排查了快一个小时,最后才在浏览器开发者工具的 Application 面板里看到 Cookie 压根没存进去。
1.2 为什么是 django-sslserver,而不是别的一堆方案
本地起 HTTPS 的路子其实挺多的,我列一下自己试过的几种,以及各自的取舍。
第一种是反向代理:本地跑runserver,前面挂一个 Nginx 或 Caddy 做 TLS 终止。优点是贴近生产架构,证书管理成熟;缺点是每次改配置都要重载、端口要重新规划、Windows 上装 Nginx 还得折腾路径,对"只想快速验证一个回调"的场景来说太重了。
第二种是**django-extensions的runserver_plus**,它带--cert-file参数,配合 Werkzeug 的调试器,功能更全。缺点是它引入了 Werkzeug 依赖,调试页面的交互风格和原生runserver有差异,有些团队不允许在依赖里加这么重的东西。
第三种是自己写一个 management command,继承runserver,重写inner_run塞一个ssl_context进去。这方案最干净,但要自己维护,而且 Django 版本升级时inner_run的签名改过好几次,维护成本不低。
第四种就是本文的主角django-sslserver,它的核心价值就一句话:装完加一行INSTALLED_APPS,命令从runserver改成runsslserver,其他什么都不用动。参数透传、自动重载、静态文件服务、--noreload这些runserver的行为它都继承下来了。这种"零侵入"的特性,在团队协作里价值很高——你不需要跟同事解释"你要先起个代理再改个环境变量",只要在 README 里写一行命令就行。
提示:选型时先想清楚你的需求边界。如果只是"临时跑一次回调验证",随手一个自签名证书加
runsslserver足够了;如果团队长期需要本地 HTTPS,建议直接上mkcert生成本地可信 CA,再配合任意一种启动方式,体验会好非常多。
2. 拆开看:runsslserver 到底做了什么
理解这个包的实现,能帮你在出问题时快速定位。它的代码量其实很小,核心逻辑就是:继承 Django 的runserver命令,在inner_run里构造一个ssl.SSLContext,把证书和私钥加载进去,然后把这个 context 传给 WSGI 服务器的make_server。换句话说,它没有替换 Django 的开发服务器,只是给 socket 套了一层 TLS 外壳。请求进来先解密,解密后的字节流交给原来的 WSGI handler,后面的路由、中间件、视图,全都感知不到 HTTPS 的存在。
理解这一点很重要,因为它解释了后面很多现象:为什么静态文件还能自动服务(因为还是那套 staticfiles 的 handler)、为什么自动重载还能用(因为还是那套 autoreload 机制)、为什么有些安全相关的配置需要你手动在 settings 里加(因为 TLS 终止发生在应用层之下,Django 自己不知道外面是不是 HTTPS,除非你告诉它)。
2.1 runsslserver 与 runserver 的行为差异对照
虽然大部分行为一致,但有几处差异必须心里有数,否则调试时会怀疑人生。
| 行为项 | runserver | runsslserver | 说明 |
|---|---|---|---|
| 默认协议 | http | https | 端口默认仍是 8000 |
| 默认证书 | 无 | 包内自带的 development 证书 | 未显式指定时使用 |
| 自动重载 | 支持 | 支持 | 基于 Django 原生 autoreload |
| 静态文件 | DEBUG 下自动服务 | 同样自动服务 | 走 staticfiles handler |
| 证书参数 | 无 | --certificate/--key成对使用 | 只给一个会报错 |
| 请求协议标识 | request.is_secure()为 False | 取决于配置 | 需配合SECURE_PROXY_SSL_HEADER或直连判断 |
| 适用环境 | 开发 | 开发 | 都不适合上线 |
表格里"请求协议标识"这一行是重灾区。严格的 TLS 直连情况下,Django 的 WSGI handler 会通过wsgi.url_scheme拿到https,request.is_secure()返回 True,这是正常的。但如果你中间又套了一层代理,或者某些客户端带X-Forwarded-Proto头,判断就可能失真,进而导致SECURE_SSL_REDIRECT无限重定向。这类问题在排查时优先看request.is_secure()的实际返回值,别靠猜。
2.2 版本兼容性:那些年踩过的坑
这是django-sslserver最容易让人翻车的地方。这个包的维护节奏不算快,早期版本(0.2 系列)在 Django 3.x 上跑得好好的,但 Django 4.0 之后runserver内部实现调整过,部分老版本会出现启动即报错的情况,典型表现是初始化阶段抛出参数不匹配的异常,或者证书加载完成后 socket 包装失败。
我的处理原则很简单:先升级到该包的最新版本,再看 Django 版本。如果最新版仍然不兼容,就别在这个包上死磕了,直接换方案。下面是一张我整理的替代方案对照表,供你在卡住时快速切换。
| 方案 | 依赖 | 上手成本 | 证书管理 | 适用场景 |
|---|---|---|---|---|
django-sslserver | 单一轻量包 | 极低 | 手动指定或内置 | 快速验证、单人开发 |
django-extensions+runserver_plus | 依赖 Werkzeug | 低 | --cert-file | 需要增强调试面板 |
| 本地 Nginx 反向代理 | 独立服务 | 中 | 成熟 | 团队统一环境、贴近生产 |
| 本地 Caddy 反向代理 | 独立二进制 | 中 | 自动本地证书 | 想省心又要长期用 |
| 自写 management command | 无 | 高 | 完全自控 | 有定制需求、愿意维护 |
我个人的习惯是:日常开发用django-sslserver,因为它启动最快;一旦需要给同事或测试同学演示,就切到 Caddy,因为它的本地证书能被系统信任,不用每次点"继续访问"。
3. 从零搭起来:安装、配置与证书生成
这一节是纯操作,跟着做就行。我按"最省事"到"最规范"的顺序排,你可以按需取用。
3.1 安装与 INSTALLED_APPS 配置
安装本身没什么好说的:
pip install django-sslserver装完之后必须在settings.py的INSTALLED_APPS里加上'sslserver'。这一步省略了会怎样?命令会提示找不到runsslserver。因为 Django 的 management command 发现机制是基于INSTALLED_APPS扫描management/commands/目录的,没注册就等于没这个命令。这个错误信息其实很直白,但每年都有人问,所以单独提一句。
INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'sslserver', # 放在业务 app 之前或之后都行 'myapp', ]注意:把你的业务 app 和
sslserver的先后顺序不影响命令注册,但如果两个 app 下有同名的 management command,Django 会按INSTALLED_APPS的顺序取第一个,这时顺序就有意义了。一般不会撞名,知道有这么回事即可。
装完先别急着起服务,先用python manage.py help看一眼命令列表里有没有runsslserver。有,说明注册成功;没有,大概率是INSTALLED_APPS拼错了或者虚拟环境装到了别处。
3.2 用 openssl 生成一张带 SAN 的自签名证书
包内自带的证书能用,但它有几个硬伤:私钥是公开的、有效期不可控、SAN 里写的可能只有localhost。一旦你要用局域网 IP 给手机调试,内置证书立刻失效。所以我的建议是永远自己生成,多花 30 秒的事。
下面这条命令是我用了很久的模板,直接改 IP 就能用:
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ -keyout localhost-key.pem \ -out localhost-cert.pem \ -subj "/C=CN/ST=Dev/L=Dev/O=LocalDev/OU=Dev/CN=localhost" \ -addext "subjectAltName=DNS:localhost,DNS:*.local.test,IP:127.0.0.1,IP:192.168.1.20"参数逐个解释一下,这几个点每个都有坑:
-x509表示直接生成自签名证书而不是证书请求;-nodes是"no DES",意思是私钥不加密,否则每次启动服务都要输密码,自动化脚本就没法跑了;-days 365是有效期,开发证书给一年足够;-newkey rsa:2048是同时生成新私钥,2048 位在开发场景完全够用,追求速度可以用ecdsa但兼容性略差。
-subj里的CN=localhost其实是历史遗留字段,现代浏览器(Chrome 58 之后)不再用 CN 匹配域名,只看 SAN。所以真正起作用的是后面那个-addext。这里必须把DNS:localhost、IP:127.0.0.1,以及你本机的局域网 IP 全部塞进 SAN 列表,少一个就报ERR_CERT_COMMON_NAME_INVALID。
-addext这个参数需要 OpenSSL 1.1.1 及以上版本。macOS 自带的 LibreSSL 有可能不认,遇到unknown option就brew install openssl然后用绝对路径调用。Windows 上如果用 Git Bash 自带的 openssl,版本一般都够。
生成完会得到两个文件:localhost-cert.pem(证书,可以公开)和localhost-key.pem(私钥,绝对不能提交到仓库)。务必把私钥加进.gitignore,我见过不止一个项目把它传上去了。
3.3 启动命令与常用参数速查
证书有了,启动就一行:
python manage.py runsslserver --certificate localhost-cert.pem --key localhost-key.pem 0.0.0.0:8443这里有两个细节值得说。第一,--certificate和--key必须成对出现,只给一个会直接报错退出,因为底层构建 SSLContext 时两个都必需。第二,端口跟在地址后面,用0.0.0.0:8443是为了让局域网内的手机能访问;如果只写runsslserver,默认绑127.0.0.1:8000,手机连不上。
常用参数我整理成一张表,方便对照:
| 参数 | 作用 | 使用建议 |
|---|---|---|
--certificate | 指定证书文件路径 | 必填,与--key配对 |
--key | 指定私钥文件路径 | 必填,权限设为 600 |
--certificate-chain | 指定中间证书链 | 自签名场景一般不用 |
--ciphers | 指定加密套件 | 默认即可,除非要复现特定握手 |
--noreload | 关闭自动重载 | 调试启动阶段崩溃时用 |
地址:端口 | 绑定地址与端口 | 真机调试必须用0.0.0.0 |
--noreload这个参数值得单独说。自动重载的原理是父进程监视文件变化后重启子进程,但有时候你的代码在AppConfig.ready()里抛异常,重载机制会陷入"启动-崩溃-重启"的死循环,日志刷屏根本看不清真正的错误。这时候加--noreload,让它崩一次就停住,错误信息一目了然。这是我在排查初始化问题时最常用的手法。
4. Django 侧的配套配置:让 HTTPS 真的"生效"
服务起来了,浏览器能打开页面,这只是第一步。接下来你会发现一堆配置层面的问题:ALLOWED_HOSTS拦截、CSRF 403、静态资源加载失败、Cookie 写不进去。这一节专门处理这些。
4.1 ALLOWED_HOSTS、CSRF 与安全 Cookie 的联动
ALLOWED_HOSTS是第一道关。用127.0.0.1访问没问题,因为 Django 在DEBUG=True时默认允许localhost、127.0.0.1和[::1]。但你一旦换成局域网 IP,立刻DisallowedHost。所以开发环境把 IP 加进去:
ALLOWED_HOSTS = ['localhost', '127.0.0.1', '192.168.1.20']接着是 CSRF。Django 4.0 之后,CSRF_TRUSTED_ORIGINS里的每一项必须带 scheme,也就是要写https://192.168.1.20:8443,只写域名会被忽略。这个改动很隐蔽,因为它不报配置错误,只是校验一直失败,表现为表单提交 403。我第一次升级 Django 版本后在这上面浪费了半天。
再来是 Cookie 安全属性。开发环境里我建议先保持关闭,等页面跑通再逐个打开:
SESSION_COOKIE_SECURE = False # 通了之后再改 True CSRF_COOKIE_SECURE = False SECURE_SSL_REDIRECT = False # 开发环境强烈建议关 SECURE_HSTS_SECONDS = 0 # 千万别在本地开 HSTSSECURE_SSL_REDIRECT = True在开发环境是个经典的坑:如果你的健康检查、静态资源或者某个内部请求走的是 http,Django 会 301 到 https,而目标又是同一个地址,就可能形成重定向循环,浏览器报ERR_TOO_MANY_REDIRECTS。更麻烦的是这个配置有时候会和你本地的前端 dev server 打架。
SECURE_HSTS_SECONDS更是绝对不能随手开的。HSTS 是浏览器层面的记忆机制,一旦你本地开了,浏览器会记住"这个域名以后只能走 https",有效期内的所有 http 请求都会被强制改写。清起来很麻烦,得去浏览器设置里手动删。我在本地测试 HSTS 的时候,后来不得不用一个全新的端口号绕开。
提示:判断安全配置有没有生效,最直接的办法是在视图里打印
request.is_secure()、request.get_host()和request.META.get('HTTP_X_FORWARDED_PROTO')三个值,一眼就能看出协议标识是不是和预期一致。
4.2 局域网真机调试:绑定地址与防火墙
手机真机调试是 HTTPS 需求最强烈的场景,也是最容易卡住的地方。完整的链路有三段,任何一段断掉都连不上。
第一段是绑定地址。前面说过必须用0.0.0.0:8443,否则服务只监听回环网卡。第二段是主机防火墙。Windows 上第一次启动时通常弹窗询问是否允许,如果点了取消,后面就一直连不上,得去"高级安全 Windows Defender 防火墙"里手动加一条入站规则放行端口。macOS 上如果开了防火墙也是同理。第三段是网络本身,确认手机和电脑在同一个 Wi-Fi 下,且这个 Wi-Fi 没有开"AP 隔离"(很多公共网络和部分路由器默认开启,会阻止设备间互访)。
三段都通了之后,手机浏览器访问https://192.168.1.20:8443,会看到证书警告。这是预期行为,因为自签名证书不在手机的信任库里。两种处理方式:一是点"高级"继续访问(iOS 上较新的版本可能不给这个选项了);二是把证书装进手机系统——iOS 需要下载.crt文件后在"设置-通用-关于本机-证书信任设置"里手动开启完全信任,Android 则在"安全-加密与凭据-安装证书"里操作,注意 Android 7 以后应用默认不信任用户证书,浏览器能过但 App 内 WebView 可能还是拦。
如果嫌装证书麻烦,又需要长期给多台设备调试,那就直接上mkcert。它会生成一个本地 CA 并自动装进系统信任库,之后签出来的证书在浏览器里全是绿锁。这个方案我在下一节展开。
5. 客户端访问:curl、requests 与浏览器
服务端搭好了,客户端怎么连是另一回事。不同客户端的证书校验策略差别很大,挨个说。
5.1 curl 与 Python requests 的信任处理
curl默认会校验证书。访问自签名证书的地址,直接报SSL certificate problem: self signed certificate。开发阶段最省事的办法是加-k:
curl -k https://127.0.0.1:8443/api/health/但-k有个副作用:它把证书校验整个关掉了,你没法通过它验证"证书配置是否正确"。所以真正需要验证证书本身时,应该用--cacert指定你的证书文件:
curl --cacert localhost-cert.pem https://127.0.0.1:8443/api/health/这样如果通过,说明证书链、SAN、有效期都是对的;如果还是失败,错误信息会精确告诉你问题在哪,比如no alternative certificate subject name matches target host name就是 SAN 没配对。
Python 的requests同理,verify=False能跑通但会打一堆InsecureRequestWarning,看着很闹心。开发环境我一般这么写:
import requests import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) resp = requests.get( "https://127.0.0.1:8443/api/health/", verify=False, timeout=5, ) print(resp.status_code, resp.json())更规范一点,用verify指向证书路径:
resp = requests.get( "https://127.0.0.1:8443/api/health/", verify="localhost-cert.pem", timeout=5, )这里要提醒一句:verify=False千万别出现在任何会进入生产分支的代码里。我见过的做法是把这类调试代码集中放到一个scripts/dev_前缀的脚本里,或者在 CI 上加一条 grep 规则扫描关键词,从机制上堵住。
5.2 用 mkcert 做本地可信证书:一次配置长期受益
自签名证书最大的痛点是"每次都要点继续访问",而且手机上的 WebView 又特别倔。mkcert的思路是:在你本机装一个根 CA,并让它进入系统信任库;之后所有由这个 CA 签发的证书,系统都认。这样浏览器、curl、Python 的requests(如果用系统信任库的话)就全部绿锁了,体验接近真实证书。
流程大致三步。第一步装好mkcert并执行一次mkcert -install,它会把根 CA 装进系统信任库,同时告诉你根证书文件在哪(后面手机要用它)。第二步签发证书,把需要的域名和 IP 全列上:
mkcert -cert-file localhost-cert.pem -key-file localhost-key.pem \ localhost 127.0.0.1 ::1 192.168.1.20第三步,还是用runsslserver起服务,只是证书换成 mkcert 签发的这一对:
python manage.py runsslserver --certificate localhost-cert.pem --key localhost-key.pem 0.0.0.0:8443手机端需要额外把 mkcert 的根 CA(用mkcert -CAROOT找到目录,里面的rootCA.pem)传过去安装并完全信任,之后手机上访问局域网 IP 也是绿锁。
mkcert和自签名方案的取舍很清楚:自签名是零依赖、一次性用;mkcert是一次配置、长期受益,但需要在每台设备上装根 CA。团队里如果有多人需要真机调试,我建议统一用mkcert并把根 CA 的安装步骤写进项目文档,能省掉大量重复沟通。
6. 踩坑实录:报错排查速查表与几条硬经验
这一节是我这几年攒下来的排查清单,按"见到什么现象"到"往哪查"的方式组织,比按技术点分类好用得多。
6.1 常见报错与排查方向
| 现象 / 报错 | 常见原因 | 处理方向 |
|---|---|---|
No such command: runsslserver | 未加入INSTALLED_APPS | 检查拼写、确认虚拟环境 |
ERR_CERT_COMMON_NAME_INVALID | SAN 里没有当前访问的主机名或 IP | 重新签发,把 IP 加进 SAN |
ERR_CERT_AUTHORITY_INVALID | 自签名证书,不在信任库 | 手动信任、或改用 mkcert |
DisallowedHost | 访问的 Host 不在ALLOWED_HOSTS | 加入 IP 或域名 |
| 表单提交 403 CSRF | CSRF_TRUSTED_ORIGINS缺 scheme | 写成https://ip:port |
| 登录成功但刷新掉登录 | Cookie 的 Secure 属性与协议不匹配 | 检查SESSION_COOKIE_SECURE |
ERR_TOO_MANY_REDIRECTS | SECURE_SSL_REDIRECT与协议标识不一致 | 临时关闭该配置再定位 |
| 页面加载但样式全丢 | 静态资源被当作混合内容拦截 | 检查STATIC_URL是否仍是 http |
| 手机连不上 | 绑定地址或防火墙或 AP 隔离 | 三段链路逐个验证 |
| 启动即崩、日志刷屏 | 初始化代码异常 + 自动重载 | 加--noreload看首次报错 |
表格里"静态资源混合内容"这条展开说一下。现象是页面能打开,但 CSS、JS、图片全部加载失败,控制台报Mixed Content: The page at 'https://...' was loaded over HTTPS, but requested an insecure resource 'http://...'。根因通常是STATIC_URL或MEDIA_URL里硬编码了http://,或者是某个模板里写死了绝对地址。开发环境把这两项改成相对路径/static/、/media/就基本能解决。
还有一条不在表里但很典型:改了证书文件但服务没重启,浏览器还是报旧错误。runsslserver是在启动时加载证书到内存的,文件替换了不会自动生效。改完证书记得重启服务,然后强制刷新浏览器(Ctrl+Shift+R),必要时清一下该站点的证书缓存。
6.2 几条用血换来的经验
第一,证书文件和信息分离管理。私钥、证书、生成脚本三者放一起,但私钥单独进.gitignore。更好的做法是把证书目录整个忽略,然后在 README 里放一行生成命令,谁需要谁自己跑。这样既避免了私钥泄露,也避免了不同机器之间证书不一致导致的"我这能跑他那不能跑"。
第二,开发环境的配置和生产的配置必须物理隔离。我习惯把安全相关的开关全部收进一个settings/dev.py里,生产配置单独一个文件,靠DJANGO_SETTINGS_MODULE切换。这样就不会出现"本地调试时把SECURE_SSL_REDIRECT改成 False,提交时忘了改回来"的事故。这条建议听起来很基础,但真正踩过的人才知道有多痛。
第三,HTTPS 相关的调试,优先怀疑"协议标识"而不是"加密本身"。大多数问题(重定向循环、CSRF 失败、Cookie 丢失)的根源都是 Django 认为当前请求是 http,而不是 TLS 握手失败。所以排查的第一步永远是在视图里把request.is_secure()打出来看,而不是去翻证书。
第四,别在开发环境跟 HSTS 较劲。一旦浏览器记住了某个 host 的 HSTS 策略,清起来相当麻烦。如果你确实需要测试 HSTS 行为,用一个专门的端口(比如 9443)配合一个专门的 host,测完就废弃,别污染主调试地址。
第五,django-sslserver的版本要跟着 Django 一起升级。这类小包最容易出现"你升了框架它没升"的错配。我的做法是在requirements.txt里给两者都写明确的下限,并在升级 Django 大版本时顺手跑一次runsslserver冒烟测试,两分钟的事,能省掉后面一堆时间。
最后说个我自己的判断标准:如果某天你发现自己在本地 HTTPS 上花的时间超过半小时,那就别再优化django-sslserver了,直接换mkcert加 Caddy 那一套。工具是用来省时间的,不是用来伺候的。这套runsslserver的组合,我从最初为了跑通一个回调临时用,到现在成了本地调试的默认起手式,中间换过好几次方案又换回来,原因很简单——它的启动成本和心智负担确实是最低的,而开发环境要的就是这个。