静态网站快速建站,自动https

1.前言

有这样一个需求,我要给前端开发人员一个ftp账号,前端人员随时就可以增加一个站点。
于是我想到了这种方案:

    location / {
        index index.html;
        root dzkd/html/$http_host;
    }

只要在默认的server节点添加这个location,再将dzkd/html目录通过ftp交给前端人员就实现了。
蓝后,新的需求很快又出来了。我们的网站需要支持HTTPS访问。
那么我们在配置文件中增加ssl的配置不就完事了。
如果只有一个域名,做个通配符的证书确实能够达到目的。
但是目前的情况是,我不知道将来有多少的域名,也不希望每新增一个域名我都去做个通配符证书,目前(201905)通配符证书有效期只有3个月。

2.思路

没办法,这种情况下需要lua代码来助攻了,并且要使用自动签发证书的工具。
思路是这样:

  1. 写一个shell脚本定时遍历dzkd/html目录下的文件夹,每个文件夹就是一个静态网站,文件夹名称就是域名,如:dzkd/html/try.itmx.cc
  2. 检查这个域名是否含有ssl证书,通过判断certs文件夹下是否存在try.itmx.cc.crt文件得出结果
  3. 没有证书,那就运行certbot命令签发证书
  4. 用户访问站点时,在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

评论