用 Python 批量获取宝塔面板站点 SSL 信息并写入数据库

在日常运维中,我们有多个 宝塔面板 管理不同环境、不同业务的站点。
宝塔面板虽然有 SSL 证书 信息,但没有一个统一的 API 入口查看全部面板的证书有效期、过期时间等信息。
如果站点多、面板多,人工逐个检查就非常麻烦,而且容易漏掉。

因此我写了一个 Python 脚本,批量调用宝塔 API 获取站点信息,包括:

  • 站点名称
  • 面板名称
  • 面板 URL
  • SSL 证书过期时间(ssl_not_after
  • SSL 剩余天数(ssl_end_days

并将这些数据统一写入到数据库表中,方便后续前后端统一展示、做到快过期证书的预警

宝塔 API 背景

宝塔面板的开放 API 提供了 data?action=getData&table=sites 接口,可以获取站点列表。
访问时需要:

  • 面板 URL
  • API 密钥
  • 时间戳签名(MD5 加密)

本脚本封装了 签名计算API 分页处理,并支持 HTTPS 证书信息 解析。

数据库设计

数据库表 bt_sites 字段示例:

字段名 类型 说明
site_name varchar 站点名称
panel_name varchar 面板别名
panel_url varchar 面板地址
ssl_not_after datetime SSL 证书到期时间
ssl_end_days int SSL 证书剩余天数
is_deleted tinyint 逻辑删除标记
created_at datetime 创建时间
updated_at datetime 更新时间

脚本逻辑中:

  • 先将当前面板所有站点 is_deleted 标记为 1(逻辑删除)
  • 再插入或更新新获取到的站点数据(is_deleted 重置为 0)

脚本实现

#!/bin/python3
import time, hashlib, sys, os, json
import pymysql
from datetime import datetime

class bt_api:
    def __init__(self, bt_panel, bt_key):
        self.__BT_PANEL = bt_panel
        self.__BT_KEY = bt_key

    def get_sites(self):
        url = self.__BT_PANEL + '/data?action=getData'
        p_data = self.__get_key_data()
        p_data['table'] = 'sites'
        p_data['tojs'] = 'test'
        all_data = []
        page = 1
        while True:
            p_data['p'] = page
            result = self.__http_post_cookie(url, p_data)
            try:
                data = json.loads(result)
                if not isinstance(data, dict):
                    return {"data": all_data}
                all_data.extend(data.get("data", []))
                if "page" not in data or "Pnext" not in data["page"]:
                    break
                page += 1
                time.sleep(0.5)
            except json.JSONDecodeError:
                return {"data": all_data}
        return {"data": all_data}

    def __get_md5(self, s):
        return hashlib.md5(s.encode('utf-8')).hexdigest()

    def __get_key_data(self):
        now_time = int(time.time())
        return {
            'request_token': self.__get_md5(str(now_time) + self.__get_md5(self.__BT_KEY)),
            'request_time': now_time
        }

    def __http_post_cookie(self, url, p_data, timeout=1800):
        cookie_file = './' + self.__get_md5(self.__BT_PANEL) + '.cookie'
        import urllib.request, ssl, http.cookiejar
        cookie_obj = http.cookiejar.MozillaCookieJar(cookie_file)
        if os.path.exists(cookie_file):
            cookie_obj.load(cookie_file, ignore_discard=True, ignore_expires=True)
        context = ssl._create_unverified_context() if url.startswith('https') else None
        handler = urllib.request.HTTPCookieProcessor(cookie_obj)
        data = urllib.parse.urlencode(p_data).encode('utf-8')
        req = urllib.request.Request(url, data)
        opener = urllib.request.build_opener(
            urllib.request.HTTPSHandler(context=context) if context else urllib.request.HTTPHandler(),
            handler
        )
        try:
            response = opener.open(req, timeout=timeout)
            cookie_obj.save(ignore_discard=True, ignore_expires=True)
            result = response.read()
            return result.decode('utf-8') if isinstance(result, bytes) else result
        except urllib.request.HTTPError:
            return ""

# 数据库配置
DB_CONFIG = {
    "host": "ip",
    "port": "*",
    "user": "*",
    "password": "*",
    "database": "*",
    "charset": "utf8mb4"
}

# 宝塔面板列表(省略部分)
bt_panels = [
    {
        "name": "自定义名称1",
        "url": "http://url:port",
        "key": "api-key"
    },
    {
        "name": "自定义名称2",
        "url": "http://url:port",
        "key": "api-key"
    }
    # ... 其他面板配置 ...
]

def save_to_db(data_list, panel_url):
    conn = pymysql.connect(**DB_CONFIG)
    cursor = conn.cursor()
    now = datetime.now()

    # 先标记删除
    cursor.execute("UPDATE bt_sites SET is_deleted = 1, updated_at=%s WHERE panel_url=%s", (now, panel_url))

    # 插入或更新
    insert_sql = """
    INSERT INTO bt_sites (site_name, panel_name, panel_url, ssl_not_after, ssl_end_days, is_deleted, created_at, updated_at)
    VALUES (%s, %s, %s, %s, %s, 0, %s, %s)
    ON DUPLICATE KEY UPDATE
        ssl_not_after=VALUES(ssl_not_after),
        ssl_end_days=VALUES(ssl_end_days),
        is_deleted=0,
        updated_at=VALUES(updated_at)
    """
    for item in data_list:
        cursor.execute(insert_sql, (
            item["site_name"],
            item["panel_name"],
            item["panel_url"],
            item.get("ssl_not_after", None),
            item.get("ssl_end_days", None),
            now, now
        ))

    conn.commit()
    cursor.close()
    conn.close()

def main():
    # 清除 cookie
    for f in os.listdir('.'):
        if f.endswith('.cookie'):
            os.remove(f)

    for panel in bt_panels:
        print(f"拉取 {panel['name']} 中...")
        api = bt_api(panel['url'], panel['key'])
        try:
            sites = api.get_sites()
            results = []
            for site in sites.get("data", []):
                ssl = site.get("ssl", {})
                if isinstance(ssl, int):
                    ssl = {}
                results.append({
                    "site_name": site.get("name"),
                    "panel_name": panel["name"],
                    "panel_url": panel["url"],
                    "ssl_not_after": ssl.get("notAfter", None),
                    "ssl_end_days": int(ssl.get("endtime")) if ssl.get("endtime") and str(ssl.get("endtime")).isdigit() else None,
                })
            save_to_db(results, panel["url"])
        except Exception as e:
            print(f"处理面板 {panel['name']} 失败: {e}")
        time.sleep(1)

    print("所有数据处理完毕。")

if __name__ == '__main__':
    main()

使用方法

  1. 开启宝塔 API
    • 登录宝塔 → 面板设置 → 开启 API 接口
    • 添加 IP 白名单(脚本运行服务器 IP)
    • 获取 API Key
  2. 配置面板信息
    • bt_panels 中添加多个面板的 nameurlkey
  3. 配置数据库
    • 修改 DB_CONFIG 连接参数
    • 创建 bt_sites 表(提前建好唯一键约束,比如 UNIQUE(panel_url, site_name)
  • 后续可在前端查询数据库,做证书快到期提醒
  • 也可以配合 Prometheus Alert 做自动告警