944 lines
38 KiB
Python
944 lines
38 KiB
Python
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@yakpanel.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 yakpanel
|
|
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 yakpanel
|
|
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@yakpanel.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 yakpanel
|
|
print(f"[ERROR] Certificate application failed! Error details: {e}", file=sys.stderr)
|
|
exit(1)
|
|
exit(0)
|
|
|
|
|