Django本地HTTPS调试:runsslserver与证书配置 1. 开发环境为什么非得上 HTTPS从一个回调调试说起先说结论django-sslserver这个包解决的是一个非常具体、非常窄的问题——让 Django 自带的开发服务器runserver能跑在 HTTPS 上。它不是什么生产级方案也不是什么安全加固工具它的定位就是一个开发期的便利工具让你在本机用python manage.py runsslserver一条命令起一个带 TLS 的开发服务器省掉配 Nginx、配 Caddy、配证书链那一整套流程。我第一次真正需要它是在对接一个第三方开放平台的授权回调。对方要求回调地址必须是https://开头本地怎么调都调不通只能临时去找各种绕路方案。后来又陆续碰到几类场景浏览器navigator.clipboard在 http 下直接返回 undefined、Service Worker 注册被拒、SameSiteNone的 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 全部歇菜。这条规则带来的直接影响是只要你的调试场景里出现了真实设备 局域网 IPHTTPS 就从可选项变成了必选项。而一旦上了 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 的行为差异对照虽然大部分行为一致但有几处差异必须心里有数否则调试时会怀疑人生。行为项runserverrunsslserver说明默认协议httphttps端口默认仍是 8000默认证书无包内自带的 development 证书未显式指定时使用自动重载支持支持基于 Django 原生 autoreload静态文件DEBUG 下自动服务同样自动服务走 staticfiles handler证书参数无--certificate/--key成对使用只给一个会报错请求协议标识request.is_secure()为 False取决于配置需配合SECURE_PROXY_SSL_HEADER或直连判断适用环境开发开发都不适合上线表格里请求协议标识这一行是重灾区。严格的 TLS 直连情况下Django 的 WSGI handler 会通过wsgi.url_scheme拿到httpsrequest.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-extensionsrunserver_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 commandDjango 会按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 /CCN/STDev/LDev/OLocalDev/OUDev/CNlocalhost \ -addext subjectAltNameDNS: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里的CNlocalhost其实是历史遗留字段现代浏览器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 在DEBUGTrue时默认允许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在开发环境是个经典的坑如果你的健康检查、静态资源或者某个内部请求走的是 httpDjango 会 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。开发阶段最省事的办法是加-kcurl -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同理verifyFalse能跑通但会打一堆InsecureRequestWarning看着很闹心。开发环境我一般这么写import requests import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) resp requests.get( https://127.0.0.1:8443/api/health/, verifyFalse, timeout5, ) print(resp.status_code, resp.json())更规范一点用verify指向证书路径resp requests.get( https://127.0.0.1:8443/api/health/, verifylocalhost-cert.pem, timeout5, )这里要提醒一句verifyFalse千万别出现在任何会进入生产分支的代码里。我见过的做法是把这类调试代码集中放到一个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_INVALIDSAN 里没有当前访问的主机名或 IP重新签发把 IP 加进 SANERR_CERT_AUTHORITY_INVALID自签名证书不在信任库手动信任、或改用 mkcertDisallowedHost访问的 Host 不在ALLOWED_HOSTS加入 IP 或域名表单提交 403 CSRFCSRF_TRUSTED_ORIGINS缺 scheme写成https://ip:port登录成功但刷新掉登录Cookie 的 Secure 属性与协议不匹配检查SESSION_COOKIE_SECUREERR_TOO_MANY_REDIRECTSSECURE_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是在启动时加载证书到内存的文件替换了不会自动生效。改完证书记得重启服务然后强制刷新浏览器CtrlShiftR必要时清一下该站点的证书缓存。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的组合我从最初为了跑通一个回调临时用到现在成了本地调试的默认起手式中间换过好几次方案又换回来原因很简单——它的启动成本和心智负担确实是最低的而开发环境要的就是这个。