V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
推荐学习书目
Learn Python the Hard Way
Python Sites
PyPI - Python Package Index
http://diveintopython.org/toc/index.html
Pocoo
值得关注的项目
PyPy
Celery
Jinja2
Read the Docs
gevent
pyenv
virtualenv
Stackless Python
Beautiful Soup
结巴中文分词
Green Unicorn
Sentry
Shovel
Pyflakes
pytest
Python 编程
pep8 Checker
Styles
PEP 8
Google Python Style Guide
Code Style from The Hitchhiker's Guide
lyxxxh2
V2EX  ›  Python

不用宝塔自动续签了,自己写个续签

  •  
  •   lyxxxh2 · 1 天前 · 3240 次点击

    之前

    之前宝塔自动续签失效两次: 宝塔不能自动续签的 bug 修复

    本以为已经好了,直到今天又失效,算你厉害,用不起。

    https://i.imgur.com/lJfJ0d1.png

    更新宝塔还是没用,坑爹。

    我不理解: 比续签更复杂你们都能做,怎么到续签就出问题了。

    不仅仅我一个人续签失败,挺多人都是这样。

    我理解不了啊,你们是不是故意的???

    通过 cursor 来写

    给 ai 的:

    我要自动续签 nginx 的证书,服务器是用的宝塔。
    1. 有个 domains 变量,是一个列表
       域名有:
         - c.com
         - www.a.com b.com
         - a-admin.com v.xx.com ...
    2. http 请求所有域名,根据域名证书是否小于 30 天,小于 30 天判定为过期。
    3. 利用/home/xxx/acme.sh 来申请证书,使用阿里云的 DNS 解析。AccessKey:xxx  SecretKey:123456
    4.  最后更新到 nginx 。
    

    模型用的是 auto-select,给了屎一样的代码。

    还说我 python 版低(我 3.12.3 ),也不知道用啥模型了,手动选择 3.7 才能用。

    代码

    改下配置就能用

    #!/usr/bin/env python3
    # -*- coding: utf-8 -*-
    
    import ssl
    import socket
    import datetime
    import subprocess
    import os
    import time
    from typing import List, Tuple
    
    # 域名列表
    domains = [
        "a.com,www.a.com", 
        "admin.b.com,x.b.com",
        "c.com"
    ]
    
    # 阿里云 DNS 配置
    ALIYUN_ACCESS_KEY = "xxx"
    ALIYUN_SECRET_KEY = "xx"
    
    def check_cert_expiry(domain: str) -> Tuple[bool, int]:
        """
        检查证书是否过期
        返回: (是否过期, 剩余天数)
        对于多域名证书,检查每个域名并返回最短的剩余天数
        """
        try:
            # 处理多域名情况,逗号分隔的域名
            if ',' in domain:
                domains_list = domain.split(',')
                min_days_left = float('inf')  # 设置初始值为无穷大
                all_results = []
                
                # 检查每个域名
                for single_domain in domains_list:
                    single_domain = single_domain.strip()
                    expired, days = check_cert_expiry(single_domain)
                    all_results.append((single_domain, expired, days))
                    if days < min_days_left:
                        min_days_left = days
                
                # 打印所有域名的结果
                for single_domain, expired, days in all_results:
                    print(f"  - 子域名 {single_domain} 剩余天数: {days}")
                
                # 如果最小天数小于 30 ,则需要续签
                return min_days_left < 30, min_days_left
                
            # 使用外部命令获取证书信息
            cmd = f"echo | openssl s_client -connect {domain}:443 -servername {domain} 2>/dev/null | openssl x509 -noout -dates"
            result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
            
            if result.returncode != 0:
                print(f"检查域名 {domain} 证书时出错: 无法连接或获取证书")
                return True, 0
            
            # 解析输出找到过期日期
            output = result.stdout
            not_after_line = [line for line in output.splitlines() if line.startswith('notAfter=')]
            
            if not not_after_line:
                print(f"检查域名 {domain} 证书时出错: 无法获取过期时间")
                return True, 0
                
            # 解析日期格式,例如: notAfter=May 30 12:00:00 2023 GMT
            date_str = not_after_line[0].split('=')[1]
            expires_date = datetime.datetime.strptime(date_str, '%b %d %H:%M:%S %Y %Z')
            days_left = (expires_date - datetime.datetime.now()).days
            
            print(f"域名 {domain} 证书到期日期: {expires_date.strftime('%Y-%m-%d')}, 剩余天数: {days_left}")
            return days_left < 30, days_left
        except Exception as e:
            print(f"检查域名 {domain} 证书时出错: {str(e)}")
            return True, 0  # 如果无法检查,默认为需要续签
    
    def set_ali_env():
        """
        设置阿里云 DNS API 的环境变量
        """
        os.environ['Ali_Key'] = ALIYUN_ACCESS_KEY
        os.environ['Ali_Secret'] = ALIYUN_SECRET_KEY
    
    def check_dns_record_exists(domain: str) -> bool:
        """
        检查指定域名的 DNS 验证记录是否存在
        """
        try:
            # 设置环境变量
            set_ali_env()
            
            # 验证记录的域名前缀
            acme_challenge = f"_acme-challenge.{domain}"
            
            # 使用阿里云 CLI 查询记录
            cmd = f"aliyun alidns DescribeDomainRecords --DomainName {domain.split('.')[-2]}.{domain.split('.')[-1]} --RRKeyWord _acme-challenge --Type TXT"
            result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
            
            # 检查输出中是否包含记录
            return acme_challenge in result.stdout
        except Exception as e:
            print(f"检查 DNS 记录时出错: {str(e)}")
            # 如果无法确定,假设记录存在,以确保安全
            return True
    
    def renew_cert(domain: str) -> bool:
        """
        使用 acme.sh 续签证书
        支持多域名证书申请
        """
        try:
            # 先设置环境变量
            set_ali_env()
            
            acme_path = "/home/xxx/acme.sh"
            
            # 确保 acme.sh 有执行权限
            os.chmod(acme_path, 0o755)
            
            # 处理多域名情况
            domain_params = ""
            main_domain = ""
            if ',' in domain:
                domains_list = domain.split(',')
                main_domain = domains_list[0].strip()
                domain_params = f"-d {main_domain}"
                
                # 添加其他域名
                for alt_domain in domains_list[1:]:
                    alt_domain = alt_domain.strip()
                    domain_params += f" -d {alt_domain}"
            else:
                main_domain = domain
                domain_params = f"-d {domain}"
            
            # 检查并清理 DNS 记录
            needs_cleanup = False
            
            # 检查主域名
            if check_dns_record_exists(main_domain):
                print(f"域名 {main_domain} 存在 DNS 验证记录,需要清理")
                needs_cleanup = True
                # 清理主域名
                cleanup_cmd = f"{acme_path}/acme.sh --cleanup --domain {main_domain} --dns dns_ali"
                print(f"执行清理命令: {cleanup_cmd}")
                cleanup_process = subprocess.run(cleanup_cmd, shell=True, capture_output=True, text=True)
                print(f"清理结果: {cleanup_process.stdout}")
            else:
                print(f"域名 {main_domain} 不存在 DNS 验证记录,无需清理")
            
            # 检查其他域名
            if ',' in domain:
                for alt_domain in domain.split(',')[1:]:
                    alt_domain = alt_domain.strip()
                    if check_dns_record_exists(alt_domain):
                        print(f"域名 {alt_domain} 存在 DNS 验证记录,需要清理")
                        needs_cleanup = True
                        # 清理其他域名
                        alt_cleanup_cmd = f"{acme_path}/acme.sh --cleanup --domain {alt_domain} --dns dns_ali"
                        print(f"执行清理命令: {alt_cleanup_cmd}")
                        alt_cleanup_process = subprocess.run(alt_cleanup_cmd, shell=True, capture_output=True, text=True)
                        print(f"清理结果: {alt_cleanup_process.stdout}")
                    else:
                        print(f"域名 {alt_domain} 不存在 DNS 验证记录,无需清理")
            
            # 如果进行了清理,等待 DNS 记录更新
            if needs_cleanup:
                print("等待 DNS 记录清理完成...")
                time.sleep(30)  # 等待 30 秒确保 DNS 记录已清理
            
            # 执行续签命令,明确指定使用 Let's Encrypt
            cmd = f"{acme_path}/acme.sh --issue --dns dns_ali {domain_params} --keylength 2048 --force --dnssleep 120 --server letsencrypt"
            print(f"执行命令: {cmd}")
            
            process = subprocess.Popen(
                cmd, 
                shell=True,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True
            )
            
            # 获取输出
            stdout, stderr = process.communicate()
            
            if process.returncode == 0:
                print(f"续签输出: {stdout}")
                return True
            else:
                print(f"续签错误: {stderr}")
                
                # 如果仍然失败,尝试完全移除证书再重新申请
                if "DNS record already exists" in stderr:
                    print("尝试完全移除证书后重新申请...")
                    
                    # 移除证书
                    for d in domain.split(','):
                        d = d.strip()
                        remove_cmd = f"{acme_path}/acme.sh --remove -d {d}"
                        print(f"执行移除命令: {remove_cmd}")
                        subprocess.run(remove_cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
                    
                    # 再次等待
                    print("等待 DNS 记录更新...")
                    time.sleep(30)
                    
                    # 重新申请
                    reissue_cmd = f"{acme_path}/acme.sh --issue --dns dns_ali {domain_params} --keylength 2048 --force --dnssleep 180 --server letsencrypt"
                    print(f"执行重新申请命令: {reissue_cmd}")
                    
                    reissue_process = subprocess.Popen(
                        reissue_cmd, 
                        shell=True,
                        stdout=subprocess.PIPE,
                        stderr=subprocess.PIPE,
                        text=True
                    )
                    
                    reissue_stdout, reissue_stderr = reissue_process.communicate()
                    
                    if reissue_process.returncode == 0:
                        print(f"重新申请成功: {reissue_stdout}")
                        return True
                    else:
                        print(f"重新申请失败: {reissue_stderr}")
                        return False
                
                return False
                
        except Exception as e:
            print(f"续签域名 {domain} 证书时出错: {str(e)}")
            return False
    
    def deploy_cert(domain: str) -> bool:
        """
        部署证书到 Nginx
        支持多域名证书部署
        """
        try:
            acme_path = "/home/xxx/acme.sh"
            
            # 处理多域名情况,使用第一个域名作为主域名
            main_domain = domain.split(',')[0].strip() if ',' in domain else domain
            
            # 证书安装路径
            nginx_cert_path = f"/www/server/panel/vhost/cert/{main_domain}"
            
            # 确保目录存在
            os.makedirs(nginx_cert_path, exist_ok=True)
            
            # 部署证书
            cmd = f"{acme_path}/acme.sh --install-cert -d {main_domain} " \
                  f"--key-file {nginx_cert_path}/privkey.pem " \
                  f"--fullchain-file {nginx_cert_path}/fullchain.pem " 
                #   f"\ --reloadcmd 'service nginx force-reload'"  利用宝塔重启,而不是 acme.sh 重启
            print(f"执行命令: {cmd}")
            
            process = subprocess.Popen(
                cmd, 
                shell=True,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True
            )
            
            # 获取输出
            stdout, stderr = process.communicate()
            
            if process.returncode == 0:
                print(f"部署输出: {stdout}")
                return True
            else:
                print(f"部署错误: {stderr}")
                return False
                
        except Exception as e:
            print(f"部署域名 {domain} 证书时出错: {str(e)}")
            return False
    
    def update_nginx():
        """
        更新 Nginx 配置并重启服务
        """
        try:
            # 使用宝塔命令重载 Nginx
            print("重载 Nginx 配置...")
            reload_cmd = "bt reload nginx"
            reload_process = subprocess.Popen(
                reload_cmd, 
                shell=True,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True
            )
            
            reload_stdout, reload_stderr = reload_process.communicate()
            
            if reload_process.returncode != 0:
                print(f"Nginx 重载错误: {reload_stderr}")
                return False
                
            # 完全重启 Nginx 以确保证书生效
            print("重启 Nginx 服务...")
            restart_cmd = "bt restart nginx"
            restart_process = subprocess.Popen(
                restart_cmd, 
                shell=True,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True
            )
            
            restart_stdout, restart_stderr = restart_process.communicate()
            
            if restart_process.returncode == 0:
                print(f"Nginx 重启成功: {restart_stdout}")
                return True
            else:
                print(f"Nginx 重启错误: {restart_stderr}")
                return False
        except Exception as e:
            print(f"更新和重启 Nginx 时出错: {str(e)}")
            return False
    
    def main():
        print(f"开始检查证书状态 - {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
        domains_to_renew = []
        
        # 检查所有域名的证书状态
        for domain in domains:
            print(f"检查域名: {domain}")
            is_expired, days_left = check_cert_expiry(domain)
            if is_expired:
                print(f"域名 {domain} 证书将在 {days_left} 天后过期,需要续签")
                domains_to_renew.append(domain)
            else:
                print(f"域名 {domain} 证书还有 {days_left} 天过期,无需续签")
        
        if not domains_to_renew:
            print("所有证书都在有效期内,无需续签")
            return
        
        # 续签需要更新的证书
        renewed_domains = []
        for domain in domains_to_renew:
            print(f"\n 正在续签域名 {domain} 的证书...")
            if renew_cert(domain):
                print(f"域名 {domain} 证书续签成功")
                # 部署证书
                if deploy_cert(domain):
                    print(f"域名 {domain} 证书部署成功")
                    renewed_domains.append(domain)
                else:
                    print(f"域名 {domain} 证书部署失败")
            else:
                print(f"域名 {domain} 证书续签失败")
                
        # 如果有证书被续签并部署,更新 Nginx 配置
        if renewed_domains:
            print("\n 正在更新 Nginx 配置...")
            if update_nginx():
                print("Nginx 配置更新成功")
            else:
                print("Nginx 配置更新失败")
        
        print(f"\n 证书续签任务完成 - {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
        print(f"已续签的域名: {', '.join(renewed_domains) if renewed_domains else '无'}")
    
    def force_renew_all():
        """
        强制更新所有域名的证书,用于测试
        """
        print(f"开始强制更新所有证书 - {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
        
        # 续签所有域名的证书
        renewed_domains = []
        for domain in domains:
            print(f"\n 正在更新域名 {domain} 的证书...")
            if renew_cert(domain):
                print(f"域名 {domain} 证书更新成功")
                # 部署证书
                if deploy_cert(domain):
                    print(f"域名 {domain} 证书部署成功")
                    renewed_domains.append(domain)
                else:
                    print(f"域名 {domain} 证书部署失败")
            else:
                print(f"域名 {domain} 证书更新失败")
        
        # 如果有证书被更新并部署,更新 Nginx 配置
        if renewed_domains:
            print("\n 正在更新 Nginx 配置...")
            if update_nginx():
                print("Nginx 配置更新成功")
            else:
                print("Nginx 配置更新失败")
        
        print(f"\n 证书更新任务完成 - {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
        print(f"已更新的域名: {', '.join(renewed_domains) if renewed_domains else '无'}")
    
    if __name__ == "__main__":
        import sys
        if len(sys.argv) > 1 and sys.argv[1] == '--force':
            force_renew_all()
        else:
            main()
    
    40 条回复    2025-03-26 17:32:49 +08:00
    HangoX
        1
    HangoX  
       1 天前
    是会失败,很傻逼
    adoal
        2
    adoal  
       1 天前
    为啥还要用 Python 写程序来干这事呢,dehydrated 或者 acme.sh 只要写个配置不就行了吗。
    javalaw2010
        3
    javalaw2010  
       1 天前
    直接 acme.sh ,我这边生产环境稳定跑好几年了。
    shangfabao
        4
    shangfabao  
       1 天前
    同意楼上,这不重复造轮子么 acme.sh 很稳定
    adoal
        5
    adoal  
       1 天前
    仔细看了一下,这段 Python 代码是在调用 acme.sh……那就更奇怪了。
    maximdx
        6
    maximdx  
       1 天前
    letsencrypt 不是有 certbot 吗,用那个不好么
    MangK
        7
    MangK  
       1 天前
    caddy 自带证书管理,连续签都免了
    max1024
        8
    max1024  
       1 天前
    我弄几次了 acme.sh 都没有成功。
    lyxxxh2
        9
    lyxxxh2  
    OP
       1 天前
    @adoal
    没用过 acme.sh 的配置。
    之前用 acme.sh 做阿里云 cdn 和 oss 续签, 外加看了宝塔续签源码。
    第一印象就是自己写。
    Logtous
        10
    Logtous  
       1 天前
    caddy +1 省事
    daimaosix
        11
    daimaosix  
       1 天前
    certd 配好不用管了
    lepig
        12
    lepig  
       1 天前
    9.0.0 稳定版就是有问题。

    > |-没有找到 30 天内到期的 SSL 证书,正在尝试去寻找其它可续签证书!
    > |-所有任务已处理完成!

    就算还有 1 天到期,他依然扫描不到要续签的证书。

    现在用最新正式版 9.5 好像可以。 不过我也不打算用面板自带的了
    dnsjia
        13
    dnsjia  
       1 天前
    太复杂了,用下我写的这个 https://ssl.dnsjia.com
    kera0a
        14
    kera0a  
       1 天前 via iPhone
    acme.sh + Bark ,不管失败还是成功都会有手机通知,但用了很多年一直稳定成功。
    ripperdev
        15
    ripperdev  
       1 天前
    最近用 Caddy 替换了 Nginx ,证书申请和续签不需要额外的配置,省事多了
    ttlive
        16
    ttlive  
       1 天前
    用 certd 续签
    y1y1
        17
    y1y1  
       1 天前 via iPhone
    bronyakaka
        18
    bronyakaka  
       1 天前
    用 certbot 插件,
    全自动配置 nginx ,啥脚本都不用写
    skiy
        19
    skiy  
       1 天前
    我直接 acme.sh + docker 。acme.sh 无法重启外部的 nginx ,但是我写个计划任务,定时检测 ssl 文件是否有更新,有更新就 reload nginx 即可。简单,方便迁移。
    jqtmviyu
        20
    jqtmviyu  
       1 天前
    lc5900
        21
    lc5900  
       1 天前
    Caddy +1,包括我的通配符域名证书都一起自动管理了,解放双手
    whereFly
        22
    whereFly  
       1 天前
    @skiy 能手动申请 Letsencrypt 免费证书吗?我想手动添加 dns 验证。
    zenghx
        23
    zenghx  
       1 天前
    acme.sh 用了很多年都挺稳的
    xiangyuecn
        24
    xiangyuecn  
       1 天前
    2025 年了,公网上的 nginx 、apache 之类的 web 服务器还是没有提供自动管理 https 证书的功能吗,整合一下不难吧,方便广大的小网站免去 https 维护,自动根据配置域名 自动通过 ACME 协议一个域名更新个单域名证书就 ojbk 了,url 文件验证对于 web 服务器要多方便就有多方便
    WhatTheBridgeSay
        25
    WhatTheBridgeSay  
       1 天前
    自己又造了个已经有无数最佳实践轮子,非常骄傲,发帖跟大家分享下
    WhatTheBridgeSay
        26
    WhatTheBridgeSay  
       1 天前
    @skiy #19 acme.sh 就是几个 shell 脚本,这也有必要放进容器里么...还附带不能安装,不能 reload 的劣势
    RobinHuuu
        27
    RobinHuuu  
       1 天前 via iPhone
    acme 的 cron job 就是用来续签的,,,
    colorbeta
        28
    colorbeta  
       1 天前
    cf 配置 15 年不是一劳永逸么
    skiy
        29
    skiy  
       1 天前
    @whereFly 能啊。我是将所有域名的 _acme-challenge 都 CNAME 到一个域名了。然后通过 exchange 方式更新。非常方便。
    skiy
        30
    skiy  
       1 天前
    @WhatTheBridgeSay 用 shell 判断文件是否有更新,有更新就 reload 行了啊。就一个 shell 脚本而已。每次我迁移时,只需要打包 out ssl 和 acme.sh docker-compose.yml 就行了。迁移非常方便。
    ljpCN
        31
    ljpCN  
       1 天前
    k8s 里 cert-manager 可以直接配置续签证书。或者直接用 dokploy 这种部署方案,都比安装宝塔面板更优雅更可扩展。
    root71370
        32
    root71370  
       1 天前 via Android
    1panel 很简单
    jaylee4869
        33
    jaylee4869  
       1 天前   ❤️ 1
    certbot + crontab. 两三行 shell 就好了啊。。
    ch3nbo
        34
    ch3nbo  
       22 小时 53 分钟前 via Android
    Caddy +1 不要太爽,时间用来专注干别的吧。
    Ansen
        35
    Ansen  
       22 小时 30 分钟前 via iPhone
    yangth
        36
    yangth  
       14 小时 58 分钟前 via Android
    完全没必要 docker ,还要外部脚本去调,一个证书闹麻了
    nuk
        37
    nuk  
       13 小时 57 分钟前
    加个 dns hook 脚本增加删除 dns 记录就得了。。
    snylonue
        38
    snylonue  
       13 小时 4 分钟前
    jenson47
        39
    jenson47  
       12 小时 45 分钟前
    建议这个 https://github.com/usual2970/certimate
    支持多个云
    zoharSoul
        40
    zoharSoul  
       6 小时 23 分钟前
    谁会故意啊 别太抽象
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2514 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 23ms · UTC 15:56 · PVG 23:56 · LAX 08:56 · JFK 11:56
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.