1.前言
有这样一个需求,我要给前端开发人员一个ftp账号,前端人员随时就可以增加一个站点。
于是我想到了这种方案:
location / {
index index.html;
root dzkd/html/$http_host;
}
只要在默认的server
节点添加这个location
,再将dzkd/html
目录通过ftp
交给前端人员就实现了。
蓝后,新的需求很快又出来了。我们的网站需要支持HTTPS访问。
那么我们在配置文件中增加ssl
的配置不就完事了。
如果只有一个域名,做个通配符的证书确实能够达到目的。
但是目前的情况是,我不知道将来有多少的域名,也不希望每新增一个域名我都去做个通配符证书,目前(201905)通配符证书有效期只有3个月。
2.思路
没办法,这种情况下需要lua
代码来助攻了,并且要使用自动签发证书的工具。
思路是这样:
- 写一个
shell
脚本定时遍历dzkd/html
目录下的文件夹,每个文件夹就是一个静态网站,文件夹名称就是域名,如:dzkd/html/try.itmx.cc
- 检查这个域名是否含有ssl证书,通过判断
certs
文件夹下是否存在try.itmx.cc.crt
文件得出结果 - 没有证书,那就运行
certbot
命令签发证书 - 用户访问站点时,在
ssl_certificate_by_lua
阶段从certs
目录寻找合适的证书,用于建立ssl连接
3.关键代码和配置
思路描述得比较简单,看代码会比较清晰吧。
- 运行在
certbot
容器中的shell
脚本entrypoint.sh
,负责发现新文件夹(站点),进而签发ssl证书#!/bin/sh function on_success () { chmod o+xr /etc/letsencrypt/archive /etc/letsencrypt/live chmod -R o+r /etc/letsencrypt/archive if [ ! -f "/webroot/$i/index.html" ];then echo "Generate index.html file..." echo "Congratulations on the success of the site." >/webroot/$i/index.html chown 1001:100 /webroot/$i/index.html fi } while [ 1 ] do for i in `ls /webroot | grep "\\."` do if [ ! -d "/webroot/${i}" ];then continue fi if [ -f "/webroot/${i}/ssl/${i}.crt" -a -f "/webroot/${i}/ssl/${i}.key" ] || [ -f "/etc/letsencrypt/live/${i}/fullchain.pem" -a -f "/etc/letsencrypt/live/${i}/privkey.pem" ];then continue fi if [ -f "/webroot/${i}/ssl/${i}.error" ];then continue fi echo "create ssl cert for: ${i}" error_info="" echo "Try standalone mode..." exec_info=$(certbot certonly \ --standalone \ --no-eff-email \ --agree-tos \ -m itmx@foxmail.com \ --server https://acme-v02.api.letsencrypt.org/directory \ -d $i 2>&1) echo -e $exec_info if [ -f "/etc/letsencrypt/live/${i}/fullchain.pem" -a -f "/etc/letsencrypt/live/${i}/privkey.pem" ];then on_success $i echo "Generate ssl cert success by standalone!" continue fi error_info="${error_info}Standalone mode authentication failed. Please check domain name resolution or filing.\r\n${exec_info}\r\n" echo "Try DNS mode..." exec_info=$(certbot certonly \ --no-eff-email --agree-tos \ -a certbot-dns-aliyun:dns-aliyun \ --certbot-dns-aliyun:dns-aliyun-credentials /etc/letsencrypt/credentials.ini \ -m itmx@foxmail.com \ --server https://acme-v02.api.letsencrypt.org/directory \ -d $i 2>&1) echo -e $exec_info if [ -f "/etc/letsencrypt/live/${i}/fullchain.pem" -a -f "/etc/letsencrypt/live/${i}/privkey.pem" ];then on_success $i echo "Generate ssl cert success by dns-aliyun!" continue fi error_info="${error_info}DNS mode authentication failed. Please check whether there is this domain name in Aliyun account. Or set a larger value for option '--dns-aliyun-propagation-seconds'\r\n${exec_info}\r\n" if [ ! -d "/webroot/$i/ssl" ];then mkdir -p /webroot/$i/ssl fi echo -e $error_info > /webroot/$i/ssl/$i.error chown -R 1001:100 /webroot/$i done sleep 5s done
openresty
容器中的nginx.conf
的文件worker_processes auto; events { worker_connections 4096; } error_log logs/error.log; http { access_log off; index index.html; root dzkd/html; resolver 223.5.5.5 114.114.114.114 valid=3600s; server_tokens off; #lua目录 lua_package_path "/usr/local/openresty/nginx/dzkd/mylualib/?.lua;/usr/local/openresty/nginx/dzkd/mylua/?.lua;;"; lua_code_cache on; include mime.types; default_type application/octet-stream; sendfile on; keepalive_timeout 65; open_file_cache max=204800 inactive=20s; open_file_cache_min_uses 1; open_file_cache_valid 30s; gzip on; gzip_min_length 1k; gzip_buffers 4 16k; gzip_http_version 1.0; gzip_comp_level 2; gzip_types text/plain application/x-javascript text/css application/xml; server { listen 80; server_name _; set $web_root /usr/local/openresty/nginx/dzkd/html; # if (-d /etc/letsencrypt/live/$http_host) { # rewrite ^(.*)$ https://$host$1 permanent; # } # if (-f $web_root/$http_host/ssl/$http_host.crt) { # rewrite ^(.*)$ https://$host$1 permanent; # } if (!-d $web_root/$http_host) { return 403; } location ^~ /.well-known { proxy_pass http://certbot; } location = /favicon.ico { return 200 ''; } location ^~ /ssl/ { deny all; } location / { index index.html; root dzkd/html/$http_host; } } server { listen 443 ssl http2; server_name _; ssl_certificate ssl/fake.crt; ssl_certificate_key ssl/fake.key; ssl_certificate_by_lua_file dzkd/mylua/ssl_cert.lua; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:!DSS'; ssl_prefer_server_ciphers on; ssl_session_timeout 5m; ssl_session_cache shared:SSL:50m; ssl_session_tickets off; # add_header Strict-Transport-Security "max-age=31536000; includeSubdomains; preload"; set $web_root /usr/local/openresty/nginx/dzkd/html; if (!-d $web_root/$http_host) { return 403; } location = /favicon.ico { return 200 ''; } location ^~ /ssl/ { deny all; } location / { index index.html; root dzkd/html/$http_host; } } include /etc/nginx/conf.d/*.conf; }
openresty
容器中负责证书加载的lua
脚本local ssl = require "ngx.ssl" --[[ local ocsp = require "ngx.ocsp" local http = require "resty.http" ]] local lrucache = require "resty.lrucache" local cache = lrucache.new(400) local from_cache = false local check = 0 -- 清除之前设置的证书和私钥 local ok, err = ssl.clear_certs() if not ok then ngx.log(ngx.ERR, "failed to clear existing (fallback) certificates") return ngx.exit(ngx.ERROR) end local http_host = ssl.server_name() or 'default' local base_dir = '/usr/local/openresty/nginx/dzkd'; local get_file_content = function(path) local file, err = io.open(path) if not file then return nil, err end local content = file:read('*a') file:close() return content end local get_cert_data = function() local data, err = cache:get(http_host) if data then from_cache = true return data.crt end -- 获取证书内容,先检查网站根目录下的ssl文件夹是否存在证书 cert_data, err = get_file_content(string.format('%s/html/%s/ssl/%s.crt', base_dir, http_host, http_host)) check = check + 1 if not cert_data then -- 再尝试从certbot的目录获取证书 cert_data, err = get_file_content(string.format('/etc/letsencrypt/live/%s/fullchain.pem', http_host)) check = check + 1 end if not cert_data then ngx.log(ngx.ERR, "failed to open pem cert file: ", err) return nil end -- 把 pem 格式的证书直接转换成 der 格式 cert_data, err = ssl.cert_pem_to_der(cert_data) if not cert_data then ngx.log(ngx.ERR, "failed to get DER cert: ", err) return nil end return cert_data end local get_pkey_data = function() local data, err = cache:get(http_host) if data then from_cache = true return data.key end -- 先检查网站根目录下的ssl文件夹是否存在证书私钥 pkey_data, err = get_file_content(string.format('%s/html/%s/ssl/%s.key', base_dir, http_host, http_host)) check = check + 1 if not pkey_data then pkey_data, err = get_file_content(string.format('/etc/letsencrypt/live/%s/privkey.pem', http_host)) check = check + 1 end if not pkey_data then ngx.log(ngx.ERR, "failed to open pem key file: ", err) return end -- 把 pem 格式的私钥直接转换成 der 格式 pkey_data, err = ssl.priv_key_pem_to_der(pkey_data) if not pkey_data then ngx.log(ngx.ERR, "failed to get DER private key: ", err) return end return pkey_data end --[[ local get_ocsp = function(cert_data) local data, err = cache:get(http_host) if data then from_cache = true return data.ocsp end -- 当前 OCSP 接口只支持 DER 格式的证书 local ocsp_url, err = ocsp.get_ocsp_responder_from_der_chain(cert_data) if not ocsp_url then ngx.log(ngx.ERR, "failed to get OCSP responder: ", err) return end -- 生成 OCSP 请求体 local ocsp_req, ocsp_request_err = ocsp.create_ocsp_request(cert_data) if not ocsp_req then ngx.log(ngx.ERR, "failed to create OCSP request: ", err) return end local httpc = http.new() httpc:set_timeout(10000) local res, err = httpc:request_uri(ocsp_url, { method = "POST", body = ocsp_req, headers = { ["Content-Type"] = "application/ocsp-request", } }) -- 校验 CA 的返回结果 if not res then ngx.log(ngx.ERR, "OCSP responder query failed: ", err) return ngx.exit(ngx.ERROR) end if res.status ~= 200 then ngx.log(ngx.ERR, "OCSP responder returns bad HTTP status code ", res.status) return ngx.exit(ngx.ERROR) end local ocsp_resp = res.body if ocsp_resp and #ocsp_resp > 0 then local ok, err = ocsp.validate_ocsp_response(ocsp_resp, cert_data) if not ok then ngx.log(ngx.ERR, "failed to validate OCSP response: ", err) return ngx.exit(ngx.ERROR) end return ocsp_resp end end ]] local cert_data = get_cert_data() if not cert_data then return end local pkey_data = get_pkey_data() if not pkey_data then return end --[[ local ocsp_resp = get_ocsp(cert_data) if not ocsp_resp then return end ]] if check % 2 == 1 then ngx.log(ngx.ERR, 'certificates may be loading.') return end -- 设置ssl证书 local ok, err = ssl.set_der_cert(cert_data) if not ok then ngx.log(ngx.ERR, "failed to set DER cert: ", err) return end -- 设置ssl证书私钥 local ok, err = ssl.set_der_priv_key(pkey_data) if not ok then ngx.log(ngx.ERR, "failed to set DER private key: ", err) return end --[[ -- 设置当前 SSL 连接的 OCSP stapling local ok, err = ocsp.set_ocsp_status_resp(ocsp_resp) if not ok then ngx.log(ngx.ERR, "failed to set ocsp status resp: ", err) return end ]] if not from_cache then cache:set(http_host, { crt = cert_data, key = pkey_data --[[, ocsp = ocsp_resp]] }, 600) end
4.快速上手
以上就是关键的代码和配置了,为了便于使用,我已经构建成独立的镜像了,编排文件如下:
version: '3'
services:
web:
image: itmx/nginx-proxy-plus:0.1.0
container_name: nginx-gateway
restart: always
network_mode: host
volumes:
- letsencrypt:/etc/letsencrypt:ro
- nginx-conf:/etc/nginx/conf.d:ro
- nginx-certs:/etc/nginx/certs:ro
- ./nginx-html:/usr/local/openresty/nginx/dzkd/html:ro
depends_on:
- dockergen
dockergen:
image: itmx/docker-gen:0.1
container_name: docker-gen
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
- nginx-conf:/etc/nginx/conf.d
- nginx-certs:/etc/nginx/certs:ro
depends_on:
- certbot
certbot:
image: itmx/certbot-dns-aliyun:1.1
container_name: auto-https-certbot
restart: always
volumes:
- letsencrypt:/etc/letsencrypt
- ./nginx-html:/webroot
ftp:
image: atmoz/sftp
container_name: auto-https-ftp
restart: always
volumes:
- ./nginx-html:/home/website/html
ports:
- "3389:22"
command: website:passwordxxx:1001
networks:
default:
external:
name: nginx-gateway
volumes:
letsencrypt:
nginx-conf:
nginx-certs:
别忘修改./nginx-html
目录的所有者,否则SFTP登录后没有建立文件夹的权限
chown -R 1001:100 nginx-html