Files
aaPanel/class/ssl_manage.py
T
Jack ed55fa708d Update to 7.7.0
Since version 7.7.0, we recommend yours update python to 3.12.

[+] Using nginx technology to load static files improves access speed
[+] Refactor homepage, website, FTP, and database using vue3
[+] Table loading changed to skeleton screen
[+] Add Website statistics-v2 professional plug-in
[+] Add Home page - top 5 resource occupancy
[+] Add protection for Files management (requires Tamper-proof for Enterprise 3.7)
[+] Website, FTP, Databases page add program status
[+] Add FTP log analysis (only supports Centos)
[+] Add password-free login to phpMyAdmin
[+] Add Proxy Project in Website (Supported when web service uses Nginx)
[+] Add WP Toolkit (Pro version only)
[+] Redesigned Docker module
[+] Add WP Toolkit Protection
[+] Add WP Toolkit Backup and Restore
[+] Add WP Toolkit Migrated
[+] Add WP Toolkit Clone site (supports new domain and subdomain)
[+] Add WP Toolkit Create site from backup of other panel
[+] Add WP Toolkit support for Cron automatic backup (only save Local disk)
[+] Add WP Toolkit operation log
[+] Add Integrity check for WP Toolkit
[+] Add WP Toolkit plug-in management and themes management

[*] Optimize phpMyAdmin formula access method
[*] Optimize Home page PHP display problem
[*] Optimize jump to the login interface after the login expires
[*] Optimize automatic renewal of SSL at some times
[*] Optimize Let's Encrypt to increase application success rate

[-] Fix Logs Audit cannot be opened
[-] Fix apache URL rewrite issue
[-] Fix phpmyadmin installation problem
[-] Fix the problem that some servers cannot install software
[-] Fix upload file error
[-] Fix left menu hiding problem
[-] Fix aaPanel Mobile QR code display problem
[-] Fix problem that third-party plug-ins are not displayed in the App Store
[-] Fix issue where the menu bar is blank when opening new tabs
[-] Fixed panel not being accessible in some cases
[-] Fix the issue where Curl warning caused the inability to apply for SSL
[-] Fix Quota issues for Website, FTP, Databases
[-] Fix file interface display problem on mobile terminal
2024-07-19 11:25:10 +08:00

643 lines
25 KiB
Python

# coding: utf-8
# +-------------------------------------------------------------------
# | aaPanel x3
# +-------------------------------------------------------------------
# | Copyright (c) 2015-2016 aaPanel(www.aapanel.com) All rights reserved.
# +-------------------------------------------------------------------
# | Author: baozi <baozi@bt.com>
# +-------------------------------------------------------------------
import json
import os
import shutil
import time
import OpenSSL
from hashlib import md5
from datetime import datetime
from typing import Optional, Tuple, List, Dict
import public
import db
from panelAes import AesCryptPy3
SSL_SAVE_PATH = "{}/vhost/ssl_saved".format(public.get_panel_path())
class _SSLDatabase:
def __init__(self):
db_path = "{}/data/db".format(public.get_panel_path())
if not os.path.exists(db_path):
os.makedirs(db_path, 0o600)
self.db_file = '{}/data/db/ssl_data.db'.format(public.get_panel_path())
if not os.path.exists(self.db_file):
self.init_db()
if not os.path.exists(SSL_SAVE_PATH):
os.makedirs(SSL_SAVE_PATH, 0o600)
def init_db(self):
tmp_db = db.Sql()
setattr(tmp_db, "_Sql__DB_FILE", self.db_file)
create_sql_str = (
"CREATE TABLE IF NOT EXISTS 'ssl_info' ("
"'id' INTEGER PRIMARY KEY AUTOINCREMENT, "
"'hash' TEXT NOT NULL UNIQUE, "
"'path' TEXT NOT NULL, "
"'dns' TEXT NOT NULL, "
"'subject' TEXT NOT NULL, "
"'info' TEXT NOT NULL DEFAULT '', "
"'cloud_id' INTEGER NOT NULL DEFAULT -1, "
"'not_after' TEXT NOT NULL, "
"'use_for_panel' INTEGER NOT NULL DEFAULT 0, "
"'use_for_site' TEXT NOT NULL DEFAULT '[]', "
"'auth_info' TEXT NOT NULL DEFAULT '{}', "
"'create_time' INTEGER NOT NULL DEFAULT (strftime('%s'))"
");"
)
res = tmp_db.execute(create_sql_str)
if isinstance(res, str) and res.startswith("error"):
public.WriteLog("SSL管理", "建表ssl_info失败")
return
index_sql_str = "CREATE INDEX IF NOT EXISTS 'hash_index' ON 'ssl_info' ('hash');"
res = tmp_db.execute(index_sql_str)
if isinstance(res, str) and res.startswith("error"):
public.WriteLog("SSL管理", "为ssl_info建立索引hash_index失败")
return
tmp_db.close()
def connection(self):
tmp_db = db.Sql()
setattr(tmp_db, "_Sql__DB_FILE", self.db_file)
tmp_db.table("ssl_info")
return tmp_db
ssl_db = _SSLDatabase()
class _LocalSSLInfoTool:
def __init__(self):
self._letsencrypt = self.get_letsencrypt_conf()
@staticmethod
def get_letsencrypt_conf():
conf_file = "{}/config/letsencrypt_v2.json".format(public.get_panel_path())
if not os.path.exists(conf_file):
conf_file = "{}/config/letsencrypt.json".format(public.get_panel_path())
if not os.path.exists(conf_file):
return None
tmp_config = public.readFile(conf_file)
try:
orders = json.loads(tmp_config)["orders"]
except (json.JSONDecodeError, KeyError):
return None
return orders
def get_auth(self, domains):
if self._letsencrypt is None:
return None
for _, data in self._letsencrypt.items():
if 'save_path' not in data:
continue
for d in data['domains']:
if d in domains:
return {
"auth_type": data.get('auth_type'),
"auth_to": data.get('auth_to')
}
class SSLManger:
_REFRESH_TIP = "{}/data/ssl_cloud_refresh.tip".format(public.get_panel_path())
_OTHER_DATA_NAME = ("use_for_panel", "use_for_site",)
def __init__(self):
self._local_ssl_info_tool = None
# 与letsencrypt对接
@property
def local_tool(self):
if self._local_ssl_info_tool is None:
self._local_ssl_info_tool = _LocalSSLInfoTool()
return self._local_ssl_info_tool
return self._local_ssl_info_tool
# 用于部署
@classmethod
def get_cert_for_deploy(cls, ssl_hash: str) -> Dict:
res = cls.find_ssl_info(ssl_hash=ssl_hash)
if res is None:
return public.returnMsg(False, '证书不存在!')
data = {
'privkey': public.readFile(res["path"] + '/privkey.pem'),
'fullchain': public.readFile(res["path"] + '/fullchain.pem')
}
if not isinstance(data["privkey"], str) or not isinstance(data["fullchain"], str):
return public.returnMsg(False, '证书读取错误!')
return data
# 是否刷新
@classmethod
def need_refresh(cls):
now = int(time.time())
if not os.path.isfile(cls._REFRESH_TIP):
public.writeFile(cls._REFRESH_TIP, str(now))
return True
last_time = int(public.readFile(cls._REFRESH_TIP))
if last_time + 60 * 60 * 4 < now:
public.writeFile(cls._REFRESH_TIP, str(now))
return True
return False
# 获取hash指纹
@staticmethod
def ssl_hash(cert_filename: str = None, certificate: str = None, ignore_errors: bool = False) -> Optional[str]:
if cert_filename is not None and os.path.isfile(cert_filename):
certificate = public.readFile(cert_filename)
if not isinstance(certificate, str) or not certificate.startswith("-----BEGIN"):
if ignore_errors:
return None
raise ValueError("证书格式错误")
md5_obj = md5()
md5_obj.update(certificate.encode("utf-8"))
return md5_obj.hexdigest()
@staticmethod
def strf_date(sdate):
return time.strftime('%Y-%m-%d', time.strptime(sdate, '%Y%m%d%H%M%S'))
# 获取证书信息
@classmethod
def get_cert_info(cls, cert_filename: str = None, certificate: str = None):
if cert_filename is not None and os.path.isfile(cert_filename):
certificate = public.readFile(cert_filename)
if not isinstance(certificate, str) or not certificate.startswith("-----BEGIN"):
raise ValueError("证书格式错误")
try:
result = {
"issuer": '',
"dns": [],
}
x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, certificate.encode("utf-8"))
# 取产品名称
issuer = x509.get_issuer()
result['issuer'] = ''
if hasattr(issuer, 'CN'):
result['issuer'] = issuer.CN
if not result['issuer']:
is_key = [b'0', '0']
issue_comp = issuer.get_components()
if len(issue_comp) == 1:
is_key = [b'CN', 'CN']
for iss in issue_comp:
if iss[0] in is_key:
result['issuer'] = iss[1].decode()
break
if not result['issuer']:
if hasattr(issuer, 'O'):
result['issuer'] = issuer.O
# 取到期时间
result['notAfter'] = cls.strf_date(x509.get_notAfter().decode("utf-8")[:-1])
# 取申请时间
result['notBefore'] = cls.strf_date(x509.get_notBefore().decode("utf-8")[:-1])
# 取可选名称
for i in range(x509.get_extension_count()):
s_name = x509.get_extension(i)
if s_name.get_short_name() in [b'subjectAltName', 'subjectAltName']:
s_dns = str(s_name).split(',')
for d in s_dns:
result['dns'].append(d.split(':')[1])
subject = x509.get_subject().get_components()
# 取主要认证名称
if len(subject) == 1:
result['subject'] = subject[0][1].decode()
else:
if not result['dns']:
for sub in subject:
if sub[0] == b'CN':
result['subject'] = sub[1].decode()
break
if 'subject' in result:
result['dns'].append(result['subject'])
else:
result['subject'] = result['dns'][0]
return result
except:
return None
# 通过文件名称检查并保存
def save_by_file(self, cert_filename, private_key_filename, cloud_id=None, other_data: Optional[Dict] = None):
if not os.path.isfile(cert_filename) or not os.path.isfile(private_key_filename):
raise ValueError("不存在的证书")
certificate = public.readFile(cert_filename)
private_key = public.readFile(private_key_filename)
if not isinstance(certificate, str) or not isinstance(private_key, str):
raise ValueError("证书格式错误")
return self.save_by_data(certificate, private_key, cloud_id=cloud_id)
# 通过证书内容检查并保存
def save_by_data(self, certificate: str,
private_key: str,
cloud_id: Optional[int] = None,
other_data: Optional[Dict] = None) -> Dict:
if not certificate.startswith("-----BEGIN") or not private_key.startswith("-----BEGIN"):
raise ValueError("证书格式检查错误")
if cloud_id is None:
cloud_id = -1
hash_data = self.ssl_hash(certificate=certificate)
db_data = self.get_ssl_info_by_hash(hash_data)
if db_data is not None: # 已经保存过的
# 检查 cloud_id 与 保存的 cloud_id 不同时,更新cloud_id
if db_data['cloud_id'] != cloud_id and cloud_id != -1:
ssl_db.connection().where("id = ?", (db_data["id"],)).update({"cloud_id": cloud_id})
db_data['cloud_id'] = cloud_id
db_data["dns"] = json.loads(db_data["dns"])
db_data["info"] = json.loads(db_data["info"])
db_data["auth_info"] = json.loads(db_data["auth_info"])
db_data["use_for_site"] = json.loads(db_data["use_for_site"])
return db_data
info = self.get_cert_info(certificate=certificate)
if info is None:
raise ValueError("证书信息解析错误")
auth_info = self.local_tool.get_auth(info['dns'])
if auth_info is None:
auth_info = {}
pdata = {
"hash": hash_data,
"path": "{}/{}".format(SSL_SAVE_PATH, hash_data),
"dns": json.dumps(info['dns']),
"subject": info['subject'],
"info": json.dumps(info),
"cloud_id": cloud_id,
"not_after": info["notAfter"],
"auth_info": json.dumps(auth_info)
}
if other_data:
for key, other_data in other_data.items():
if key in self._OTHER_DATA_NAME:
pdata[key] = other_data
res_id = ssl_db.connection().insert(pdata)
if isinstance(res_id, str) and res_id.startswith("error"):
raise ValueError("数据库写入错误:" + res_id)
pdata["id"] = res_id
if not os.path.exists(pdata["path"]):
os.makedirs(pdata["path"], 0o600)
public.writeFile("{}/privkey.pem".format(pdata["path"]), private_key)
public.writeFile("{}/fullchain.pem".format(pdata["path"]), certificate)
public.writeFile("{}/info.json".format(pdata["path"]), pdata["info"])
pdata["info"] = info
return pdata
# 通过hash指纹获取ssl信息
@staticmethod
def get_ssl_info_by_hash(hash_data: str) -> Optional[dict]:
data = ssl_db.connection().where("hash = ?", (hash_data,)).find()
if isinstance(data, str):
raise ValueError("数据库查询错误:" + data)
if len(data) == 0:
return None
return data
@staticmethod
def _get_cbc_key_and_iv(with_uer_info=True):
uer_info_file = "{}/data/userInfo.json".format(public.get_panel_path())
try:
user_info = json.loads(public.readFile(uer_info_file))
uid = user_info["uid"]
except (json.JSONDecodeError, KeyError):
return None, None, None
md5_obj = md5()
md5_obj.update(str(uid).encode('utf8'))
bytes_data = md5_obj.hexdigest()
key = ''
iv = ''
for i in range(len(bytes_data)):
if i % 2 == 0:
iv += bytes_data[i]
else:
key += bytes_data[i]
if with_uer_info:
return key, iv, user_info
return key, iv, None
def get_cert_list(self, param: Optional[Tuple[str, List]] = None, force_refresh: bool = False) -> List:
if self.need_refresh() or force_refresh:
self._refresh_ssl_info_by_cloud()
self._get_ssl_by_local_data()
return self._get_cert_list(param)
# 获取证书列表
@classmethod
def _get_cert_list(cls, param: Optional[Tuple[str, List]]) -> List:
db_conn = ssl_db.connection()
if param is not None and len(param) == 2 and isinstance(param[0], str) and isinstance(param[1], (tuple, list)):
db_conn.where(param[0], param[1])
res = db_conn.select()
if isinstance(res, str):
raise ValueError("数据库查询错误:" + res)
for value in res:
value["dns"] = json.loads(value["dns"])
value["info"] = json.loads(value["info"])
value["auth_info"] = json.loads(value["auth_info"])
value["use_for_site"] = json.loads(value["use_for_site"])
value['endtime'] = int((datetime.strptime(value['not_after'], "%Y-%m-%d").timestamp()
- datetime.today().timestamp()) / (60 * 60 * 24))
res.sort(key=lambda x: x["not_after"], reverse=True)
return res
# 从云端收集证书
def _refresh_ssl_info_by_cloud(self):
key, iv, user_info = self._get_cbc_key_and_iv(with_uer_info=True)
if key is None or iv is None:
raise ValueError('面板未登录,无法链接云端!')
AES = AesCryptPy3(key, "CBC", iv, char_set="utf8")
# 对接云端
url = "https://www.bt.cn/api/Cert_cloud_deploy/get_cert_list"
try:
res_text = public.httpPost(url, {
"uid": user_info["uid"],
"access_key": user_info["access_key"],
"serverid": user_info["serverid"],
})
res_data = json.loads(res_text)
if res_data["status"] is False:
raise ValueError("获取云端数据失败")
res_list = res_data['data']
except:
raise ValueError("链接云端失败")
change_set = set()
for data in res_list:
try:
privateKey = AES.aes_decrypt(data["privateKey"])
certificate = AES.aes_decrypt(data["certificate"])
cloud_id = data["id"]
change_data = self.save_by_data(certificate, privateKey, cloud_id)
change_set.add(change_data.get("id"))
except:
pass
all_ids = ssl_db.connection().field("id").select()
for ssl_id in all_ids:
if ssl_id["id"] not in change_set:
ssl_db.connection().where("id = ?", (ssl_id["id"], )).update({"cloud_id": -1})
# 从本地收集证书
def _get_ssl_by_local_data(self): # 从本地获取可用证书
local_paths = ['/www/server/panel/vhost/cert', '/www/server/panel/vhost/ssl']
for path in local_paths:
if not os.path.exists(path):
continue
for p_name in os.listdir(path):
pem_file = "{}/{}/fullchain.pem".format(path, p_name)
key_file = "{}/{}/privkey.pem".format(path, p_name)
if os.path.isfile(pem_file) and os.path.isfile(key_file):
try:
self.save_by_file(pem_file, key_file)
except:
pass
panel_pem_file = "/www/server/panel/ssl/fullchain.pem"
panel_key_file = "/www/server/panel/ssl/privkey.pem"
if os.path.isfile(panel_pem_file) and os.path.isfile(panel_key_file):
try:
self.save_by_file(panel_pem_file, panel_key_file, other_data={"use_for_panel": 1})
except:
pass
# 从源储存位置删除
@classmethod
def _remove_ssl_from_local(cls, ssh_hash: str): # 从本地获取可用证书
local_path = '/www/server/panel/vhost/ssl'
if not os.path.exists(local_path):
return
for p_name in os.listdir(local_path):
pem_file = "{}/{}/fullchain.pem".format(local_path, p_name)
if os.path.isfile(pem_file):
hash_data = cls.ssl_hash(cert_filename=pem_file)
if hash_data == ssh_hash:
shutil.rmtree("{}/{}".format(local_path, p_name))
@classmethod
def add_use_for_site(cls, site_id, ssl_id=None, ssl_hash=None) -> bool:
return cls.change_use_for_site(site_id, ssl_id, ssl_hash, is_add=True)
@classmethod
def remove_use_for_site(cls, site_id, ssl_id=None, ssl_hash=None):
return cls.change_use_for_site(site_id, ssl_id, ssl_hash, is_add=False)
# 查询证书
@staticmethod
def find_ssl_info(ssl_id=None, ssl_hash=None) -> Optional[dict]:
tmp_conn = ssl_db.connection()
if ssl_id is None and ssl_hash is None:
raise ValueError("没有参数信息")
if ssl_id is not None:
tmp_conn.where("id = ?", (ssl_id,))
else:
tmp_conn.where("hash = ?", (ssl_hash,))
target = tmp_conn.find()
if isinstance(target, str) and target.startswith("error"):
raise ValueError("数据库查询错误:" + target)
if not bool(target):
return None
target["auth_info"] = json.loads(target["auth_info"])
target["use_for_site"] = json.loads(target["use_for_site"])
target["dns"] = json.loads(target["dns"])
target["info"] = json.loads(target["info"])
target['endtime'] = int((datetime.strptime(target['not_after'], "%Y-%m-%d").timestamp()
- datetime.today().timestamp()) / (60 * 60 * 24))
return target
@classmethod
def change_use_for_site(cls, site_id, ssl_id=None, ssl_hash=None, is_add=True):
target = cls.find_ssl_info(ssl_id=ssl_id, ssl_hash=ssl_hash)
if not target:
return False
try:
site_ids = json.loads(target["use_for_site"])
except:
site_ids = []
if site_id in site_ids and is_add is False:
site_ids.remove(site_id)
up_res = ssl_db.connection().where("id = ?", (target["id"],)).update({"use_for_site": json.dumps(site_ids)})
if isinstance(up_res, str) and up_res.startswith("error"):
raise ValueError("数据库查询错误:" + up_res)
if site_id not in site_ids and is_add is True:
site_ids.append(site_id)
up_res = ssl_db.connection().where("id = ?", (target["id"],)).update({"use_for_site": json.dumps(site_ids)})
if isinstance(up_res, str) and up_res.startswith("error"):
raise ValueError("数据库查询错误:" + up_res)
return True
def get_all_site_ssl(self):
all_sites = public.M("sites").select()
self.clear_use_for_site()
if isinstance(all_sites, str) and all_sites.startswith("error"):
raise ValueError(all_sites)
for site in all_sites:
prefix = "" if site["project_type"] == "PHP" else site["project_type"].lower() + "_"
tmp = self._get_site_ssl_info(site["name"], prefix=prefix)
if tmp is None:
continue
hash_data = self.ssl_hash(cert_filename=tmp[0])
self.add_use_for_site(site["id"], ssl_hash=hash_data)
@staticmethod
def clear_use_for_site():
ssl_db.connection().update({"use_for_site": "[]"})
@staticmethod
def _get_site_ssl_info(site_name, prefix='') -> Optional[Tuple[str, str]]:
path = os.path.join('/www/server/panel/vhost/cert/', site_name)
pem_file = os.path.join(path, "fullchain.pem")
key_file = os.path.join(path, "privkey.pem")
if not os.path.isfile(pem_file) or not os.path.isfile(key_file):
path = os.path.join('/etc/letsencrypt/live/', site_name)
pem_file = os.path.join(path, "fullchain.pem")
key_file = os.path.join(path, "privkey.pem")
if not os.path.isfile(pem_file) or not os.path.isfile(key_file):
return None
webserver = public.get_webserver()
if webserver == "nginx":
conf_file = "{}/vhost/nginx/{}{}.conf".format(public.get_panel_path(), prefix, site_name)
elif webserver == "apache":
conf_file = "{}/vhost/apache/{}{}.conf".format(public.get_panel_path(), prefix, site_name)
else:
conf_file = "{}/vhost/openlitespeed/detail/{}.conf".format(public.get_panel_path(), site_name)
conf = public.readFile(conf_file)
if not conf:
return None
if public.get_webserver() == 'nginx':
keyText = 'ssl_certificate'
elif public.get_webserver() == 'apache':
keyText = 'SSLCertificateFile'
else:
keyText = 'openlitespeed/detail/ssl'
if conf.find(keyText) == -1:
return None
return pem_file, key_file
# 删除证书
def remove_cert(self, ssl_id=None, ssl_hash=None, local: bool = False):
_, _, user_info = self._get_cbc_key_and_iv(with_uer_info=True)
if user_info is None:
raise ValueError('面板未登录,无法上传云端!')
target = self.find_ssl_info(ssl_id=ssl_id, ssl_hash=ssl_hash)
if not target:
raise ValueError('没有指定的证书')
if local:
shutil.rmtree(target["path"])
self._remove_ssl_from_local(target["hash"]) # 把ssl下的也删除
ssl_db.connection().delete(id=target["id"])
if target["cloud_id"] != -1:
url = "https://www.bt.cn/api/Cert_cloud_deploy/del_cert"
try:
res_text = public.httpPost(url, {
"cert_id": target["cloud_id"],
"hashVal": target["hash"],
"uid": user_info["uid"],
"access_key": user_info["access_key"],
"serverid": user_info["serverid"],
})
res_data = json.loads(res_text)
if res_data["status"] is False:
return res_data
except:
if local:
raise ValueError("本地以删除成功, 链接云端失败, 无法删除云端数据")
raise ValueError("链接云端失败, 无法删除云端数据")
ssl_db.connection().where("id = ?", (target["id"],)).update({"cloud_id": -1})
return public.returnMsg(True, "删除成功")
# 下载证书
def upload_cert(self, ssl_id=None, ssl_hash=None):
key, iv, user_info = self._get_cbc_key_and_iv()
if key is None or iv is None:
raise ValueError(False, '面板未登录,无法上传云端!')
target = self.find_ssl_info(ssl_id=ssl_id, ssl_hash=ssl_hash)
if not target:
raise ValueError("没有指定的证书信息")
data = {
'privateKey': public.readFile(target["path"] + '/privkey.pem'),
'certificate': public.readFile(target["path"] + '/fullchain.pem'),
"encryptWay": "AES-128-CBC",
"hashVal": target['hash'],
"uid": user_info["uid"],
"access_key": user_info["access_key"],
"serverid": user_info["serverid"],
}
if data["privateKey"] is False or data["certificate"] is False:
raise ValueError('证书文件读取错误')
AES = AesCryptPy3(key, "CBC", iv, char_set="utf8")
data["privateKey"] = AES.aes_encrypt(data["privateKey"])
data["certificate"] = AES.aes_encrypt(data["certificate"])
# 对接云端
url = "https://www.bt.cn/api/Cert_cloud_deploy/cloud_deploy"
try:
res_text = public.httpPost(url, data)
res_data = json.loads(res_text)
if res_data["status"] is True:
cloud_id = int(res_data["data"].get("id"))
ssl_db.connection().where("id = ?", (target["id"], )).update({"cloud_id": cloud_id})
return res_data
else:
return res_data
except:
raise ValueError('链接云端失败')