青岛OJ跑起来之后,第一件让我夜不能寐的事,就是它还在用HTTP裸奔。登录页面输入暗语一样的强密码,训练场上百号人同时提交代码,密码在局域网里倒还好,一旦把OJ暴露到公网,抓包工具一开,所有账号密码和提交的代码全部“直播”出去。我才意识到,配置SSL证书、开启HTTPS访问不是锦上添花,而是OJ上线的刚需。
这篇是这个系列的第七篇,重点说清楚给青岛OJ配SSL证书的完整路径:从证书方案选型、acme.sh签发,到把证书挂进Docker里的Nginx、开启HTTPS并自动续期,顺便解决几个我踩过的坑。适合已经能够正常通过HTTP访问青岛OJ、想把站点升级为HTTPS的管理员,也适合部署其他Docker化Web服务的人参考。
1. 为什么OJ站点必须走HTTPS:登录密码泄露跟“裸奔”没区别
先说一个被很多人忽略的点:你在Nginx或者后端做的任何密码加密、Session加密,都不解决传输层的问题。HTTP是明文协议,数据从浏览器到服务器之间经过的每一个路由器、交换机、Wi-Fi热点,理论上都能完整看到POST表单里的密码。OJ这种场景尤其危险——账号密码、待评测的源代码,都是敏感数据。源代码如果被中间人篡改,还可能直接影响评测结果。
有人会反驳:“我OJ是内网部署,不暴露公网。”那也建议开HTTPS。内网也有一堆ARP欺骗、伪基站Wi-Fi,配置一次成本很低,没必要赌。而且现在主流浏览器对HTTP站点直接标“不安全”,学生访问OJ、老师演示的时候,页面上一把红色锁头,观感太差。
还有一点跟OJ强相关:现代浏览器对HTTPS页面里的HTTP请求有限制,也就是混合内容拦截。等你之后加代码高亮、MathJax公式、在线IDE这类子功能,如果还停留在HTTP,很多浏览器会直接挡住混在页面里的非HTTPS资源。到时候排查起来比现在配证书麻烦十倍。
所以,给OJ配HTTPS不是一个“可选优化”,而是部署清单里的一项基本配置。接下来进入实操环节,我会把从零到生效的每一步都拆开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 证书方案怎么选:Let‘s Encrypt、云厂商免费证书还是自签名
市面上免费SSL证书来源不少,但适合OJ场景的其实就两类。
2.1 Let’s Encrypt证书:自动续期、通配符支持
Let‘s Encrypt是目前最主流的免费证书颁发机构,证书有效期只有90天,靠ACME协议自动续期。优点是完全免费、自动化程度高、所有主流客户端都有,比如acme.sh、certbot。签发时有两种验证方式:HTTP验证和DNS验证。HTTP验证需要在域名对应站点根目录放一个临时文件,或者用standalone模式临时占用80端口;DNS验证则需要在DNS服务商处加一个TXT记录,适合通配符证书。
对青岛OJ来说,如果没有特殊需求,签发一个普通单域名证书就够了,比如oj.example.com。如果你将来想给多个子域名都上HTTPS,也可以签发通配符证书,比如*.example.com,前提是你的DNS服务商支持API自动添加记录。acme.sh对国内外主流DNS服务商都有接口,配置好之后续期完全不用管。
2.2 云厂商免费证书:一年量、手动续期
阿里云、腾讯云等云厂商也提供免费SSL证书,一般一年期。这类证书适合不想折腾ACME、习惯在云控制台操作的人。流程是:在控制台申请证书,绑定域名,下载Nginx格式的证书文件,然后上传到服务器。缺点是一年到期后需要手动再次申请,有些还限制单账号每年免费额度。热搜里提到的“阿里云ssl证书免费续期”,实际上就是指这种证书到期后重新申请免费版,并不是自动续费,至少流程上是手动操作一步。2025年之后阿里云免费证书策略也有调整,个人申请通常只能一次3个月或一年,具体以控制台为准。
既然我们做OJ,追求的是稳定省心,我更推荐Let’s Encrypt + acme.sh。这不是说云厂商证书不好,而是自动续期的体验差异太大:一个忘记续期,证书过期,浏览器直接拒绝连接,OJ停摆;另一个只要定时任务正常,证书到期前自动续上,根本不用管。
2.3 自签名证书:只能说“本地测试专用”
自签名证书可以自己用openssl生成,也能让浏览器通过HTTPS访问(需手动信任),但没有任何公信力,协作者每台设备都要导入证书,学生异地访问OJ时更麻烦。除非你是在完全离线的内网做测试,否则不建议。青岛OJ本身就有公网访问需求,老老实实用公网受信任的证书。
选型之前,还要确认两件事:第一,你有没有域名,没有的话先去注册一个,IP地址是没法申请公网HTTPS证书的(内网IP也不行);第二,域名能不能解析到你服务器,最好提前把A记录配置好,并且确认解析生效。证书验证的是“你确实控制这个域名”,所以域名解析是前提。
3. 用acme.sh签发证书:域名解析、安装、签发的完整链路
我之前一直用certbot,后来换成了acme.sh,原因就一个:纯Shell脚本,不依赖Python环境,对服务器内存占用极小,而且部署到Nginx超简单。下面以acme.sh为例。
3.1 安装acme.sh
执行下面一条命令即可:
bash复制curl https://get.acme.sh | sh -s email=你的邮箱@example.com
它会自动把你的acme.sh安装到当前用户目录下的.acme.sh,并写入一个定时任务(cron)用于每天检查证书到期时间。安装完成后,执行下面命令让环境变量生效:
bash复制source ~/.bashrc
如果服务器有多个用户,建议用专门运行Nginx服务的管理员用户来安装,避免权限纠缠。
3.2 签发证书前必须确认的“前置条件”
在签发之前,我先踩过一个坑:域名的A记录指向了CDN,导致验证失败。Let’s Encrypt的HTTP验证是让ACME客户端从你域名对应的服务器上取一个随机文件,如果你域名解析到CDN,CDN节点上当然没有这个文件,验证就失败了。所以签发前,请把域名的解析临时指向你的服务器IP,等证书签完再改回去。用DNS验证方式则不存在这个问题,但需要DNS服务商支持API,配置起来稍麻烦。
另一个容易忽略的点是:80端口必须能公网访问。即使你最终只跑HTTPS,签发证书这一步HTTP验证通常需要用到80端口(standalone模式)或已经存在的Nginx端口。如果你服务器防火墙或云安全组没有放行80端口,验证会一直超时。
3.3 使用standalone模式签发
如果当前服务器上没有占用80端口的服务,可以直接用standalone模式,acme.sh会临时启动一个轻量HTTP服务来完成验证:
bash复制~/.acme.sh/acme.sh --issue -d oj.example.com --standalone -k ec-256
-k ec-256指定生成ECC证书,对应的私钥是256位ECDSA,安全性更高,握手速度也更快。如果你对兼容性要求极高,可以不加-k参数,默认生成RSA 2048证书。
签发成功后,acme.sh会在~/.acme.sh/oj.example.com_ecc/目录下生成证书文件和私钥,其中包括fullchain.cer和oj.example.com.key,这两个就是后面Nginx要用的文件。
3.4 使用webroot模式签发
如果服务器上已经有运行中的Nginx,使用webroot模式更优雅,不需要停服务:
bash复制~/.acme.sh/acme.sh --issue -d oj.example.com --webroot /var/www/html -k ec-256
这里的/var/www/html需要替换成你Nginx实际站点根目录。acme.sh会在该目录下生成临时验证文件,Nginx访问http://oj.example.com/.well-known/acme-challenge/xxxx时能直接定位到该文件。青岛OJ的官方镜像一般不用宿主机的Nginx,我们更常用的还是standalone模式,反正在签发期间临时关一下OJ服务或者换个端口,问题不大。
3.5 签发后的目录布局
模板化描述一下,签好后你的证书文件大概长这样:
code复制~/.acme.sh/oj.example.com_ecc/
├── ca.cer
├── fullchain.cer
├── oj.example.com.conf
└── oj.example.com.key
fullchain.cer是完整证书链,包含站点证书和CA中间证书,Nginx里ssl_certificate必须用这个,而不是单独的cert.pem之类的文件。oj.example.com.key是私钥,权限要设置成600或640,只让Nginx和root用户可读。
3.6 安装证书到指定的目录
acme.sh可以直接把证书安装到Nginx使用的目录,并记录部署位置,便于后续自动续期时更新证书。命令:
bash复制~/.acme.sh/acme.sh --install-cert -d oj.example.com \
--key-file /etc/nginx/ssl/oj.example.com.key \
--fullchain-file /etc/nginx/ssl/oj.example.com.cer \
--reloadcmd "docker exec nginx nginx -s reload"
这里我提前创建了/etc/nginx/ssl目录,把证书写到这个位置。--reloadcmd在每次续期成功后会执行,让Nginx重新加载新证书。如果你的Nginx跑在Docker容器里,并且容器名是nginx,就写上面对话框里的命令;如果Nginx直接跑在宿主机,改成systemctl reload nginx即可。
4. 把证书挂进青岛OJ的Nginx容器:修改配置、开启HTTPS
青岛OJ官方部署方式是基于Docker Compose,核心服务有nginx、oj-server、oj-judge等,其中nginx容器负责对外接收请求并反向代理到oj-server。我们要做的就是把证书文件放进nginx容器,然后修改Nginx配置,监听443端口。
4.1 找到并确认nginx容器所用的配置文件
由于不同的部署方式可能不一样,我建议你先执行:
bash复制docker ps
找到名叫oj-nginx或类似名字的nginx容器,然后看容器的挂载信息:
bash复制docker inspect <nginx容器ID> | grep -A10 Mounts
正常情况下,nginx容器会把宿主机某个目录下的静态文件挂载进去,同时可能挂载Nginx配置文件。如果配置文件没有挂载到宿主机,你可以用docker cp的方式把容器内的/etc/nginx/nginx.conf拷出来修改后再塞回去,但这样不够优雅,容器重建会丢失。更合理的做法是在docker-compose.yml里把配置目录挂载出来。
下面是一个简化的docker-compose.yml片段,展示nginx服务挂载ssl证书和配置目录的思路:
yaml复制 nginx:
image: registry.cn-qingdao.aliyuncs.com/qduoj/nginx
container_name: oj-nginx
volumes:
- ./nginx_conf:/etc/nginx/conf.d:ro
- ./ssl:/etc/nginx/ssl:ro
- ./static:/static:ro
ports:
- "80:80"
- "443:443"
depends_on:
- oj-server
restart: always
如果你的原配置没有挂载nginx_conf和ssl目录,建议按这个思路自行添加。别担心会影响原有服务,只需要保持其他挂载项不变,之后重启容器即可。
4.2 准备证书文件到宿主机
我把证书放在/opt/oj/ssl目录下:
bash复制mkdir -p /opt/oj/ssl
cp ~/.acme.sh/oj.example.com_ecc/fullchain.cer /opt/oj/ssl/oj.example.com.cer
cp ~/.acme.sh/oj.example.com_ecc/oj.example.com.key /opt/oj/ssl/oj.example.com.key
chmod 600 /opt/oj/ssl/oj.example.com.key
然后在docker-compose.yml里把./ssl:/etc/nginx/ssl:ro映射进容器。如果你用之前acme.sh安装证书的命令,直接指定--key-file /opt/oj/ssl/oj.example.com.key和--fullchain-file /opt/oj/ssl/oj.example.com.cer,以后续期自动覆盖这两个文件,更省心。
4.3 编写Nginx配置文件开启HTTPS
青岛OJ的nginx容器可能默认用的是/etc/nginx/conf.d/default.conf,我们需要新建一个独立的配置文件,比如/opt/oj/nginx_conf/oj_https.conf,内容如下:
nginx复制server {
listen 80;
server_name oj.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name oj.example.com;
ssl_certificate /etc/nginx/ssl/oj.example.com.cer;
ssl_certificate_key /etc/nginx/ssl/oj.example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
client_max_body_size 50m;
gzip on;
gzip_types text/plain text/css application/json application/javascript application/xml font/woff2;
gzip_min_length 1024;
location / {
proxy_pass http://oj-server:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
这里有几点要特别说明:
oj-server:8080是青岛OJ后端服务在Docker网络中的访问地址,具体端口要以你的docker-compose.yml为准,有的是8080,有的是8000。如果配置不对,后端的接口会全部502。X-Forwarded-Proto $scheme非常重要。青岛OJ的某些功能,比如生成链接、重定向,需要知道客户端当初是用HTTP还是HTTPS访问的,如果这个头没设置,后端的重定向可能把HTTPS请求重新导回HTTP,造成死循环。ssl_prefer_server_ciphers off可以让客户端优先选择更安全的套件,兼容性也更好。client_max_body_size按需设置,如果OJ支持上传比较大的测试数据包,建议调大一些。
4.4 重启容器并验证服务是否正常
修改完配置后,在docker-compose.yml所在目录执行:
bash复制docker compose up -d
如果版本较老,可能需要用docker-compose up -d。重启后先用命令行检查端口监听:
bash复制ss -tlnp | grep -E '443|80'
再访问https://oj.example.com,如果出现登录页面并且地址栏带锁,说明HTTPS已经生效。同时访问http://oj.example.com,应该自动跳转到https://。
如果发现网页打不开,先看nginx容器日志:
bash复制docker logs --tail 50 oj-nginx
常见的坑是:证书路径写错、容器内没有挂载成功、Nginx配置语法错误。可以用下面命令进容器测试配置语法:
bash复制docker exec oj-nginx nginx -t
如果有语法错误,会直接提示哪一行有问题,改完再重启。
5. 自动续期与证书失效自救:别让证书过期成为OJ事故
Let‘s Encrypt证书只有90天有效期,这既是优势也是风险。自动续期是必须配置的,但自动续期也存在“看起来配了、实际没跑”的情况。下面我把续期的坑一次性说清。
5.1 确认cron任务是否真的存在
acme.sh安装时会自动添加一条crontab。检查方法:
bash复制crontab -l | grep acme
正常会看到类似48 2 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null这样的记录。如果没有,手动加一条,每天凌晨2点48分检查一次即可。
5.2 测试续期能否成功
手动执行一次续期命令,不要等它自己跑:
bash复制~/.acme.sh/acme.sh --cron --force
这里加--force会强制续期,即使没到时间也会重新签发。你会看到acme.sh重新生成证书,并执行我们之前配置的--reloadcmd。
5.3 确认reloadcmd是否真的把新证书加载进Nginx
这是最阴间的一步。acme.sh续期后,会执行--reloadcmd里写的命令。但如果你把证书路径写进容器,reloadcmd执行的是宿主机命令,假如Nginx跑在容器里,需要确保命令执行的环境正确。常见写法:
bash复制docker exec oj-nginx nginx -s reload
有的容器里没有nginx命令,或者Nginx进程不是master进程,reload会失败。建议先手动执行一次这条命令,确认能正常刷新生效。我见过不少案例:acme.sh日志正常,证书文件也更新了,但Nginx还在用内存里的旧证书,原因就是reload命令静默失败。更稳妥的方式是:
bash复制docker exec oj-nginx nginx -t && docker exec oj-nginx nginx -s reload
5.4 证书快过期时怎么手动续期
如果某天你发现证书还有3天过期,但自动续期没跑,不要慌,手动执行:
bash复制~/.acme.sh/acme.sh --renew -d oj.example.com --force
然后检查证书有效期:
bash复制openssl x509 -enddate -noout -in /opt/oj/ssl/oj.example.com.cer
5.5 最坏情况:证书已过期,站点无法访问
如果证书已经过期,浏览器会直接拒绝连接,连管理后台都进不去,这是很尴尬的事。此时别去改配置,先临时用HTTP访问,或者用curl -k https://oj.example.com带上-k参数跳过证书验证,进入服务器把证书续掉。如果再应急一点,可以在Nginx配置里临时注释掉443 server,先恢复HTTP访问,等证书续完再把HTTPS打开。但注意,这种紧急操作不要过夜,因为HTTP裸奔时间越长风险越高。
6. 常见故障排查与安全加固:从弱hash算法到HSTS
最后这部分,我把实际运维中遇到的几类问题和额外加固建议列一下。特别是热搜词里提到的“ssl 证书使用了弱 hash 算法 (cve-2005-4900)怎么修复”,这其实是老生常谈的证书签名算法问题,值得单独说一下。
6.1 弱Hash算法(CVE-2005-4900)到底是什么,怎么检查
CVE-2005-4900指的是SSL证书使用了弱哈希算法(MD5或SHA1)来签名,攻击者可能利用碰撞伪造证书。这个问题在2025年的今天已经很少见,因为主流CA早已不签发SHA1证书,但如果你在运维老系统时遇到,说明你的证书链可能非常旧,或者是从某个不规范的渠道拿到的。
检查证书签名算法的命令:
bash复制openssl x509 -in /opt/oj/ssl/oj.example.com.cer -text -noout | grep "Signature Algorithm"
如果输出是sha256WithRSAEncryption或ecdsa-with-SHA256,说明没问题。如果是sha1WithRSAEncryption,则必须立刻重新签发证书,用支持SHA256的CA。acme.sh签发的证书默认就是SHA256签名,不用额外操作。如果你遇到的是扫描工具报CVE-2005-4900,除了检查站点证书,还要检查CA中间证书和根证书是否含有SHA1签名。不过最终用户通常无法修改根证书,你能做的就是把服务器证书换成正规CA并确保链完整。
6.2 证书链不完整导致部分手机浏览器不认
访问https://时桌面浏览器正常,手机浏览器却报“证书不可信”,多半是证书链不完整。Nginx里的ssl_certificate必须配置为包含完整链的fullchain,而不是只有站点证书。acme.sh的fullchain.cer就是完整链,直接用即可。如果你从云厂商下载证书,一般会得到一个压缩包,解压后找一个名字里带chain或fullchain的文件,没有的话把站点证书和CA证书按顺序拼接:
bash复制cat 你的域名证书.pem 你的CA证书.pem > fullchain.pem
6.3 重置容器后证书失效或者丢失
我用Docker部署OJ时经历过一次:因为升级镜像,执行了docker compose down && docker compose up -d,结果发现nginx容器里的证书没了。原因就是容器重建后,所有未挂载到宿主机的内容都会丢失。所以一定要把证书目录通过volumes映射进容器,不要用docker cp往可写层塞文件。这是最保险的做法。
6.4 开启HSTS,让浏览器强制走HTTPS
在Nginx的443 server块里加一行:
nginx复制add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
这个头的意思是:告诉浏览器,未来两个月内,这个域名只能通过HTTPS访问,即使你手动输入http://oj.example.com,浏览器也会自动改成HTTPS。注意,只有确认HTTPS完全稳定后再开HSTS,否则一旦证书出问题,用户浏览器将在一段时间内无法通过HTTP回退访问。max-age可以先设小一点,比如3600,测试没问题再改成63072000。
6.5 TLS版本与加密套件的合理配置
OJ用户里会有老旧的浏览器吗?现在基本不太会。所以建议只保留TLS 1.2和1.3,关闭TLS 1.0/1.1。参考配置:
nginx复制ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305';
ECC证书(ec-256)配合上面前几个GCM套件,握手和加密性能都不错。如果你使用RSA证书,第一条ECDHE-ECDSA会匹配不上,自动切换到RSA套件,不影响使用。
6.6 混合内容问题排查
配好HTTPS后,最常遇到的问题不是页面打不开,而是“页面能看,但某些资源加载失败”。浏览器控制台会提示Mixed Content,比如图片、CSS、JS还是用http://引用的。青岛OJ的模板一般会使用相对路径,出问题概率不大,但如果你在外面定制了Head、脚本,要统一改成相对路径或者协议相对路径//oj.example.com/...。
最简单的排查方法:
bash复制grep -r "http://oj.example.com" /opt/oj/static/
找到后把所有静态资源里的绝对http://链接改成https://或者相对路径。
6.7 定期检查证书过期的自动化小技巧
除了acme.sh自带的cron,我还会加一个额外检查,在续期脚本里额外发一条通知,比如通过钉钉/企业微信机器人推送证书续期结果。这样如果后续某天ACME脚本因为网络原因失败,你能第一时间察觉。
bash复制~/.acme.sh/acme.sh --cron > /tmp/acme.log 2>&1
CODE=$?
if [ $CODE -ne 0 ]; then
curl -s -X POST "https://oapi.xxx.com/robot/send?access_token=TOKEN" -H 'Content-Type: application/json' -d '{"msgtype":"text","text":{"content":"OJ证书续期失败,请上服务器处理"}}'
fi
在线监控服务也可以,比如免费的UptimeRobot,配置一个HTTPS关键字监控,一旦证书失效或页面无法访问,立刻告警。
6.8 最后补一个容易被忽视的细节
使用acme.sh时,如果服务器时间不准,可能导致证书签发和验证失败。很多云服务器默认时区可能是UTC,系统时间本身没问题,但一些虚拟化环境存在时间漂移。建议装好NTP服务,确保时间同步准确。
bash复制timedatectl set-ntp true
时间漂移不光影响ACME,还会影响OJ的提交时间戳,早解决早安心。
给青岛OJ配HTTPS,整个流程走下来,其实就是三件事:拿到证书、把证书配进Nginx、让证书自动续期。自动化做好以后,日常基本无感,但安全感提升是实实在在的。你可能会觉得第一次折腾ACME、找镜像挂载路径有点烦,但配完之后,再去访问自己OJ的绿色锁头,那种“这站终于可以放心让学生用了”的踏实感,值得。
