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 天前 · 3218 次点击

    之前

    之前宝塔自动续签失效两次: 宝塔不能自动续签的 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  
       21 小时 4 分钟前 via Android
    Caddy +1 不要太爽,时间用来专注干别的吧。
    Ansen
        35
    Ansen  
       20 小时 42 分钟前 via iPhone
    yangth
        36
    yangth  
       13 小时 10 分钟前 via Android
    完全没必要 docker ,还要外部脚本去调,一个证书闹麻了
    nuk
        37
    nuk  
       12 小时 8 分钟前
    加个 dns hook 脚本增加删除 dns 记录就得了。。
    snylonue
        38
    snylonue  
       11 小时 16 分钟前
    jenson47
        39
    jenson47  
       10 小时 56 分钟前
    建议这个 https://github.com/usual2970/certimate
    支持多个云
    zoharSoul
        40
    zoharSoul  
       4 小时 34 分钟前
    谁会故意啊 别太抽象
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   3043 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 24ms · UTC 14:07 · PVG 22:07 · LAX 07:07 · JFK 10:07
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.