Update to v7.63.0

This commit is contained in:
aapanel.com
2026-01-09 09:47:01 +08:00
parent 8cbfeafe48
commit a001604b69
1523 changed files with 62886 additions and 52816 deletions
+943
View File
@@ -0,0 +1,943 @@
import base64
import binascii
import hashlib
import json
import os
import fcntl
import re
import shutil
import socket
import subprocess
import sys
import time
import datetime
from pathlib import Path
import requests
APACHE_CONF_DIRS = [
"/www/server/panel/vhost/apache"
]
def is_ipv4(ip):
'''
@name 是否是IPV4地址
@author hwliang
@param ip<string> IP地址
@return True/False
'''
# 验证基本格式
if not re.match(r"^\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3}$", ip):
return False
# 验证每个段是否在合理范围
try:
socket.inet_pton(socket.AF_INET, ip)
except AttributeError:
try:
socket.inet_aton(ip)
except socket.error:
return False
except socket.error:
return False
return True
def is_ipv6(ip):
'''
@name 是否为IPv6地址
@author hwliang
@param ip<string> 地址
@return True/False
'''
# 验证基本格式
if not re.match(r"^[\w:]+$", ip):
return False
# 验证IPv6地址
try:
socket.inet_pton(socket.AF_INET6, ip)
except socket.error:
return False
return True
def check_ip(ip):
return is_ipv4(ip) or is_ipv6(ip)
def find_apache_conf_files(keyword):
"""查找 Apache 主配置和 vhost 文件"""
files = set()
for base in APACHE_CONF_DIRS:
base_path = Path(base)
if not base_path.exists():
continue
for f in base_path.rglob("*.conf"):
with open(f, "r") as file:
content = file.read()
# 检查是否包含 ServerName 或 ServerAlias 指令
if keyword in content:
files.add(str(f))
return list(files)
def insert_location_into_vhost(file_path, keyword, verify_file):
LOCATION_BLOCK = [
" <Location /.well-known/acme-challenge/{}>\n".format(verify_file),
" Require all granted\n",
" Header set Content-Type \"text/plain\"\n",
" </Location>\n",
" Alias /.well-known/acme-challenge/{} /tmp/{}\n".format(verify_file, verify_file),
]
path = Path(file_path)
backup = path.with_suffix(path.suffix + ".bak")
shutil.copy(path, backup)
with open(path, "r") as f:
lines = f.readlines()
new_lines = []
in_vhost = False
hit_vhost = False
location_exists = False
for line in lines:
stripped = line.strip()
if stripped.lower().startswith("<virtualhost"):
in_vhost = True
hit_vhost = False
location_exists = False
if in_vhost:
low = stripped.lower()
if (low.startswith("servername") or low.startswith("serveralias")) and keyword in stripped:
hit_vhost = True
if "<location /.well-known/acme-challenge/>" in low:
location_exists = True
if stripped.lower() == "</virtualhost>":
if hit_vhost and not location_exists:
new_lines.extend(LOCATION_BLOCK)
in_vhost = False
new_lines.append(line)
with open(path, "w") as f:
f.writelines(new_lines)
return True
def find_nginx_files_by_servername(keyword):
"""通过 nginx -T 找到包含 server_name 的配置文件"""
result = subprocess.run(
["nginx", "-T"],
stderr=subprocess.STDOUT,
stdout=subprocess.PIPE,
text=True,
check=True
)
files = set()
current_file = None
for line in result.stdout.splitlines():
if line.startswith("# configuration file"):
current_file = line.split()[-1].rstrip(":")
if "server_name" in line and keyword in line:
if current_file:
files.add(current_file)
return list(files)
def insert_location_into_server(file_path, keyword, verify_file, verify_content):
path = Path(file_path)
backup = path.with_suffix(path.suffix + ".bak")
shutil.copy(path, backup)
with open(path, "r") as f:
lines = f.readlines()
new_lines = []
brace_level = 0
in_server = False
hit_server = False
location_exists = False
LOCATION_BLOCK = [
" location = /.well-known/acme-challenge/{} {{\n".format(verify_file),
" default_type text/plain;\n",
" return 200 \"{}\";\n".format(verify_content),
" }\n"
]
for i, line in enumerate(lines):
stripped = line.strip()
# server 开始
if stripped.startswith("server"):
in_server = True
hit_server = False
location_exists = False
if in_server:
brace_level += line.count("{")
brace_level -= line.count("}")
if "server_name" in line and keyword in line:
hit_server = True
if "location /.well-known/acme-challenge/" in line:
location_exists = True
# server 结束
if brace_level == 0:
if hit_server and not location_exists:
new_lines.extend(LOCATION_BLOCK)
in_server = False
new_lines.append(line)
with open(path, "w") as f:
f.writelines(new_lines)
return True
class AutoApplyIPSSL:
# 请求到ACME接口
def __init__(self):
self._wait_time = 5
self._max_check_num = 15
self._url = 'https://acme-v02.api.letsencrypt.org/directory'
self._bits = 2048
self._conf_file_v2 = '/www/server/panel/config/letsencrypt_v2.json'
self._apis = None
self._replay_nonce = None
self._config = self.read_config()
# 取接口目录
def get_apis(self):
if not self._apis:
# 尝试从配置文件中获取
api_index = "Production"
if not 'apis' in self._config:
self._config['apis'] = {}
if api_index in self._config['apis']:
if 'expires' in self._config['apis'][api_index] and 'directory' in self._config['apis'][api_index]:
if time.time() < self._config['apis'][api_index]['expires']:
self._apis = self._config['apis'][api_index]['directory']
return self._apis
# 尝试从云端获取
res = requests.get(self._url)
if not res.status_code in [200, 201]:
result = res.json()
if "type" in result:
if result['type'] == 'urn:acme:error:serverInternal':
raise Exception('Service is closed for maintenance or internal error occurred, check <a href="https://letsencrypt.status.io/" target="_blank" class="btlink">https://letsencrypt.status.io/</a> .')
raise Exception(res.content)
s_body = res.json()
self._apis = {}
self._apis['newAccount'] = s_body['newAccount']
self._apis['newNonce'] = s_body['newNonce']
self._apis['newOrder'] = s_body['newOrder']
self._apis['revokeCert'] = s_body['revokeCert']
self._apis['keyChange'] = s_body['keyChange']
# 保存到配置文件
self._config['apis'][api_index] = {}
self._config['apis'][api_index]['directory'] = self._apis
self._config['apis'][api_index]['expires'] = time.time() + \
86400 # 24小时后过期
self.save_config()
return self._apis
def acme_request(self, url, payload):
headers = {}
payload = self.stringfy_items(payload)
if payload == "":
payload64 = payload
else:
payload64 = self.calculate_safe_base64(json.dumps(payload))
protected = self.get_acme_header(url)
protected64 = self.calculate_safe_base64(json.dumps(protected))
signature = self.sign_message(
message="{0}.{1}".format(protected64, payload64)) # bytes
signature64 = self.calculate_safe_base64(signature) # str
data = json.dumps(
{"protected": protected64, "payload": payload64,
"signature": signature64}
)
headers.update({"Content-Type": "application/jose+json"})
response = requests.post(url, data=data.encode("utf8"), headers=headers)
# 更新随机数
self.update_replay_nonce(response)
return response
# 更新随机数
def update_replay_nonce(self, res):
replay_nonce = res.headers.get('Replay-Nonce')
if replay_nonce:
self._replay_nonce = replay_nonce
def stringfy_items(self, payload):
if isinstance(payload, str):
return payload
for k, v in payload.items():
if isinstance(k, bytes):
k = k.decode("utf-8")
if isinstance(v, bytes):
v = v.decode("utf-8")
payload[k] = v
return payload
# 转为无填充的Base64
def calculate_safe_base64(self, un_encoded_data):
if sys.version_info[0] == 3:
if isinstance(un_encoded_data, str):
un_encoded_data = un_encoded_data.encode("utf8")
r = base64.urlsafe_b64encode(un_encoded_data).rstrip(b"=")
return r.decode("utf8")
# 获请ACME请求头
def get_acme_header(self, url):
header = {"alg": "RS256", "nonce": self.get_nonce(), "url": url}
if url in [self._apis['newAccount'], 'GET_THUMBPRINT']:
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
private_key = serialization.load_pem_private_key(
self.get_account_key().encode(),
password=None,
backend=default_backend(),
)
public_key_public_numbers = private_key.public_key().public_numbers()
exponent = "{0:x}".format(public_key_public_numbers.e)
exponent = "0{0}".format(exponent) if len(
exponent) % 2 else exponent
modulus = "{0:x}".format(public_key_public_numbers.n)
jwk = {
"kty": "RSA",
"e": self.calculate_safe_base64(binascii.unhexlify(exponent)),
"n": self.calculate_safe_base64(binascii.unhexlify(modulus)),
}
header["jwk"] = jwk
else:
header["kid"] = self.get_kid()
return header
def get_nonce(self, force=False):
# 如果没有保存上一次的随机数或force=True时则重新获取新的随机数
if not self._replay_nonce or force:
response = requests.get(
self._apis['newNonce'],
)
self._replay_nonce = response.headers["Replay-Nonce"]
return self._replay_nonce
def analysis_private_key(self, key_pem, password=None):
"""
解析私钥
:param key_pem: 私钥内容
:param password: 私钥密码
:return: 私钥对象
"""
try:
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
private_key = serialization.load_pem_private_key(
key_pem.encode(),
password=password,
backend=default_backend()
)
return private_key
except:
return None
def sign_message(self, message):
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
pk = self.analysis_private_key(self.get_account_key())
return pk.sign(message.encode("utf8"), padding.PKCS1v15(), hashes.SHA256())
# 获用户取密钥对
def get_account_key(self):
if not 'account' in self._config:
self._config['account'] = {}
k = "Production"
if not k in self._config['account']:
self._config['account'][k] = {}
if not 'key' in self._config['account'][k]:
self._config['account'][k]['key'] = self.create_key()
if type(self._config['account'][k]['key']) == bytes:
self._config['account'][k]['key'] = self._config['account'][k]['key'].decode()
self.save_config()
return self._config['account'][k]['key']
def create_key(self, key_type='RSA'):
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa, ec, ed25519
if key_type == 'RSA':
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=self._bits
)
elif key_type == 'EC':
private_key = ec.generate_private_key(ec.SECP256R1())
elif key_type == 'ED25519':
private_key = ed25519.Ed25519PrivateKey.generate()
else:
raise ValueError(f"Unsupported key type: {key_type}")
private_key_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
)
return private_key_pem
def get_kid(self, force=False):
#如果配置文件中不存在kid或force = True时则重新注册新的acme帐户
if not 'account' in self._config:
self._config['account'] = {}
k = "Production"
if not k in self._config['account']:
self._config['account'][k] = {}
if not 'kid' in self._config['account'][k]:
self._config['account'][k]['kid'] = self.register()
self.save_config()
time.sleep(3)
self._config = self.read_config()
return self._config['account'][k]['kid']
# 读配置文件
def read_config(self):
if not os.path.exists(self._conf_file_v2):
self._config = {'orders': {}, 'account': {}, 'apis': {}, 'email': None}
self.save_config()
return self._config
with open(self._conf_file_v2, 'r') as f:
fcntl.flock(f, fcntl.LOCK_SH) # 加锁
tmp_config = f.read()
fcntl.flock(f, fcntl.LOCK_UN) # 解锁
f.close()
if not tmp_config:
return self._config
try:
self._config = json.loads(tmp_config)
except:
self.save_config()
return self._config
return self._config
# 写配置文件
def save_config(self):
fp = open(self._conf_file_v2, 'w+')
fcntl.flock(fp, fcntl.LOCK_EX) # 加锁
fp.write(json.dumps(self._config))
fcntl.flock(fp, fcntl.LOCK_UN) # 解锁
fp.close()
return True
# 注册acme帐户
def register(self, existing=False):
if not 'email' in self._config:
self._config['email'] = 'demo@aapanel.com'
if existing:
payload = {"onlyReturnExisting": True}
elif self._config['email']:
payload = {
"termsOfServiceAgreed": True,
"contact": ["mailto:{0}".format(self._config['email'])],
}
else:
payload = {"termsOfServiceAgreed": True}
res = self.acme_request(url=self._apis['newAccount'], payload=payload)
if res.status_code not in [201, 200, 409]:
raise Exception("Failed to register ACME account: {}".format(res.json()))
kid = res.headers["Location"]
return kid
def create_csr(self, ips):
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import serialization
import ipaddress
# 生成私钥
pk = self.create_key()
private_key = serialization.load_pem_private_key(pk, password=None)
# IP证书不需要CN
csr_builder = x509.CertificateSigningRequestBuilder().subject_name(x509.Name([]))
# 添加 subjectAltName 扩展
alt_names = [x509.IPAddress(ipaddress.ip_address(ip)) for ip in ips]
csr_builder = csr_builder.add_extension(
x509.SubjectAlternativeName(alt_names),
critical=False
)
# 签署 CSR
csr = csr_builder.sign(private_key, hashes.SHA256())
# 返回 CSR (ASN1 格式)
return csr.public_bytes(serialization.Encoding.DER), pk
def apply_ip_ssl(self, ips, email, webroot=None, mode=None, path=None):
print("Starting to apply for Let's Encrypt IP SSL certificate...")
print("Retrieving ACME API directory...")
self.get_apis()
self._config['email'] = email
print("Creating order...")
order_data = self.create_order(ips)
if not order_data:
raise Exception("Failed to create order!")
print("Order created successfully")
print("Performing domain verification...")
try:
self.get_and_set_authorizations(order_data, webroot, mode, ips)
except Exception as e:
raise Exception("Domain verification failed! {}".format(e))
# 完成订单
print("Creating CSR...")
csr, private_key = self.create_csr(ips)
print("Sending CSR and completing order...")
res = self.acme_request(order_data['finalize'], payload={
"csr": self.calculate_safe_base64(csr)
})
if res.status_code not in [200, 201]:
raise Exception("Failed to complete order! {}".format(res.json()))
# 获取证书
print("Retrieving certificate...")
cert_url = res.json().get('certificate')
if not cert_url:
raise Exception("Failed to retrieve certificate URL!")
cert_res = self.acme_request(cert_url, payload="")
if cert_res.status_code not in [200, 201]:
raise Exception("Failed to retrieve certificate! {}".format(cert_res.json()))
print("Certificate retrieved successfully!")
cert_pem = cert_res.content.decode()
# 保存证书和私钥
if not path:
path = "/www/server/panel/ssl"
if not os.path.exists(path):
os.makedirs(path)
cert_path = os.path.join(path, 'certificate.pem')
key_path = os.path.join(path, 'privateKey.pem')
with open(cert_path, 'w') as f:
f.write(cert_pem)
with open(key_path, 'w') as f:
f.write(private_key.decode())
return cert_path, key_path
def create_order(self, ips):
identifiers = []
for ip in ips:
identifiers.append({"type": "ip", "value": ip})
payload = {"identifiers": identifiers, "profile": "shortlived"}
print("Create order, domain name list:{}".format(','.join(ips)))
res = self.acme_request(self._apis['newOrder'], payload)
if not res.status_code in [201,200]: # 如果创建失败
print("Failed to create order, attempting to fix error...")
e_body = res.json()
if 'type' in e_body:
# 如果随机数失效
if e_body['type'].find('error:badNonce') != -1:
print("Nonce invalid, retrieving new nonce and retrying...")
self.get_nonce(force=True)
res = self.acme_request(self._apis['newOrder'], payload)
# 如果帐户失效
if e_body['detail'].find('KeyID header contained an invalid account URL') != -1:
print("Account invalid, re-registering account and retrying...")
k = "Production"
del(self._config['account'][k])
self.get_kid()
self.get_nonce(force=True)
res = self.acme_request(self._apis['newOrder'], payload)
if not res.status_code in [201,200]:
print(res.json())
# 2025/12/25 aapanel
raise Exception(str(res.json()))
# return {}
return res.json()
# UTC时间转时间戳
def utc_to_time(self, utc_string):
try:
utc_string = utc_string.split('.')[0]
utc_date = datetime.datetime.strptime(
utc_string, "%Y-%m-%dT%H:%M:%S")
# 按北京时间返回
return int(time.mktime(utc_date.timetuple())) + (3600 * 8)
except:
return int(time.time() + 86400 * 7)
def get_keyauthorization(self, token):
acme_header_jwk_json = json.dumps(
self.get_acme_header("GET_THUMBPRINT")["jwk"], sort_keys=True, separators=(",", ":")
)
acme_thumbprint = self.calculate_safe_base64(
hashlib.sha256(acme_header_jwk_json.encode("utf8")).digest()
)
acme_keyauthorization = "{0}.{1}".format(token, acme_thumbprint)
base64_of_acme_keyauthorization = self.calculate_safe_base64(
hashlib.sha256(acme_keyauthorization.encode("utf8")).digest()
)
return acme_keyauthorization, base64_of_acme_keyauthorization
# 获取并设置验证信息
def get_and_set_authorizations(self, order_data, webroot=None, mode=None, ips=None):
import os
if 'authorizations' not in order_data:
raise Exception("Abnormal order data, missing authorization information!")
for auth_url in order_data['authorizations']:
res = self.acme_request(auth_url, payload="")
if not res.status_code in [200, 201]:
raise Exception("Failed to get authorization information! {}".format(res.json()))
s_body = res.json()
if 'status' in s_body:
if s_body['status'] in ['invalid']:
raise Exception("Invalid order, current order status is verification failed!")
if s_body['status'] in ['valid']: # 跳过无需验证的域名
continue
for challenge in s_body['challenges']:
if challenge['type'] == "http-01":
break
if challenge['type'] != "http-01":
raise Exception("http-01 verification method not found, cannot continue applying for certificate!")
# 检查是否需要验证
check_auth_data = self.check_auth_status(challenge['url'])
if check_auth_data.json()['status'] == 'invalid':
raise Exception('Domain verification failed, please try applying again!')
if check_auth_data.json()['status'] == 'valid':
continue
acme_keyauthorization, auth_value = self.get_keyauthorization(
challenge['token'])
print(challenge)
if mode:
if mode == 'standalone':
from http.server import HTTPServer, SimpleHTTPRequestHandler
import threading
import os
class ACMERequestHandler(SimpleHTTPRequestHandler):
def log_message(self, format, *args):
# 屏蔽默认的请求日志输出
return
def do_GET(self):
if self.path == '/.well-known/acme-challenge/{}'.format(challenge['token']):
self.send_response(200)
self.send_header('Content-type', 'text/plain')
self.end_headers()
self.wfile.write(acme_keyauthorization.encode())
else:
self.send_response(404)
self.end_headers()
server_address = ('', 80)
httpd = HTTPServer(server_address, ACMERequestHandler)
def start_server():
httpd.serve_forever()
server_thread = threading.Thread(target=start_server)
server_thread.daemon = True
server_thread.start()
# 2025/12/26 aapanel
time.sleep(2) # 等待服务器启动
try:
# ========================= 校验服务 ==================================
server_started = False
challenge_local = f"http://127.0.0.1/.well-known/acme-challenge/{challenge['token']}"
for i in range(10): # 最大尝试时间5秒
try:
# 临时检查80 打印
with socket.create_connection(("127.0.0.1", 80), timeout=0.2):
print("Temporary server started on port 80.")
response = requests.get(challenge_local, timeout=0.5)
if response.status_code == 200 and response.text == acme_keyauthorization:
print("Temporary server started and responding correctly challenge token on port 80.")
server_started = True
break
else:
print("Temporary server response incorrect, retrying...")
time.sleep(0.5)
except Exception:
time.sleep(0.5)
if not server_started:
# raise Exception("Failed to start temporary HTTP server on port 80 in 5 seconds.")
print("Failed to start temporary HTTP server on port 80 in 5 seconds.")
# ========================= 通知ACME服务器进行验证 ===============================
self.acme_request(challenge['url'], payload={"keyAuthorization": "{0}".format(acme_keyauthorization)})
self.check_auth_status(challenge['url'], [
'valid', 'invalid'])
finally:
httpd.shutdown()
server_thread.join()
elif mode == 'nginx':
tmp_path = '/www/server/panel/vhost/nginx/tmp_apply_ip_ssl.conf'
files = find_nginx_files_by_servername(ips[0])
if not files:
print("No related Nginx configuration files found, attempting to create temporary configuration file...")
if not os.path.exists('/www/server/panel/vhost/nginx'):
raise Exception("No Nginx configuration files found, and Nginx configuration directory does not exist!")
# 如果没有找到相关配置文件,则创建一个临时配置文件
with open(tmp_path, 'w') as f:
f.write("""server
{{
listen 80;
server_name {0};
location /.well-known/acme-challenge/{1} {{
default_type text/plain;
return 200 "{2}";
}}
}}
""".format(ips[0], challenge['token'], acme_keyauthorization))
try:
for file in files:
print("Modifying Nginx configuration file: {}".format(file))
insert_location_into_server(file, ips[0], verify_file=challenge['token'], verify_content=acme_keyauthorization)
# 重新加载Nginx配置
subprocess.run(["nginx", "-t"], check=True)
subprocess.run(["nginx", "-s", "reload"], check=True)
# 通知ACME服务器进行验证
self.acme_request(challenge['url'],
payload={"keyAuthorization": "{0}".format(acme_keyauthorization)})
self.check_auth_status(challenge['url'], [
'valid', 'invalid'])
finally:
for file in files:
print("Restoring Nginx configuration file: {}".format(file))
# 恢复备份文件
backup_file = file + ".bak"
if os.path.exists(backup_file):
shutil.move(backup_file, file)
if not files:
print("Deleting temporary Nginx configuration file...")
# 删除临时配置文件
os.remove(tmp_path)
# 重新加载Nginx配置
subprocess.run(["nginx", "-t"], check=True)
subprocess.run(["nginx", "-s", "reload"], check=True)
elif mode == 'apache':
tmp_path = '/www/server/panel/vhost/apache/tmp_apply_ip_ssl.conf'
files = find_apache_conf_files(ips[0])
if not files:
print("No related Apache configuration files found, attempting to create temporary configuration file...")
if not os.path.exists('/www/server/panel/vhost/apache'):
raise Exception("No Apache configuration files found, and Apache configuration directory does not exist!")
# 如果没有找到相关配置文件,则创建一个临时配置文件
with open(tmp_path, 'w') as f:
f.write("""<VirtualHost *:80>
ServerName {0}
<Location /.well-known/acme-challenge/{1}>
Require all granted
Header set Content-Type "text/plain"
</Location>
Alias /.well-known/acme-challenge/{1} /tmp/{1}
</VirtualHost>
""".format(ips[0], challenge['token']))
try:
for file in files:
print("Modifying Apache configuration file: {}".format(file))
insert_location_into_vhost(file, ips[0], verify_file=challenge['token'])
# 写入验证文件
with open('/tmp/{}'.format(challenge['token']), 'w') as f:
f.write(acme_keyauthorization)
# 重新加载Apache配置
subprocess.run(["/etc/init.d/httpd", "reload"], check=True)
# 通知ACME服务器进行验证
self.acme_request(challenge['url'],
payload={"keyAuthorization": "{0}".format(acme_keyauthorization)})
self.check_auth_status(challenge['url'], [
'valid', 'invalid'])
finally:
for file in files:
print("Restoring Apache configuration file: {}".format(file))
# 恢复备份文件
backup_file = file + ".bak"
if os.path.exists(backup_file):
shutil.move(backup_file, file)
if not files:
print("Deleting temporary Apache configuration file...")
# 删除临时配置文件
os.remove(tmp_path)
# 重新加载Apache配置
subprocess.run(["systemctl", "restart", "httpd"], check=True)
else:
# 使用webroot方式验证
challenge_path = os.path.join(
webroot, '.well-known', 'acme-challenge')
if not os.path.exists(challenge_path):
os.makedirs(challenge_path)
file_path = os.path.join(challenge_path, challenge['token'])
with open(file_path, 'w') as f:
f.write(acme_keyauthorization)
try:
# 通知ACME服务器进行验证
self.acme_request(challenge['url'], payload={"keyAuthorization": "{0}".format(acme_keyauthorization)})
self.check_auth_status(challenge['url'], [
'valid', 'invalid'])
finally:
os.remove(file_path)
# 检查验证状态
def check_auth_status(self, url, desired_status=None):
desired_status = desired_status or ["pending", "valid", "invalid"]
number_of_checks = 0
authorization_status = "pending"
while True:
print("|- {} checking verification result...".format(number_of_checks + 1))
if desired_status == ['valid', 'invalid']:
time.sleep(self._wait_time)
check_authorization_status_response = self.acme_request(url, "")
a_auth = check_authorization_status_response.json()
if not isinstance(a_auth, dict):
continue
authorization_status = a_auth["status"]
number_of_checks += 1
if authorization_status in desired_status:
if authorization_status == "invalid":
try:
if 'error' in a_auth['challenges'][0]:
ret_title = a_auth['challenges'][0]['error']['detail']
elif 'error' in a_auth['challenges'][1]:
ret_title = a_auth['challenges'][1]['error']['detail']
elif 'error' in a_auth['challenges'][2]:
ret_title = a_auth['challenges'][2]['error']['detail']
else:
ret_title = str(a_auth)
except:
ret_title = str(a_auth)
raise StopIteration(
"{0} >>>> {1}".format(
ret_title,
json.dumps(a_auth)
)
)
break
if number_of_checks == self._max_check_num:
raise StopIteration(
"Error: Verification attempted {0} times. Maximum verification attempts: {1}. Verification interval: {2} seconds.".format(
number_of_checks,
self._max_check_num,
self._wait_time
)
)
print("|-Verification result: {}".format(authorization_status))
return check_authorization_status_response
if __name__ == '__main__':
import argparse
# 解析命令行参数
parser = argparse.ArgumentParser(description='Auto-apply IP SSL certificate script')
parser.add_argument('-ips', type=str, required=True, help='IP addresses to apply SSL certificate for', dest='ips')
parser.add_argument('-email', type=str, required=False, help='Email for SSL certificate application', dest='email')
parser.add_argument('-w', type=str, help='Website root directory', dest='webroot')
parser.add_argument('--standalone', help='Apply certificate using standalone mode', dest='standalone', action='store_true')
parser.add_argument('--nginx', help='Apply certificate using nginx mode', dest='nginx', action='store_true')
parser.add_argument('--apache', help='Apply certificate using apache mode', dest='apache', action='store_true')
parser.add_argument('-path', type=str, help='Certificate save path', dest='path')
args = parser.parse_args()
if not args.standalone and not args.webroot and not args.nginx and not args.apache:
print("No verification mode detected, will attempt to auto-select verification mode!")
# 自动选择验证模式
# 判断80端口是否被占用
use_80 = False
result = subprocess.run(
["lsof", "-i:80"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
if result.stdout:
result = subprocess.run(
["netstat", "-lntup", "|", "grep", "80"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
if result.stdout:
use_80 = True
if use_80:
print("It is detected that port 80 is occupied, try to use Nginx or Apache mode to verify...")
# 检查是否安装Nginx
if os.path.exists('/www/server/nginx/sbin/nginx'):
args.nginx = True
print("Selected Nginx mode for verification...")
elif os.path.exists('/www/server/apache/bin/httpd'):
args.apache = True
print("Selected Apache mode for verification...")
else:
print("[ERROR] Nginx or Apache installation not detected, cannot use Nginx or Apache mode for verification! Please release port 80 and try again.")
exit(1)
else:
args.standalone = True
print("Port 80 not occupied, selected standalone mode for verification...")
if not args.email:
# 使用默认邮箱
email = "demo@aapanel.com"
else:
email = args.email
ips = args.ips.split(',')
# 先只支持单个IP申请
if len(ips) > 1 and not args.standalone:
print("[ERROR] Multiple IP SSL certificate application not supported in non-standalone mode!")
exit(1)
# 先只支持IPv4
if not is_ipv4(ips[0]):
print("[ERROR] Only IPv4 addresses are supported for SSL certificate application at this time!")
exit(1)
auto_ssl = AutoApplyIPSSL()
mode = None
if args.standalone:
mode = 'standalone'
elif args.nginx:
mode = 'nginx'
elif args.apache:
mode = 'apache'
try:
cert_path, key_path = auto_ssl.apply_ip_ssl(ips, email, webroot=args.webroot, mode=mode, path=args.path)
except Exception as e:
# 2025/12/25 aapanel
print(f"[ERROR] Certificate application failed! Error details: {e}", file=sys.stderr)
exit(1)
exit(0)
+254
View File
@@ -0,0 +1,254 @@
# encoding utf-8
import subprocess
import requests
import os
import logging
import tempfile
class UpdateSiteTotal:
# 类级别的常量定义,集中管理固定配置
DEFAULT_USER_AGENT = "BT-Panel/10.0"
INSTALL_SCRIPT_TIMEOUT = 600 # 安装脚本超时时间(秒)
VERSION_CHECK_TIMEOUT = 10 # 版本检查超时时间(秒)
SCRIPT_DOWNLOAD_TIMEOUT = 15 # 脚本下载超时时间(秒)
site_total_path = '/www/server/site_total'
download_url = 'https://node.aapanel.com/site_total/'
version_url = download_url + 'version.txt'
install_sh = download_url + 'install.sh'
site_total_bin = os.path.join(site_total_path, 'site_total')
def __init__(self):
"""初始化更新工具
"""
# 初始化日志配置
self._init_logger()
def _init_logger(self):
"""初始化日志配置(封装为独立方法,便于维护)"""
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
# ------------------------------
# 版本相关方法
# ------------------------------
def get_current_version(self):
"""获取当前安装的版本号"""
if not self.is_installed():
self._log_warning(f"{self.site_total_bin} is not detected, so the current version cannot be obtained")
return None
try:
# 执行版本命令
result = subprocess.run(
[self.site_total_bin, 'version'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=True,
timeout=self.VERSION_CHECK_TIMEOUT
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, Exception) as e:
return self._handle_version_cmd_error(e)
# 解析版本号(调用辅助方法)
return self._parse_version_output(result)
def _parse_version_output(self, result):
"""解析版本命令的输出结果,提取版本号"""
# 合并stdout和stderr,避免版本信息输出到错误流
output_lines = result.stdout.splitlines() + result.stderr.splitlines()
for line in output_lines:
if "Version:" in line:
# 提取版本号并格式化(确保x.y格式)
raw_version = line.split(":")[-1].strip()
return self._format_version(raw_version)
self._log_warning(f"The version number cannot be parsed from the output: {output_lines}")
return None
def _format_version(self, raw_version):
"""将原始版本号格式化为x.y的字符串格式"""
try:
float_version = float(raw_version)
return f"{float_version:.1f}"
except ValueError:
self._log_error(f"The version number format is abnormal. It's the original value: {raw_version}")
return None
def _handle_version_cmd_error(self, error):
"""处理版本命令执行中的异常"""
if isinstance(error, subprocess.CalledProcessError):
self._log_error(f"The version command failed to execute: {error.stderr.strip()}")
elif isinstance(error, subprocess.TimeoutExpired):
self._log_error("The version command has timed out")
else:
self._log_error(f"An error occurred when obtaining the version number: {str(error)}")
return None
def get_latest_version(self):
"""获取最新版本号(返回字符串格式)"""
try:
response = requests.get(
self.version_url,
timeout=self.VERSION_CHECK_TIMEOUT,
headers={"User-Agent": self.DEFAULT_USER_AGENT}
)
response.raise_for_status()
latest_version = response.text.strip()
if not latest_version:
self._log_warning("The latest version number obtained is empty")
return None
return latest_version
except requests.RequestException as e:
self._log_error(f"Failed to get the latest version: {str(e)}")
return None
def check_update_available(self):
"""检查是否有可用更新(基于float格式版本号比较)"""
current_version_str = self.get_current_version()
latest_version_str = self.get_latest_version()
# 检查版本号获取结果
if not current_version_str:
return False, "The current version cannot be obtained"
if not latest_version_str:
return False, "The latest version cannot be obtained"
# 版本号比较
try:
current_version = float(current_version_str)
latest_version = float(latest_version_str)
except ValueError as e:
error_msg = f"Version number format error (it should be x.y): {str(e)}"
self._log_error(f"{error_msg}(Current: {current_version_str}, Latest: {latest_version_str})")
return False, error_msg
if latest_version > current_version:
return True, f"Current version: {current_version_str}, The latest version: {latest_version_str}"
else:
return False, f"Current version: {current_version_str}, The latest version: {latest_version_str}"
# ------------------------------
# 安装/更新相关方法
# ------------------------------
def is_installed(self):
"""检查site_total是否已安装且可执行"""
return os.path.exists(self.site_total_bin) and os.access(self.site_total_bin, os.X_OK)
def install_or_update(self):
"""执行安装或更新操作"""
# 下载安装脚本
install_script = self._download_install_script()
if not install_script:
return False, "The download and installation script failed"
# 执行安装脚本
return self._execute_install_script(install_script)
def _download_install_script(self):
"""下载安装脚本"""
try:
response = requests.get(
self.install_sh,
timeout=self.SCRIPT_DOWNLOAD_TIMEOUT,
headers={"User-Agent": self.DEFAULT_USER_AGENT}
)
response.raise_for_status()
return response.text
except requests.RequestException as e:
self._log_error(f"The download and installation script failed: {str(e)}")
return None
def _execute_install_script(self, script_content):
"""执行安装脚本"""
temp_script = None
try:
# 创建临时脚本文件
temp_script = self._create_temp_script(script_content)
# 执行脚本
result = subprocess.run(
["bash", temp_script],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=self.INSTALL_SCRIPT_TIMEOUT
)
# 检查执行结果
if result.returncode != 0:
error_msg = (f"The installation script failed to execute (return code: {result.returncode})\n"
f"Error output: {result.stderr.strip()[:500]}")
self._log_error(error_msg)
return False, error_msg
if not self.is_installed():
return False, "The installation is complete but the executable file was not found"
return True, "Installation/update successful"
except subprocess.TimeoutExpired:
return False, "The installation script has timed out"
except Exception as e:
return False, f"An error occurred during the installation process: {str(e)}"
finally:
# 清理临时文件
self._cleanup_temp_script(temp_script)
def _create_temp_script(self, content):
"""创建临时脚本文件(辅助方法,封装文件操作)"""
with tempfile.NamedTemporaryFile(
mode='w',
suffix='.sh',
delete=False,
dir='/tmp'
) as f:
f.write(content)
return f.name
def _cleanup_temp_script(self, script_path):
"""清理临时脚本文件(辅助方法,确保资源释放)"""
if script_path and os.path.exists(script_path):
try:
os.remove(script_path)
except OSError as e:
self._log_warning(f"Temporary files cannot be deleted {script_path}: {str(e)}")
# ------------------------------
# 主逻辑与工具方法
# ------------------------------
def update_if_needed(self):
"""有更新时执行更新,未安装时执行安装(主逻辑入口)"""
if not self.is_installed():
self._log_info("Installation not detected. Start the installation")
return self.install_or_update()
has_update, msg = self.check_update_available()
if not has_update:
return False, f"No update required: {msg}"
self._log_info(f"Update detected: {msg},Start implementing the update")
return self.install_or_update()
# 日志工具方法(封装日志调用,便于统一管理)
def _log_info(self, msg):
logging.info(msg)
def _log_warning(self, msg):
logging.warning(msg)
def _log_error(self, msg):
logging.error(msg)
if __name__ == "__main__":
updater = UpdateSiteTotal()
success, message = updater.update_if_needed()
logging.info(f"Operation result: {'Success' if success else 'Failure'} - {message}")
+25 -1
View File
@@ -50,6 +50,30 @@ if [ -f "/etc/init.d/php-fpm-74" ];then
/etc/init.d/php-fpm-74 reload
fi
if [ -f "/etc/init.d/php-fpm-80" ];then
/etc/init.d/php-fpm-80 reload
fi
if [ -f "/etc/init.d/php-fpm-81" ];then
/etc/init.d/php-fpm-81 reload
fi
if [ -f "/etc/init.d/php-fpm-82" ];then
/etc/init.d/php-fpm-82 reload
fi
if [ -f "/etc/init.d/php-fpm-83" ];then
/etc/init.d/php-fpm-83 reload
fi
if [ -f "/etc/init.d/php-fpm-84" ];then
/etc/init.d/php-fpm-84 reload
fi
if [ -f "/etc/init.d/php-fpm-85" ];then
/etc/init.d/php-fpm-85 reload
fi
if [ -f "/etc/init.d/mysqld" ];then
/etc/init.d/mysqld reload
fi
@@ -73,4 +97,4 @@ sleep 2
sync
echo 3 > /proc/sys/vm/drop_caches
echo '----------------------------------------------------------------------------'
echo '----------------------------------------------------------------------------'
+509 -172
View File
@@ -1,65 +1,319 @@
# coding: utf-8
import json
import os
import socket
import subprocess
import sys
import time
from functools import wraps
from typing import Optional, Dict
from functools import wraps, partial
from typing import Optional, Dict, Callable
import fcntl
os.chdir("/www/server/panel")
sys.path.insert(0, "class/")
sys.path.insert(0, "class_v2/")
from public import (
WriteLog,
get_setup_path,
get_panel_path,
readFile,
writeFile,
get_url,
)
from public import readFile
SETUP_PATH = get_setup_path()
SETUP_PATH = "/www/server"
DATA_PATH = os.path.join(SETUP_PATH, "panel/data")
PLUGINS_PATH = os.path.join(SETUP_PATH, "panel/plugin")
DAEMON_SERVICE = os.path.join(DATA_PATH, "daemon_service.pl")
DAEMON_SERVICE_LOCK = os.path.join(DATA_PATH, "daemon_service_lock.pl")
DAEMON_RESTART_RECORD = os.path.join(DATA_PATH, "daemon_restart_record.pl")
MANUAL_FLAG = os.path.join(get_panel_path(), "data/mod_push_data", "manual_flag.pl")
MANUAL_FLAG = os.path.join(SETUP_PATH, "panel/data/mod_push_data", "manual_flag.pl")
def run_command(cmd, timeout=5) -> str:
try:
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=timeout,
check=False
)
output = result.stdout.strip()
return output if output else ""
except subprocess.TimeoutExpired as t:
write_logs(f"Command execution timed out: {cmd}, error: {t}")
return ""
except Exception as e:
write_logs(f"Command execution failed: {cmd}, error: {e}")
return ""
def service_shop_name(services_name: str) -> str:
# 对应商店名字
shop_name = {
"postfix": "mail_sys",
"pgsql": "pgsql_manager",
"pure-ftpd": "pureftpd",
"php-fpm-52": "php-5.2",
"php-fpm-53": "php-5.3",
"php-fpm-54": "php-5.4",
"php-fpm-55": "php-5.5",
"php-fpm-56": "php-5.6",
"php-fpm-70": "php-7.0",
"php-fpm-71": "php-7.1",
"php-fpm-72": "php-7.2",
"php-fpm-73": "php-7.3",
"php-fpm-74": "php-7.4",
"php-fpm-80": "php-8.0",
"php-fpm-81": "php-8.1",
"php-fpm-82": "php-8.2",
"php-fpm-83": "php-8.3",
"php-fpm-84": "php-8.4",
"php-fpm-85": "php-8.5",
}
return shop_name.get(services_name, services_name)
def pretty_title(services_name: str) -> str:
title = {
"apache": "Apache",
"nginx": "Nginx",
"openlitespeed": "OpenLiteSpeed",
"redis": "Redis",
"mysql": "MySQL/MariaDB",
"mongodb": "MongoDB",
"pgsql": "PostgreSQL",
"pure-ftpd": "Pure-FTPd",
"memcached": "Memcached",
"ssh": "SSH",
"postfix": "Postfix",
"pdns": "PowerDNS",
# PHP-FPM
"php-fpm-52": "PHP 5.2 FPM",
"php-fpm-53": "PHP 5.3 FPM",
"php-fpm-54": "PHP 5.4 FPM",
"php-fpm-55": "PHP 5.5 FPM",
"php-fpm-56": "PHP 5.6 FPM",
"php-fpm-70": "PHP 7.0 FPM",
"php-fpm-71": "PHP 7.1 FPM",
"php-fpm-72": "PHP 7.2 FPM",
"php-fpm-73": "PHP 7.3 FPM",
"php-fpm-74": "PHP 7.4 FPM",
"php-fpm-80": "PHP 8.0 FPM",
"php-fpm-81": "PHP 8.1 FPM",
"php-fpm-82": "PHP 8.2 FPM",
"php-fpm-83": "PHP 8.3 FPM",
"php-fpm-84": "PHP 8.4 FPM",
"php-fpm-85": "PHP 8.5 FPM",
# plugins
"btwaf": "aaPanel WAF",
"fail2ban": "Fail2Ban",
}
return title.get(services_name, services_name)
# =======================================================
def ssh_ver() -> str:
version_info = run_command(["ssh", "-V"])
if version_info and "OpenSSH" in version_info:
return version_info.split(",")[0]
return ""
def postfix_ver() -> str:
# mail_version = x.x.x
output = run_command(["/usr/sbin/postconf", "mail_version"])
if output and "=" in output:
return output.split("=")[-1].strip()
return ""
def pgsql_pid() -> str:
pid_str = run_command(["pgrep", "-o", "postgres"])
if pid_str and pid_str.isdigit():
return pid_str
return ""
def pgsql_ver() -> str:
# psql (PostgreSQL) 18.0
output = run_command([f"{SETUP_PATH}/pgsql/bin/psql", "--version"])
if output:
from re import search
match = search(r"(\d+\.\d+(\.\d+)?)", output)
if match:
return match.group(1)
org_file = f"{SETUP_PATH}/pgsql/data/PG_VERSION",
if os.path.exists(str(org_file)):
try:
with open(str(org_file), "r") as f:
version = f.read().strip()
return version
except:
return ""
return ""
def pdns_pid() -> str:
pid_str = run_command(["pgrep", "-x", "pdns_server"])
if pid_str and pid_str.isdigit():
return pid_str
return ""
def pdns_ver() -> str:
# PowerDNS Authoritative Server x.x.x
output = run_command(["pdns_server", "--version"])
if not output:
return ""
from re import search
match = search(r"(\d+\.\d+\.\d+)", output)
if match:
return match.group(1)
if "PowerDNS" in output:
return output.split()[-1]
return ""
def waf_pid() -> str:
pid_str = run_command(["pgrep", "-x", "BT-WAF"])
if pid_str and pid_str.isdigit():
return pid_str
return ""
def pluign_ver(plugin_name: str) -> str:
info = f"{PLUGINS_PATH}/{plugin_name}/info.json"
if not os.path.exists(info):
return ""
try:
with open(info, "r") as f:
json_data = json.load(f)
ret = json_data.get("versions", "")
return ret
except:
pass
return ""
SERVICES_MAP = {
# ========================= core base =============================
"panel": (
"BT-Panel", f"{SETUP_PATH}/panel/logs/panel.pid", f"{SETUP_PATH}/panel/init.sh"
"BT-Panel", f"{SETUP_PATH}/panel/logs/panel.pid", f"{SETUP_PATH}/panel/init.sh",
f"{SETUP_PATH}/nginx/version.pl",
),
"apache": (
"httpd", f"{SETUP_PATH}/apache/logs/httpd.pid", "/etc/init.d/httpd"
"httpd", f"{SETUP_PATH}/apache/logs/httpd.pid", "/etc/init.d/httpd",
f"{SETUP_PATH}/apache/version.pl",
),
"nginx": (
"nginx", f"{SETUP_PATH}/nginx/logs/nginx.pid", "/etc/init.d/nginx"
),
"redis": (
"redis-server", f"{SETUP_PATH}/redis/redis.pid", "/etc/init.d/redis"
),
"mysql": (
"mysqld", "/tmp/mysql.sock", "/etc/init.d/mysqld"
),
"mongodb": (
"mongod", f"{SETUP_PATH}/mongodb/log/configsvr.pid", "/etc/init.d/mongodb"
),
"pure-ftpd": (
"pure-ftpd", "/var/run/pure-ftpd.pid", "/etc/init.d/pure-ftpd"
),
"memcached": (
"memcached", "/var/run/memcached.pid", "/etc/init.d/memcached"
"nginx", f"{SETUP_PATH}/nginx/logs/nginx.pid", "/etc/init.d/nginx",
f"{SETUP_PATH}/nginx/version.pl",
),
"openlitespeed": (
"litespeed", "/tmp/lshttpd/lsphp.sock", "/usr/local/lsws/bin/lswsctrl"
)
"litespeed", "/tmp/lshttpd/lsphp.sock", "/usr/local/lsws/bin/lswsctrl",
"/usr/local/lsws/VERSION",
),
"redis": (
"redis-server", f"{SETUP_PATH}/redis/redis.pid", "/etc/init.d/redis",
f"{SETUP_PATH}/redis/version.pl",
),
"mysql": (
"mysqld", "/tmp/mysql.sock", "/etc/init.d/mysqld",
f"{SETUP_PATH}/mysql/version.pl",
),
"mongodb": (
"mongod", f"{SETUP_PATH}/mongodb/log/configsvr.pid", "/etc/init.d/mongodb",
f"{SETUP_PATH}/mongodb/version.pl",
),
"pgsql": (
"postgres", pgsql_pid, "/etc/init.d/pgsql",
pgsql_ver,
),
"pure-ftpd": (
"pure-ftpd", "/var/run/pure-ftpd.pid", "/etc/init.d/pure-ftpd",
f"{SETUP_PATH}/pure-ftpd/version.pl",
),
"memcached": (
"memcached", "/var/run/memcached.pid", "/etc/init.d/memcached",
"/usr/local/memcached/version_check.pl",
),
"ssh": (
"sshd", "/var/run/sshd.pid", "/etc/init.d/ssh",
ssh_ver,
),
"postfix": (
"master", "/var/spool/postfix/pid/master.pid", "/etc/init.d/postfix",
postfix_ver,
),
"pdns": (
# "pdns_server", pdns_pid, "/usr/sbin/pdns_server",
"pdns_server", pdns_pid, "/www/server/panel/class_v2/ssl_dnsV2/aadns.pl",
pdns_ver,
),
# ======================== PHP-FPM ============================
"php-fpm-52": ("php-fpm", f"{SETUP_PATH}/php/52/var/run/php-fpm.pid", "/etc/init.d/php-fpm-52",
f"{SETUP_PATH}/php/52/version.pl"),
"php-fpm-53": ("php-fpm", f"{SETUP_PATH}/php/53/var/run/php-fpm.pid", "/etc/init.d/php-fpm-53",
f"{SETUP_PATH}/php/53/version.pl"),
"php-fpm-54": ("php-fpm", f"{SETUP_PATH}/php/54/var/run/php-fpm.pid", "/etc/init.d/php-fpm-54",
f"{SETUP_PATH}/php/54/version.pl"),
"php-fpm-55": ("php-fpm", f"{SETUP_PATH}/php/55/var/run/php-fpm.pid", "/etc/init.d/php-fpm-55",
f"{SETUP_PATH}/php/55/version.pl"),
"php-fpm-56": ("php-fpm", f"{SETUP_PATH}/php/56/var/run/php-fpm.pid", "/etc/init.d/php-fpm-56",
f"{SETUP_PATH}/php/56/version.pl"),
"php-fpm-70": ("php-fpm", f"{SETUP_PATH}/php/70/var/run/php-fpm.pid", "/etc/init.d/php-fpm-70",
f"{SETUP_PATH}/php/70/version.pl"),
"php-fpm-71": ("php-fpm", f"{SETUP_PATH}/php/71/var/run/php-fpm.pid", "/etc/init.d/php-fpm-71",
f"{SETUP_PATH}/php/71/version.pl"),
"php-fpm-72": ("php-fpm", f"{SETUP_PATH}/php/72/var/run/php-fpm.pid", "/etc/init.d/php-fpm-72",
f"{SETUP_PATH}/php/72/version.pl"),
"php-fpm-73": ("php-fpm", f"{SETUP_PATH}/php/73/var/run/php-fpm.pid", "/etc/init.d/php-fpm-73",
f"{SETUP_PATH}/php/73/version.pl"),
"php-fpm-74": ("php-fpm", f"{SETUP_PATH}/php/74/var/run/php-fpm.pid", "/etc/init.d/php-fpm-74",
f"{SETUP_PATH}/php/74/version.pl"),
"php-fpm-80": ("php-fpm", f"{SETUP_PATH}/php/80/var/run/php-fpm.pid", "/etc/init.d/php-fpm-80",
f"{SETUP_PATH}/php/80/version.pl"),
"php-fpm-81": ("php-fpm", f"{SETUP_PATH}/php/81/var/run/php-fpm.pid", "/etc/init.d/php-fpm-81",
f"{SETUP_PATH}/php/81/version.pl"),
"php-fpm-82": ("php-fpm", f"{SETUP_PATH}/php/82/var/run/php-fpm.pid", "/etc/init.d/php-fpm-82",
f"{SETUP_PATH}/php/82/version.pl"),
"php-fpm-83": ("php-fpm", f"{SETUP_PATH}/php/83/var/run/php-fpm.pid", "/etc/init.d/php-fpm-83",
f"{SETUP_PATH}/php/83/version.pl"),
"php-fpm-84": ("php-fpm", f"{SETUP_PATH}/php/84/var/run/php-fpm.pid", "/etc/init.d/php-fpm-84",
f"{SETUP_PATH}/php/84/version_check.pl"),
"php-fpm-85": ("php-fpm", f"{SETUP_PATH}/php/85/var/run/php-fpm.pid", "/etc/init.d/php-fpm-85",
f"{SETUP_PATH}/php/85/version_check.pl"),
# ======================== plugins ============================
# nginx : btwaf
# apache: btwaf_httpd
"btwaf": (
# "BT-WAF", f"{PLUGINS_PATH}/btwaf/BT-WAF.pid", f"/etc/init.d/btwaf",
"BT-WAF", waf_pid, f"/etc/init.d/btwaf",
partial(pluign_ver, "btwaf"),
),
"fail2ban": (
"fail2ban-server", f"{PLUGINS_PATH}/fail2ban/fail2ban.pid", "/etc/init.d/fail2ban",
partial(pluign_ver, "fail2ban"),
),
}
# 日志
def write_logs(msg: str):
try:
from public import M
t = time.strftime('%Y-%m-%d %X', time.localtime())
M("logs").add("uid,username,type,log,addtime", (1, "system", "Service Daemon", msg, t))
except:
pass
# 手动干预
def manual_flag(server_name: str = None, open_: str = None) -> Optional[dict]:
if not server_name: # only read
return DaemonManager.manual_safe_read()
@@ -71,6 +325,198 @@ def manual_flag(server_name: str = None, open_: str = None) -> Optional[dict]:
return DaemonManager.manual_safe_read()
# 管理服务助手
class ServicesHelper:
def __init__(self, nick_name: str = None):
self.nick_name = nick_name
self._serviced = None
self._pid_source = None
self._bash = None
self._ver_source = None
self._pid_cache = None
self._ver_cache = None
self._install_cache = None
self._info_inited = False
def __check_pid_process(self, pid: int) -> bool:
try:
# 是否活跃
with open(f"/proc/{pid}/stat", "r") as f:
if f.read().split()[2] == "Z":
return False # 僵尸进程
# 进程是否名字匹配
with open(f"/proc/{pid}/comm", "r") as f:
proc_name = f.read().strip()
return proc_name == self.nick_name or proc_name == self._serviced
except (FileNotFoundError, IndexError):
return False
except Exception:
return False
def _init_info(self) -> None:
if self._info_inited:
return
if not self.nick_name or not isinstance(self.nick_name, str):
self._info_inited = True
return
map_info = SERVICES_MAP.get(self.nick_name)
if map_info:
self._serviced, self._pid_source, self._bash, self._ver_source = map_info
self._info_inited = True
@property
def pid(self):
"""返回pid文件路径或pid值"""
if self._pid_cache is None:
self._init_info()
if isinstance(self._pid_source, Callable):
try:
pid_val = self._pid_source()
self._pid_cache = int(pid_val) if pid_val and pid_val.isdigit() else pid_val
except Exception:
self._pid_cache = self._pid_source
else:
self._pid_cache = self._pid_source
return self._pid_cache
@property
def version(self) -> str:
"""返回服务版本号"""
if self._ver_cache is None:
self._init_info()
if isinstance(self._ver_source, Callable):
try:
self._ver_cache = self._ver_source()
except:
self._ver_cache = ""
elif isinstance(self._ver_source, str) and os.path.exists(self._ver_source):
try:
with open(self._ver_source, "r") as f:
self._ver_cache = f.read().strip()
except:
self._ver_cache = ""
else:
self._ver_cache = ""
return str(self._ver_cache).strip()
@property
def is_install(self) -> bool:
"""判断是否安装"""
if self._install_cache is not None:
return self._install_cache
self._init_info()
self._install_cache = False
# waf特殊处理
if self.nick_name == "btwaf":
if os.path.exists(f"{PLUGINS_PATH}/btwaf"):
self._install_cache = True
return self._install_cache
# postfix特殊处理
if self.nick_name == "postfix":
if os.path.exists(f"{PLUGINS_PATH}/mail_sys"):
self._install_cache = True
return self._install_cache
if any([
self._serviced and os.path.exists(f"/etc/init.d/{self._serviced}"),
self.nick_name and os.path.exists(f"/etc/init.d/{self.nick_name}"),
self._bash and os.path.exists(self._bash),
]):
self._install_cache = True
return self._install_cache
@property
def shop_name(self) -> str:
return service_shop_name(self.nick_name)
@property
def pretty_title(self) -> str:
return pretty_title(self.nick_name)
@property
def is_running(self) -> bool:
"""
判断进程是否存活
pid: 进程pid文件 str 或 int
serviced: 进程服务名称
nick_name: 进程别名
"""
if not self.is_install:
return False
if not self.pid:
return False
if isinstance(self.pid, int):
return self.__check_pid_process(self.pid)
if isinstance(self.pid, str):
if not os.path.exists(self.pid):
return False
if self.pid.endswith(".pid"):
try:
with open(self.pid, "r") as f:
temp_pid = int(f.read().strip())
return self.__check_pid_process(temp_pid)
except (ValueError, FileNotFoundError):
return False
except Exception:
return False
elif self.pid.endswith(".sock"):
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.settimeout(0.1)
s.connect(self.pid)
return True
except (socket.timeout, ConnectionRefusedError, FileNotFoundError):
return False
except Exception:
return False
# not str and not int
return False
def script(self, act: str) -> None:
if not self.is_install or act not in ["start", "stop", "restart", "status"]:
return
try:
# "try to {act} [{self.nick_name}]..."
self._init_info()
bash_path = self._bash if self._bash else f"/etc/init.d/{self._serviced}"
if isinstance(self.pid, str) and (
self.pid.endswith(".sock") or self.pid.endswith(".pid")
):
try:
os.remove(self.pid)
except:
pass
if self.nick_name == "panel":
if not os.path.exists(f"{SETUP_PATH}/panel/init.sh"):
from public import get_url
os.system(f"curl -k {get_url()}/install/update_7.x_en.sh|bash &")
cmd = ["bash", bash_path, act]
elif self.nick_name == "pdns":
cmd = ["systemctl", act, "pdns"]
else:
cmd = [bash_path, act]
result = run_command(cmd)
if not result and act in ["start", "restart"]:
write_logs(f"Failed to {act} {self.nick_name}, error: command returned no output.")
except Exception as e:
print(str(e))
write_logs(f"Failed to {act} {self.nick_name}, error: {e}")
# 守护服务管理
class DaemonManager:
@classmethod
def __ensure(cls):
@@ -231,16 +677,12 @@ class DaemonManager:
return record
# 服务守护
class RestartServices:
COUNT = 30
def __init__(self):
self.nick_name = None
self.serviced = None
self.pid_file = None
self.bash = None
def __keep_flag_right(self, manual_info: dict) -> None:
@staticmethod
def __keep_flag_right(manual_info: dict) -> None:
try:
with open(MANUAL_FLAG, "r+") as f:
try:
@@ -256,115 +698,11 @@ class RestartServices:
except Exception as e:
print("Error keep_flag_right:", e)
def _overhead(self) -> bool:
def _overhead(self, nick_name) -> bool:
return DaemonManager.update_restart_record(
self.nick_name, self.COUNT
nick_name, self.COUNT
)
def _script(self, act: str) -> None:
try:
if act not in ["start", "stop", "restart", "status"]:
return
# "try to {act} [{self.nick_name}]..."
bash_path = self.bash if self.bash else f"/etc/init.d/{self.serviced}"
if self.pid_file and self.pid_file.endswith(".sock"):
try:
os.remove(self.pid_file)
except:
pass
if self.nick_name == "panel":
if not os.path.exists(f"{SETUP_PATH}/panel/init.sh"):
os.system(f"curl -k {get_url()}/install/update_7.x_en.sh|bash &")
cmd = ["bash", bash_path, act]
else:
cmd = [bash_path, act]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
WriteLog(
"Service Daemon", f"Failed to {act} {self.nick_name}, error: {result.stderr.strip()}"
)
except subprocess.TimeoutExpired as t:
WriteLog(
"Service Daemon", f"Failed to {act} {self.nick_name}, error: time out, {t}"
)
except Exception as e:
print(str(e))
WriteLog(
"Service Daemon", f"Failed to {act} {self.nick_name}, error: {e}"
)
def is_support(self) -> bool:
try:
map_info = SERVICES_MAP.get(self.nick_name)
if not map_info:
return False
self.serviced, self.pid_file, self.bash = map_info
if not all([self.serviced, self.pid_file, self.bash]):
return False
return True
except:
return False
def is_install_service(self) -> bool:
if self.serviced == "mysqld": # mysql系列卸载根本不干净, 判断bin
if any([
os.path.exists(f"{SETUP_PATH}/mysql/bin/mariadbd"),
os.path.exists(f"{SETUP_PATH}/mysql/bin/mysqld"),
]):
return True
else:
return False
# other
if any([
os.path.exists(f"/etc/init.d/{self.serviced}"),
os.path.exists(f"/etc/init.d/{self.nick_name}"),
os.path.exists(self.bash),
]):
return True
else:
return False
def is_process_running(self) -> bool:
if not os.path.exists(self.pid_file):
return False
# sock file
if self.pid_file.endswith(".sock"):
try:
check_list = ["mysqld", "mariadbd"] if self.serviced == "mysqld" else [self.serviced]
check_list = '|'.join(check_list)
# read pid -> show process name -> grep
command = f"lsof -t {self.pid_file} 2>/dev/null | xargs -r ps -o comm= -p | grep -Ewq '{check_list}'"
return subprocess.run(command, shell=True).returncode == 0
except:
return False
else: # pid file
try:
with open(self.pid_file, "r") as f:
pid = int(f.read().strip())
# 是否活跃
with open(f"/proc/{pid}/stat", "r") as f:
if f.read().split()[2] == "Z":
return False # 僵尸进程
# 进程是否名字匹配
with open(f"/proc/{pid}/comm", "r") as f:
proc_name = f.read().strip()
if proc_name == self.nick_name or proc_name == self.serviced:
return True
return False
except:
return False
@DaemonManager.read_lock
def main(self):
manaul = readFile(MANUAL_FLAG)
@@ -381,26 +719,27 @@ class RestartServices:
for service in [
x for x in list(set(check_list)) if record.get(x, 0) < self.COUNT
]:
self.nick_name = service
if not self.is_support() or not self.is_install_service():
obj = ServicesHelper(service)
if not obj.is_install:
continue
if not self.is_process_running():
if int(manual_info.get(self.nick_name, 0)) == 1:
if not obj.is_running:
if int(manual_info.get(obj.nick_name, 0)) == 1:
# service closed maually, skip
continue
WriteLog(
"Service Daemon", f"Service [ {self.nick_name} ] is Not Running, Try to start it..."
)
if not self._overhead():
self._script("start")
time.sleep(3)
if not self.is_process_running():
if not self._overhead():
self._script("restart")
if obj.nick_name != "panel":
write_logs(f"Service [ {obj.nick_name} ] is Not Running, Try to start it...")
if manual_info.get(self.nick_name) == 1:
if not self._overhead(obj.nick_name):
obj.script("start")
time.sleep(3)
if not obj.is_running:
if not self._overhead(obj.nick_name):
obj.script("restart")
if manual_info.get(obj.nick_name) == 1:
# service is running, fix the wrong flag
manual_info[self.nick_name] = 0
manual_info[obj.nick_name] = 0
# under lock file read lock
self.__keep_flag_right(manual_info)
@@ -409,10 +748,18 @@ def first_time_installed(data: dict) -> None:
"""
首次安装服务启动守护进程服务
"""
# todo 虽然支持, 但是守护目前不干预, 前端没开放
exculde = [
"pgsql", "fail2ban", "btwaf", "ssh", "pdns", "php-fpm", "memcached"
]
if not data:
return
try:
for service in SERVICES_MAP.keys(): # support service
if service in exculde:
continue
if "php-fpm" in service:
continue
pl_name = f"{DATA_PATH}/first_installed_flag_{service}.pl"
if data.get(service): # panel installed
setup = data[service].get("setup", False)
@@ -420,7 +767,8 @@ def first_time_installed(data: dict) -> None:
os.remove(pl_name)
elif setup is True and not os.path.exists(pl_name):
DaemonManager.add_daemon(service)
writeFile(pl_name, "1", mode="w")
with open(pl_name, "w") as f:
f.write("1")
else:
pass
except:
@@ -429,14 +777,3 @@ def first_time_installed(data: dict) -> None:
if __name__ == "__main__":
pass
# import tracemalloc
# tracemalloc.start()
# snapshot1 = tracemalloc.take_snapshot()
# RestartServices().main()
# snapshot2 = tracemalloc.take_snapshot()
# top_stats = snapshot2.compare_to(snapshot1, 'lineno')
# print("[Top memory differences]")
# for stat in top_stats[:3]:
# print("stat", stat)