Update to v8.16.0

This commit is contained in:
aapanel.com
2026-07-06 09:16:25 +08:00
parent 88012af9cf
commit c529749cf8
2034 changed files with 122712 additions and 38986 deletions
+40 -8
View File
@@ -3354,6 +3354,8 @@ fullchain.pem Paste into certificate input box
continue
continue
write_log(f"|- Domain Subject:【{ssl.subject}】Trying to use DNS Verification for Renewal!")
# dns 续签成功会销毁旧 ssl, 后续判断用快照
ssl_snapshot = {"user_for": dict(ssl.user_for or {}), "subject": ssl.subject, "hash": ssl.hash}
try:
dns_apply = self.apply_cert_domain(
domains=ssl.dns,
@@ -3364,14 +3366,16 @@ fullchain.pem Paste into certificate input box
)
if dns_apply.get("status"):
write_log(
f"|- Domain Subject:【{ssl.subject}】DNS Verification "
f"|- Domain Subject:【{ssl_snapshot['subject']}】DNS Verification "
f"Renewal SSL certificate Successfully!"
)
# 类sub_all_cert: 面板证书续签后部署到面板SSL目录
self._deploy_panel_after_renew(self._renewed_ssl_of(dns_apply), ssl_snapshot)
else:
raise Exception(dns_apply.get("msg"))
except Exception as e:
write_log(
f"|- Domain Subject:【{ssl.subject}】DNS Verification "
f"|- Domain Subject:【{ssl_snapshot['subject']}】DNS Verification "
f"Renewal SSL certificate Failed:{str(e)}"
)
if self.http_fallback_renew(ssl):
@@ -3386,8 +3390,13 @@ fullchain.pem Paste into certificate input box
"""HTTP-01 最终兜底续签"""
if any("*." in d for d in ssl.dns):
return False
ssl_snapshot = {
"user_for": dict(ssl.user_for or {}),
"subject": ssl.subject,
"hash": ssl.hash,
}
write_log(
f"|- Domain Subject:【{ssl.subject}】Trying HTTP-01 fallback verification..."
f"|- Domain Subject:【{ssl_snapshot['subject']}】Trying HTTP-01 fallback verification..."
)
try:
from ssl_domainModelV2.service import HttpFallback
@@ -3400,22 +3409,45 @@ fullchain.pem Paste into certificate input box
)
if http_res.get("status"):
write_log(
f"|- Domain Subject:【{ssl.subject}】HTTP-01 fallback ({mode}) "
f"|- Domain Subject:【{ssl_snapshot['subject']}】HTTP-01 fallback ({mode}) "
f"renewal SSL certificate successfully!"
)
renewed_ssl = self._renewed_ssl_of(http_res)
# 仅 webroot 模式保存 auth_info(站点路径持久可用)
if mode == "webroot":
ssl.auth_info = {"auth_type": "http", "auth_to": auth_to}
ssl.save()
if mode == "webroot" and renewed_ssl:
renewed_ssl.auth_info = {"auth_type": "http", "auth_to": auth_to}
renewed_ssl.save()
# 类sub_all_cert: 面板证书续签后部署到面板SSL目录
self._deploy_panel_after_renew(renewed_ssl, ssl_snapshot)
return True
else:
raise Exception(http_res.get("msg"))
finally:
fallback.cleanup()
except Exception as e:
write_log(f"|- Domain Subject:【{ssl.subject}】HTTP-01 fallback failed: {str(e)}")
write_log(f"|- Domain Subject:【{ssl_snapshot['subject']}】HTTP-01 fallback failed: {str(e)}")
return False
def _renewed_ssl_of(self, apply_res: dict):
"""按 apply_cert_domain 返回的新证书算 hash, 定位续签后的新记录; 旧 ssl 可能已被 keep_same_dns_ssl_unique 销毁"""
from ssl_domainModelV2.model import DnsDomainSSL
from ssl_domainModelV2.service import CertHandler
new_hash = CertHandler.get_hash(
(apply_res.get("cert") or "") + (apply_res.get("root") or "")
)
return DnsDomainSSL.objects.filter(hash=new_hash).first() if new_hash else None
def _deploy_panel_after_renew(self, renewed_ssl, ssl_snapshot: dict):
"""续签成功后部署面板证书; 站点/邮件由 sub_all_cert 自动替换, 仅 panel 需显式部署"""
if ssl_snapshot["user_for"].get("panel") != ["panel"]:
return
if renewed_ssl and renewed_ssl.hash != ssl_snapshot["hash"]:
renewed_ssl.deploy_panel()
write_log(
f"|- Domain Subject:【{ssl_snapshot['subject']}】Panel SSL "
f"certificate deployed successfully!"
)
def apply_cert_domain(
self,
domains: list,
+2 -2
View File
@@ -10,7 +10,7 @@ from flask import session,request
import public,os,json,time,apache,psutil
class ajax:
__official_url = 'https://www.aapanel.com'
__official_url = public.OfficialApiBase()
def GetApacheStatus(self,get):
a = apache.apache()
@@ -886,7 +886,7 @@ class ajax:
# 下载云端php扩展配置
def _get_cloud_phplib(self):
if not session.get('download_url'): session['download_url'] = 'https://node.aapanel.com'
if not session.get('download_url'): session['download_url'] = public.OfficialDownloadBase()
download_url = session['download_url'] + '/install/lib/phplib_en.json'
tstr = public.httpGet(download_url)
data = json.loads(tstr)
+2 -2
View File
@@ -47,7 +47,7 @@ class panelSetup:
if ua.find('spider') != -1 or g.ua.find('bot') != -1:
return abort(403)
g.version = '8.10.0'
g.version = '8.16.0'
g.title = public.GetConfigValue('title')
g.uri = request.path
g.debug = os.path.exists('data/debug.pl')
@@ -136,7 +136,7 @@ class panelAdmin(panelSetup):
session['brand'] = public.GetConfigValue('brand')
session['product'] = public.GetConfigValue('product')
session['rootPath'] = '/www'
session['download_url'] = 'https://node.aapanel.com'
session['download_url'] = public.OfficialDownloadBase()
session['setupPath'] = session['rootPath'] + '/server'
session['logsPath'] = '/www/wwwlogs'
session['yaer'] = datetime.now().year
+79 -2
View File
@@ -20,6 +20,53 @@ try:
from BTPanel import session,admin_path_checks,g,request,cache
import send_mail
except:pass
_JSON_UNSAFE = object()
def _json_safe_value(value, depth=0):
if value is None or isinstance(value, (str, int, float, bool)):
return value
if depth >= 4:
return _JSON_UNSAFE
if isinstance(value, (list, tuple)):
safe_list = []
for item in value:
safe_item = _json_safe_value(item, depth + 1)
if safe_item is not _JSON_UNSAFE:
safe_list.append(safe_item)
return safe_list
if isinstance(value, dict):
safe_dict = {}
for key, item in value.items():
safe_item = _json_safe_value(item, depth + 1)
if safe_item is not _JSON_UNSAFE:
safe_dict[str(key)] = safe_item
return safe_dict
return _JSON_UNSAFE
def _get_panel_ssl_switch_session():
session_data = {}
for key, value in dict(session).items():
safe_value = _json_safe_value(value)
if safe_value is not _JSON_UNSAFE:
session_data[str(key)] = safe_value
return session_data
def _reload_panel_webserver():
try:
import webserver
return webserver.webserver().run_webserver()
except Exception as e:
try:
public.print_log('reload panel webserver failed: {}'.format(e))
except:
pass
return False
class config:
_setup_path = "/www/server/panel"
_key_file = _setup_path+"/data/two_step_auth.txt"
@@ -981,6 +1028,27 @@ class config:
if os.path.exists(sslConf) and not 'cert_type' in get:
public.ExecShell('rm -f ' + sslConf + '&& rm -f /www/server/panel/ssl/*')
g.rm_ssl = True
try:
switch_token = public.GetRandomString(48)
switch_data = {
"token": switch_token,
"expires": int(time.time()) + 180,
"client_ip": public.GetClientIp(),
"user_agent": request.headers.get('User-Agent', ''),
"session": _get_panel_ssl_switch_session()
}
public.writeFile('{}/data/panel_ssl_switch.json'.format(public.get_panel_path()), json.dumps(switch_data))
g.panel_ssl_switch_token = switch_token
except:
pass
try:
from flask import current_app
current_app.config['SSL'] = False
current_app.config['SESSION_COOKIE_SECURE'] = False
except:
pass
_reload_panel_webserver()
public.reload_panel()
return public.return_msg_gettext(True, 'SSL turned offPlease use http protocol to access the panel!')
else:
public.ExecShell('btpip install cffi')
@@ -1001,6 +1069,15 @@ class config:
except:
return public.return_msg_gettext(False,
'Error, unable to auto install pyOpenSSL!<p>Plesea try to manually install: pip install pyOpenSSL</p>')
try:
from flask import current_app
current_app.config['SSL'] = True
current_app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
current_app.config['SESSION_COOKIE_SECURE'] = True
except:
pass
_reload_panel_webserver()
public.reload_panel()
return public.return_msg_gettext(True,
'SSL is turned on, plesea use https protocol to access the panel!')
#自签证书
@@ -1041,7 +1118,7 @@ class config:
"access_key": 'B' * 32,
"panel": 1
}
cert_api = 'https://api.aapanel.com/aapanel_cert'
cert_api = f'{public.OfficialApiUrlBase()}/aapanel_cert'
result = json.loads(public.httpPost(cert_api, {'data': json.dumps(pdata)}))
if 'status' in result:
if result['status']:
@@ -3714,7 +3791,7 @@ class config:
# 提交
if not public.cache_get(pkey):
try:
public.run_thread(public.httpPost("https://geterror.aapanel.com/bt_error/index.php", error_infos))
public.run_thread(public.httpPost(f"{public.OfficialGetErrorBase()}/bt_error/index.php", error_infos))
public.cache_set(pkey, 1, 1800)
except Exception as e:
pass
+24 -2
View File
@@ -29,6 +29,23 @@ class DictCache(object):
self.data[key] = value
def _get_current_session_cookie(app):
try:
sid = getattr(session, 'sid', None)
if not sid:
return request.cookies.get(app.config['SESSION_COOKIE_NAME'], '')
if app.config.get('SESSION_USE_SIGNER'):
from itsdangerous import want_bytes
signer = app.session_interface._get_signer(app)
signed_sid = signer.sign(want_bytes(sid))
if not isinstance(signed_sid, str):
signed_sid = signed_sid.decode()
return signed_sid
return sid
except:
return request.cookies.get(app.config['SESSION_COOKIE_NAME'], '')
class Compress(object):
"""
The Compress object allows your application to use Flask-Compress.
@@ -91,19 +108,24 @@ class Compress(object):
if 'rm_ssl' in g:
import public
response.headers['Strict-Transport-Security'] = 'max-age=0'
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
try:
for k,v in request.cookies.items():
response.set_cookie(k,'',expires='Thu, 01-Jan-1970 00:00:00 GMT',path='/')
except:
pass
session_name = app.config['SESSION_COOKIE_NAME']
session_id = public.get_session_id()
session_id = _get_current_session_cookie(app)
response.set_cookie(session_name,'',expires='Thu, 01-Jan-1970 00:00:00 GMT',path='/')
response.set_cookie(session_name, session_id, path='/', max_age=86400 * 30,httponly=True)
response.set_cookie(session_name, session_id, path='/', max_age=86400 * 30,httponly=True,samesite='Lax')
request_token = request.cookies.get('request_token','')
if request_token:
response.set_cookie('request_token',request_token,path='/',max_age=86400 * 30)
switch_token = getattr(g, 'panel_ssl_switch_token', '')
if switch_token:
response.set_cookie('panel_ssl_switch', switch_token, path='/', max_age=180, httponly=True, samesite='Lax')
if (response.mimetype not in app.config['COMPRESS_MIMETYPES'] or
'gzip' not in accept_encoding.lower() or
+16 -1
View File
@@ -80,6 +80,17 @@ def control_init_new():
control_init_delay()
def install_packages():
try:
import openai
except ImportError:
public.ExecShell("btpip install pydantic==2.5.3 openai==1.39.0")
import openai
try:
import numpy as np
except ImportError:
public.ExecShell("btpip install numpy==1.21.6")
import numpy
try:
import dns.resolver
except ImportError:
@@ -352,6 +363,10 @@ def sql_pacth():
if not public.M('sqlite_master').where('type=? AND name=? AND sql LIKE ?', ('table', 'sites','%parent_id%')).count():
public.M('sites').execute("alter TABLE sites add parent_id STRING DEFAULT 0",())
# 添加备份类型字段,0为标准类型,1为全量备份
if not public.M('sqlite_master').where('type=? AND name=? AND sql LIKE ?', ('table', 'backup','%backup_type%')).count():
public.M('backup').execute("alter TABLE backup add backup_type STRING DEFAULT 0",())
if not public.M('sqlite_master').where('type=? AND name=? AND sql LIKE ?', ('table', 'backup','%ps%')).count():
public.M('backup').execute("alter TABLE backup add ps STRING DEFAULT 'No'",())
@@ -1032,7 +1047,7 @@ def update_py312():
download_url = public.get_url()
only_update_pyenv312 = '/tmp/only_update_pyenv312.pl'
public.writeFile(only_update_pyenv312, 'True')
public.ExecShell("nohup curl -k {}/install/update_7.x_en.sh|bash &>/tmp/panelUpdate.pl &".format(download_url))
public.ExecShell("nohup curl -k {}/install/update_panel_en.sh|bash &>/tmp/panelUpdate.pl &".format(download_url))
return True
def test_ping():
+4 -4
View File
@@ -118,7 +118,7 @@ class wx_account_msg:
def get_web_info(self,get):
if self.user_info is None: return public.returnMsg(False, public.lang("The user binding information was not obtained"))
url = "https://wafapi2.aapanel.com/api/v2/user/wx_web/info"
url = f"{public.OfficialWaf2Base()}/api/v2/user/wx_web/info"
data = {
"uid": self.user_info["uid"],
"access_key": 'B' * 32,
@@ -140,7 +140,7 @@ class wx_account_msg:
def get_web_info2(self):
if self.user_info is None: return public.returnMsg(False, public.lang("The user binding information was not obtained"))
url = "https://wafapi2.aapanel.com/api/v2/user/wx_web/info"
url = f"{public.OfficialWaf2Base()}/api/v2/user/wx_web/info"
data = {
"uid": self.user_info["uid"],
"access_key": 'B' * 32,
@@ -160,7 +160,7 @@ class wx_account_msg:
def get_auth_url(self,get):
if self.user_info is None: return public.returnMsg(False, public.lang("The user binding information was not obtained"))
url = "https://wafapi2.aapanel.com/api/v2/user/wx_web/get_auth_url"
url = f"{public.OfficialWaf2Base()}/api/v2/user/wx_web/get_auth_url"
data = {
"uid": self.user_info["uid"],
"access_key": 'B' * 32,
@@ -220,7 +220,7 @@ class wx_account_msg:
return public.returnMsg(False, public.lang("No user information was obtained"))
msg,title = self.get_send_msg(msg)
url="https://wafapi2.aapanel.com/api/v2/user/wx_web/send_template_msg_v2"
url=f"{public.OfficialWaf2Base()}/api/v2/user/wx_web/send_template_msg_v2"
datassss = {
"first": {
"value": "堡塔主机告警",
+508
View File
@@ -0,0 +1,508 @@
# coding: utf-8
# -------------------------------------------------------------------
# aaPanel
# -------------------------------------------------------------------
import re
import json
import socket
import urllib.parse
from http.cookies import SimpleCookie
import requests
import urllib3
import urllib3.util.connection as urllib3_conn
from BTPanel import request, Response, public, app, session
from mod.project.mail.billionmailMod import main as BillionMailMod
class BillionMailProxy:
HOP_BY_HOP_HEADERS = frozenset((
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"content-length",
"content-encoding",
))
PANEL_COOKIES = (
"bt_user_info",
"file_recycle_status",
"ltd_end",
"memSize",
"page_number",
"pro_end",
"request_token",
"serverType",
"site_model",
"sites_path",
"soft_remarks",
"load_page",
"Path",
"distribution",
"order",
)
def __init__(self, proxy_prefix="/billionmail"):
self.proxy_prefix = "/" + proxy_prefix.strip("/")
@staticmethod
def _error(message, status=500):
return Response(message, status=status)
def _clean_cookie(self, cookie_header):
if not cookie_header:
return ""
cookie_dict = SimpleCookie(cookie_header)
remove_names = list(self.PANEL_COOKIES)
session_cookie_name = app.config.get("SESSION_COOKIE_NAME")
if session_cookie_name:
remove_names.append(session_cookie_name)
for cookie_name in remove_names:
if cookie_name in cookie_dict:
del cookie_dict[cookie_name]
return cookie_dict.output(header="", sep=";").strip()
def _request_headers(self, target_base):
headers = {}
target = urllib.parse.urlparse(target_base)
for key in request.headers.keys():
lower_key = key.lower()
if lower_key in self.HOP_BY_HOP_HEADERS or lower_key == "host":
continue
value = request.headers.get(key)
if lower_key == "cookie":
value = self._clean_cookie(value)
if not value:
continue
elif lower_key == "origin":
value = "{}://{}".format(target.scheme, target.netloc)
elif lower_key == "referer":
value = self._rewrite_request_referer(value, target_base)
headers[key] = value
headers["Accept-Encoding"] = "identity"
headers["X-Forwarded-Host"] = request.host
headers["X-Forwarded-Proto"] = request.scheme
headers["X-Forwarded-Prefix"] = self.proxy_prefix
headers["X-Real-IP"] = request.headers.get("X-Real-IP", request.remote_addr or "")
return headers
def _rewrite_request_referer(self, value, target_base):
if not value:
return value
try:
parsed = urllib.parse.urlparse(value)
path = parsed.path or "/"
if path == self.proxy_prefix or path.startswith(self.proxy_prefix + "/"):
new_path = path[len(self.proxy_prefix):] or "/"
target = urllib.parse.urlparse(target_base)
return urllib.parse.urlunparse((
target.scheme,
target.netloc,
new_path,
"",
parsed.query,
parsed.fragment,
))
except Exception:
pass
return value
def _target_url(self, target_base, path_full):
path = "/" + (path_full or "").lstrip("/")
path = urllib.parse.quote(path, safe="/:@!$&'()*+,;=-._~%")
query = request.query_string.decode("latin-1")
url = target_base.rstrip("/") + path
if query:
url += "?" + query
return url
def _request_kwargs(self, target_base):
kwargs = {
"headers": self._request_headers(target_base),
"verify": False,
"timeout": 300,
"allow_redirects": False,
}
content_type = request.headers.get("Content-Type", "").lower()
if content_type.startswith("multipart/form-data"):
files = []
for key in request.files:
for storage in request.files.getlist(key):
files.append((
key,
(
storage.filename,
storage.stream,
storage.content_type,
),
))
form = {}
for key in request.form.keys():
values = request.form.getlist(key)
form[key] = values if len(values) > 1 else values[0]
if files:
kwargs["files"] = files
kwargs["data"] = form
else:
body = request.get_data()
if body:
kwargs["data"] = body
return kwargs
def _rewrite_location(self, value, target_base):
if not value:
return value
target = urllib.parse.urlparse(target_base)
parsed = urllib.parse.urlparse(value)
if parsed.scheme and parsed.netloc:
if parsed.hostname in ("127.0.0.1", "localhost", "::1") or parsed.netloc == target.netloc:
path = parsed.path or "/"
if path == self.proxy_prefix or path.startswith(self.proxy_prefix + "/"):
return path + (("?" + parsed.query) if parsed.query else "")
return self.proxy_prefix + path + (("?" + parsed.query) if parsed.query else "")
return value
if value.startswith("/"):
if value == self.proxy_prefix or value.startswith(self.proxy_prefix + "/"):
return value
return self.proxy_prefix + value
return value
def _rewrite_set_cookie(self, value):
if not value:
return value
value = re.sub(r";\s*domain=(127\.0\.0\.1|localhost|\[?::1\]?)[^;]*", "", value, flags=re.I)
if re.search(r";\s*path=", value, re.I):
return re.sub(r";\s*path=[^;]*", "; Path={}".format(self.proxy_prefix), value, flags=re.I)
return value + "; Path={}".format(self.proxy_prefix)
def _response_headers(self, upstream_response, target_base):
headers = []
for key, value in upstream_response.headers.items():
lower_key = key.lower()
if lower_key in self.HOP_BY_HOP_HEADERS or lower_key == "set-cookie":
continue
if lower_key in ("x-frame-options", "content-security-policy"):
continue
if lower_key == "location":
value = self._rewrite_location(value, target_base)
headers.append((key, value))
headers.append(("Referrer-Policy", "same-origin"))
return headers
def _set_cookie_headers(self, response, upstream_response):
raw_headers = getattr(upstream_response.raw, "headers", None)
cookies = []
if raw_headers is not None:
if hasattr(raw_headers, "get_all"):
cookies = raw_headers.get_all("Set-Cookie") or []
elif hasattr(raw_headers, "getlist"):
cookies = raw_headers.getlist("Set-Cookie") or []
if not cookies:
cookie_header = upstream_response.headers.get("Set-Cookie")
if cookie_header:
cookies = [cookie_header]
for cookie in cookies:
response.headers.add("Set-Cookie", self._rewrite_set_cookie(cookie))
return response
def _safe_redirect(self, value):
value = (value or "").strip()
if not value or not value.startswith("/") or value.startswith("//"):
return self.proxy_prefix + "/"
if value == self.proxy_prefix or value.startswith(self.proxy_prefix + "/"):
return value
return self.proxy_prefix + (value if value != "/" else "/")
def _sso_bridge(self):
service_name = request.args.get("service_name", "")
redirect = self._safe_redirect(request.args.get("redirect", ""))
apsess_token = self._panel_apsess_token()
panel_api_prefix = "/{}".format(apsess_token) if apsess_token else ""
csrf_token = public.get_csrf_sess_html_token_value() or ""
html = """<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="referrer" content="same-origin">
<title>BillionMail</title>
<style>
html, body { height: 100%; margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
body { display: flex; align-items: center; justify-content: center; color: #1f2937; background: #f8fafc; }
.box { text-align: center; font-size: 14px; line-height: 22px; }
.err { color: #b91c1c; white-space: pre-wrap; max-width: 720px; padding: 24px; text-align: left; }
</style>
</head>
<body>
<div class="box" id="message">Signing in to BillionMail...</div>
<script>
(function () {
var serviceName = __SERVICE_NAME__;
var redirectUrl = __REDIRECT_URL__;
var panelApiPrefix = __PANEL_API_PREFIX__;
var csrfToken = __CSRF_TOKEN__;
function getPanelApiPrefix() {
if (panelApiPrefix) return panelApiPrefix;
var match = window.location.pathname.match(/^\\/(apsess_[A-Za-z0-9]{16,32})(?:\\/|$)/);
return match ? '/' + match[1] : '';
}
function fail(err) {
var message = document.getElementById('message');
message.className = 'err';
message.textContent = 'BillionMail SSO failed:\\n' + (err && (err.message || err.responseText || String(err)));
}
function saveLogin(data) {
var ttl = Number(data.ttl || 0);
var login = {
token: data.token || '',
refresh_token: data.refreshToken || data.refresh_token || '',
ttl: ttl,
expire: Date.now() + ttl * 1000
};
if (!login.token) {
throw new Error('SSO response does not contain token.');
}
var state = {};
try {
state = JSON.parse(localStorage.getItem('UserStore') || '{}') || {};
} catch (e) {
state = {};
}
state.login = login;
localStorage.setItem('UserStore', JSON.stringify(state));
}
var body = new URLSearchParams();
if (serviceName) body.set('service_name', serviceName);
fetch(getPanelApiPrefix() + '/v2/mod/mail/billionmail/sso', {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/x-www-form-urlencoded', 'x-http-token': csrfToken},
body: body
}).then(function (res) {
return res.json();
}).then(function (res) {
if (!res || res.status !== 0) {
throw new Error(JSON.stringify(res && res.message ? res.message : res));
}
saveLogin(res.message || {});
window.location.replace(redirectUrl);
}).catch(fail);
})();
</script>
</body>
</html>"""
html = html.replace("__SERVICE_NAME__", json.dumps(service_name))
html = html.replace("__REDIRECT_URL__", json.dumps(redirect))
html = html.replace("__PANEL_API_PREFIX__", json.dumps(panel_api_prefix))
html = html.replace("__CSRF_TOKEN__", json.dumps(csrf_token))
response = Response(html, content_type="text/html; charset=utf-8", status=200)
response.headers["Cache-Control"] = "no-store"
response.headers["Referrer-Policy"] = "same-origin"
return response
def _panel_apsess_token(self):
token = request.environ.get("bt.apsess_token", "") or session.get("apsess_token", "")
token = str(token or "").strip()
while token.startswith("apsess_apsess_"):
token = token[len("apsess_"):]
if token and not token.startswith("apsess_"):
token = "apsess_" + token
if not re.match(r"^apsess_[A-Za-z0-9]{16,32}$", token):
return ""
return token
def _is_text_response(self, upstream_response, path_full):
content_type = (upstream_response.headers.get("content-type") or "").lower()
path = (path_full or "").split("?", 1)[0].lower()
if any(item in content_type for item in (
"text/html",
"text/css",
"javascript",
"application/json",
"application/xml",
)):
return True
return path.endswith((".html", ".htm", ".css", ".js", ".json", ".xml"))
@staticmethod
def _webmail_url(config):
if not isinstance(config, dict):
return ""
webmail_url = str(config.get("webmail_url") or "").strip()
if webmail_url:
return webmail_url.rstrip("/")
direct_console_url = str(config.get("direct_console_url") or "").strip()
if not direct_console_url:
return ""
return direct_console_url.rstrip("/") + "/roundcube"
def _rewrite_webmail_url(self, text, webmail_url):
if not webmail_url:
return text
panel_origin = "{}://{}".format(request.scheme, request.host).rstrip("/")
text = text.replace(panel_origin + "/roundcube", webmail_url)
text = text.replace("${location.origin}/roundcube", webmail_url)
text = text.replace("${window.location.origin}/roundcube", webmail_url)
webmail_literal = json.dumps(webmail_url)
text = re.sub(
r'(?:(?:window|globalThis)\.)?location\.origin\s*\+\s*(["\'])/roundcube\1',
webmail_literal,
text,
)
text = re.sub(
r'(\.concat\(\s*)(?:(?:window|globalThis)\.)?location\.origin\s*,\s*(["\'])/roundcube\2',
lambda match: match.group(1) + webmail_literal,
text,
)
return text
@staticmethod
def _webmail_rewrite_script(webmail_url):
return """<script>
(function () {
var webmailUrl = __WEBMAIL_URL__;
if (!webmailUrl) return;
var panelRoundcube = window.location.origin.replace(/\\/$/, '') + '/roundcube';
function rewrite(value) {
if (typeof value !== 'string') return value;
return value.split(panelRoundcube).join(webmailUrl);
}
window.__AAPANEL_BILLIONMAIL_WEBMAIL_URL__ = webmailUrl;
try {
if (navigator.clipboard && navigator.clipboard.writeText && !navigator.clipboard.__aapanelBillionMailPatched) {
var rawWriteText = navigator.clipboard.writeText.bind(navigator.clipboard);
navigator.clipboard.writeText = function (value) {
return rawWriteText(rewrite(value));
};
navigator.clipboard.__aapanelBillionMailPatched = true;
}
} catch (e) {}
document.addEventListener('copy', function (event) {
if (!event.clipboardData) return;
var selection = String(window.getSelection ? window.getSelection() : '');
var rewritten = rewrite(selection);
if (rewritten !== selection) {
event.clipboardData.setData('text/plain', rewritten);
event.preventDefault();
}
}, true);
})();
</script>""".replace("__WEBMAIL_URL__", json.dumps(webmail_url))
def _inject_webmail_rewrite_script(self, text, webmail_url):
if not webmail_url or "__AAPANEL_BILLIONMAIL_WEBMAIL_URL__" in text:
return text
script = self._webmail_rewrite_script(webmail_url)
if re.search(r"</head\s*>", text, re.I):
return re.sub(r"</head\s*>", lambda match: script + "\n" + match.group(0), text, count=1, flags=re.I)
if re.search(r"</body\s*>", text, re.I):
return re.sub(r"</body\s*>", lambda match: script + "\n" + match.group(0), text, count=1, flags=re.I)
return script + "\n" + text
def _rewrite_text_content(self, text, path_full, config=None):
proxy = self.proxy_prefix
path = (path_full or "").split("?", 1)[0].lower()
webmail_url = self._webmail_url(config)
replacements = (
('${location.origin}/static/', '${location.origin}' + proxy + '/static/'),
('"/static/', '"{}/static/'.format(proxy)),
("'/static/", "'{}/static/".format(proxy)),
("`/static/", "`{}/static/".format(proxy)),
("(/static/", "({}/static/".format(proxy)),
("url(/static/", "url({}/static/".format(proxy)),
("=/static/", "={}/static/".format(proxy)),
)
for old, new in replacements:
text = text.replace(old, new)
if path.endswith(".js"):
js_base = proxy.rstrip("/") + "/"
text = re.sub(
r'(\b[A-Za-z_$][\w$]*\.p=)(["\'])/(["\'])',
r'\1\2{}/\3'.format(proxy),
text,
)
text = re.sub(
r'(history\s*:\s*\(0,\s*[A-Za-z_$][\w$]*\.PO\)\(["\'])/(["\']\))',
lambda match: match.group(1) + js_base + match.group(2),
text,
)
text = re.sub(
r'(createWebHistory\s*\(\s*["\'])/(["\']\s*\))',
lambda match: match.group(1) + js_base + match.group(2),
text,
)
text = re.sub(
r'(prefix\s*:\s*["\'])/api(["\'])',
r'\1{}/api\2'.format(proxy),
text,
)
text = re.sub(
r'(baseURL\s*:\s*["\'])/api(["\'])',
r'\1{}/api\2'.format(proxy),
text,
)
text = self._rewrite_webmail_url(text, webmail_url)
elif path.endswith((".html", ".htm")) or "<html" in text[:1024].lower():
text = self._rewrite_webmail_url(text, webmail_url)
text = self._inject_webmail_rewrite_script(text, webmail_url)
return text
def _response_content(self, upstream_response, path_full, config=None):
content = upstream_response.content
if not content or not self._is_text_response(upstream_response, path_full):
return content
encoding = upstream_response.encoding or "utf-8"
try:
text = content.decode(encoding)
except Exception:
try:
text = content.decode("utf-8")
except Exception:
return content
return self._rewrite_text_content(text, path_full, config).encode(encoding, errors="ignore")
def proxy(self, path_full=""):
try:
if (path_full or "").strip("/") == "__aapanel_sso__":
return self._sso_bridge()
urllib3.disable_warnings()
urllib3_conn.allowed_gai_family = lambda: socket.AF_INET
config = BillionMailMod().get_proxy_config()
if not config.get("installed"):
return self._error("BillionMail is not installed", 404)
target_base = config.get("target_base")
if not target_base:
return self._error("BillionMail web port is not configured", 502)
proxy_url = self._target_url(target_base, path_full)
upstream_response = requests.request(
request.method,
proxy_url,
**self._request_kwargs(target_base)
)
response = Response(
self._response_content(upstream_response, path_full, config),
headers=self._response_headers(upstream_response, target_base),
content_type=upstream_response.headers.get("content-type", None),
status=upstream_response.status_code,
)
return self._set_cookie_headers(response, upstream_response)
except Exception as ex:
return self._error(str(ex), 500)
+106 -22
View File
@@ -15,6 +15,7 @@ import os
import re
import socket
import time
import urllib.parse
from http.cookies import SimpleCookie
import requests
@@ -25,6 +26,8 @@ from BTPanel import request, Response, public, app, get_phpmyadmin_dir, session
class HttpProxy:
_pma_path = None
_apsess_token_rep = re.compile(r"^apsess_[A-Za-z0-9]{16,32}$")
_internal_hosts = frozenset(("127.0.0.1", "localhost", "::1"))
@staticmethod
def _err_resp(msg: str = None):
@@ -32,6 +35,97 @@ class HttpProxy:
msg or "something wrong with socket, please cheak and try again...", 500
)
@staticmethod
def _has_invalid_header_chars(value):
return '\r' in value or '\n' in value
def _get_apsess_path_token(self):
token = request.environ.get('bt.apsess_token', '')
if self._apsess_token_rep.match(token):
return token
return ''
def _get_pma_proxy_path(self):
if self._pma_path is None:
self._pma_path = get_phpmyadmin_dir()
if self._pma_path:
self._pma_path = self._pma_path[0]
else:
self._pma_path = ''
return self._pma_path
@staticmethod
def _is_phpmyadmin_proxy_request():
return request.path == '/phpmyadmin' or request.path.startswith('/phpmyadmin/') \
or request.path == '/v2/phpmyadmin' or request.path.startswith('/v2/phpmyadmin/')
@staticmethod
def _replace_path_prefix(path, old_prefix, new_prefix):
if not old_prefix:
return path
old_path = '/' + old_prefix.strip('/')
new_path = '/' + new_prefix.strip('/')
if path == old_path:
return new_path
if path.startswith(old_path + '/'):
return new_path + path[len(old_path):]
return path
def _add_apsess_to_proxy_path(self, path):
token = self._get_apsess_path_token()
if not token:
return path
token_path = '/' + token
if path == token_path or path.startswith(token_path + '/'):
return path
if path == '/phpmyadmin' or path.startswith('/phpmyadmin/'):
return token_path + path
return path
def _rewrite_location_header(self, location):
if not location:
return location
if self._has_invalid_header_chars(location):
return None
try:
parts = urllib.parse.urlsplit(location)
hostname = parts.hostname
except ValueError:
return location
is_absolute = parts.scheme in ('http', 'https') and hostname in self._internal_hosts
is_root_relative = not parts.scheme and not parts.netloc and location.startswith('/') and not location.startswith('//')
if not is_absolute and not is_root_relative:
return location
path = self._replace_path_prefix(parts.path or '/', self._get_pma_proxy_path(), 'phpmyadmin')
path = self._add_apsess_to_proxy_path(path)
if is_absolute:
return urllib.parse.urlunsplit(('', '', path, parts.query, parts.fragment))
return urllib.parse.urlunsplit(('', '', path, parts.query, parts.fragment))
def _rewrite_legacy_location_header(self, location):
if location.find('phpmyadmin_') != -1:
if not self._pma_path:
self._pma_path = get_phpmyadmin_dir()
if self._pma_path:
self._pma_path = self._pma_path[0]
else:
self._pma_path = ''
location = location.replace(self._pma_path, 'phpmyadmin')
elif location.find("adminer_") != -1:
from adminer.manager import AdminerManager
adminer_dir, _ = AdminerManager().adminer_dir_port
location = location.replace(adminer_dir, 'adminer')
if location.find('127.0.0.1') != -1:
location = re.sub(r"https?://127.0.0.1(:\d+)?/", request.url_root, location)
if request.url_root.find('https://') == 0:
location = location.replace('http://', 'https://')
return location
def get_res_headers(self, p_res):
"""
@name 获取响应头
@@ -45,28 +139,14 @@ class HttpProxy:
continue
headers[h] = p_res.headers[h]
if h in ['location', 'Location']:
# ============ redirect ===================
# phpmyadmin
if headers[h].find('phpmyadmin_') != -1:
if not self._pma_path:
self._pma_path = get_phpmyadmin_dir()
if self._pma_path:
self._pma_path = self._pma_path[0]
else:
self._pma_path = ''
headers[h] = headers.get(h, "").replace(self._pma_path, 'phpmyadmin')
# adminer
elif headers[h].find("adminer_") != -1:
from adminer.manager import AdminerManager
adminer_dir, _ = AdminerManager().adminer_dir_port
headers[h] = headers.get(h, "").replace(adminer_dir, 'adminer')
# ============ redirect end ==================
if headers[h].find('127.0.0.1') != -1:
headers[h] = re.sub(r"https?://127.0.0.1(:\d+)?/", request.url_root, headers[h])
if request.url_root.find('https://') == 0:
headers[h] = headers.get(h, '').replace('http://', 'https://')
if self._is_phpmyadmin_proxy_request():
rewrite_location = self._rewrite_location_header(headers[h])
else:
rewrite_location = self._rewrite_legacy_location_header(headers[h])
if rewrite_location is None:
headers.pop(h, None)
continue
headers[h] = rewrite_location
return headers
def set_res_headers(self, res, p_res):
@@ -87,6 +167,10 @@ class HttpProxy:
# expires=expires, httponly=httponly,
# path='/')
if request.path == '/phpmyadmin' or request.path.startswith('/phpmyadmin/') \
or request.path == '/v2/phpmyadmin' or request.path.startswith('/v2/phpmyadmin/'):
res.headers['Referrer-Policy'] = 'same-origin'
return res
def get_pma_phpversion(self):
+1 -1
View File
@@ -36,7 +36,7 @@ class main(panelBase):
res = cache.get(skey)
if res: return res
res = public.httpPost('https://wafapi2.aapanel.com/Api/getUpdateLogs?type=Linux',{})
res = public.httpPost(f'{public.OfficialWaf2Base()}/Api/getUpdateLogs?type=Linux', {})
start_index = res.find('(') + 1
end_index = res.rfind(')')
+1 -1
View File
@@ -2159,7 +2159,7 @@ class panelPlugin:
def getCloudPlugin(self,get):
if session.get('getCloudPlugin') and get != None: return public.return_msg_gettext(True,'Your plugin list is already the latest version {}!',("-1",))
import json
if not session.get('download_url'): session['download_url'] = 'http://node.aapanel.com'
if not session.get('download_url'): session['download_url'] = public.OfficialDownloadBase()
#获取列表
try:
+1 -1
View File
@@ -36,7 +36,7 @@ class panelSSL:
__BINDURL = '{}/api/user'.format(public.OfficialApiBase()) # 获取token 获取官网token
# __BINDURL = 'http://dev.aapanel.com/api/user' # 获取token 获取官网token
__CODEURL = 'https://wafapi.aapanel.com/Auth/GetBindCode' # 获取绑定验证码
__CODEURL = f'{public.OfficialWafBase()}/Auth/GetBindCode' # 获取绑定验证码
__UPATH = 'data/userInfo.json'
# __APIURL = 'http://dev.aapanel.com/api'
+1 -1
View File
@@ -2582,7 +2582,7 @@ listener SSL443 {
return public.returnMsg(True, public.lang("Site stopped"))
if not os.path.exists(path):
os.makedirs(path)
public.downloadFile('https://node.aapanel.com/stop_en.html', path + '/index.html')
public.downloadFile(f'{public.OfficialDownloadBase()}/stop_en.html', path + '/index.html')
# if 'This site has been closed by administrator' not in public.readFile(path + '/index.html'):
# public.downloadFile('http://download.bt.cn/stop_en.html', path + '/index.html')
+2 -2
View File
@@ -55,8 +55,8 @@ class process_network_total:
f.close()
if red_body.find('CentOS Linux release 8.') != -1:
rpm_file = '/root/libpcap-1.9.1.rpm'
down_url = "wget -O {} https://node.aapanel.com/src/libpcap-devel-1.9.1-5.el8.x86_64.rpm --no-check-certificate -T 10".format(
rpm_file)
down_url = "wget -O {} {}/src/libpcap-devel-1.9.1-5.el8.x86_64.rpm --no-check-certificate -T 10".format(
rpm_file, public.OfficialDownloadBase())
if os.path.exists(rpm_file):
os.system(down_url)
os.system("rpm -ivh {}".format(rpm_file))
+324 -71
View File
@@ -105,6 +105,18 @@ def default_languages_config():
"google": "fa",
"title": "فارسی",
"cn": "波斯语"
},
{
"name": "ar",
"google": "ar",
"title": "العربية",
"cn": "阿拉伯语"
},
{
"name": "lo",
"google": "lo",
"title": "ພາສາລາວ",
"cn": "老挝语"
}
]
}
@@ -766,7 +778,7 @@ def GetConfigValue(key):
"template": "default", "logs_path": "/www/wwwlogs", "home": "https://www.aapanel.com", "recycle_bin": True}
writeFile('/www/server/panel/config/config.json',json.dumps(config))
if not key in config.keys():
if key == 'download': return 'http://node.aapanel.com'
if key == 'download': return OfficialDownloadBase()
return None
return config[key]
@@ -1152,58 +1164,184 @@ def get_timeout(url, timeout=3):
def get_url(timeout=0.5):
return 'https://node.aapanel.com'
return smart_get_node("download_base")
import json
# ================== 节点容灾切换 ==================
NODES_CONFIG = {
# 要拿的url 主 备
'offical_base': ['https://www.aapanel.com', 'https://sg1-www.aapanel.com'],
'api_base': ['https://api.aapanel.com', 'https://sg1-api.aapanel.com'],
'plugin_base': ['https://download.aapanel.com', 'https://sg1-www.aapanel.com'],
'download_base': ['https://node.aapanel.com', 'https://jp1-node.aapanel.com'],
'waf_base': ['https://wafapi.aapanel.com', 'https://sg1-wafapi.aapanel.com'],
'waf2_base': ['https://wafapi2.aapanel.com', 'https://sg1-wafapi2.aapanel.com'],
'w-check_base': ['https://w-check.aapanel.com', 'https://sg1-w-check.aapanel.com'],
'webshellcheck_base':['https://webshellcheck.aapanel.com','https://sg1-webshellcheck.aapanel.com'],
'geterror_base': ['https://geterror.aapanel.com', 'https://sg1-geterror.aapanel.com'],
}
_NODE_HOST_INDEX = {} # host -> [node_type] install 时构建一次,只读
_STATE_FILE = '/www/server/panel/data/node_state.json'
_original_session_request = None
def _host_of(url):
"""提取 url 的 host"""
try:
pkey = 'node_url'
node_url = cache_get(pkey)
if node_url: return node_url
nodeFile = 'data/node.json'
node_list = json.loads(readFile(nodeFile))
mnode1 = []
mnode2 = []
mnode3 = []
new_node_list = {}
for node in node_list:
node['net'], node['ping'] = get_timeout(
node['protocol'] + node['address'] + ':' + node['port'] + '/net_test', 1)
new_node_list[node['address']] = node['ping']
if not node['ping']: continue
if node['ping'] < 100: # 当响应时间<100ms且可用带宽大于1500KB时
if node['net'] > 1500:
mnode1.append(node)
elif node['net'] > 1000:
mnode3.append(node)
else:
if node['net'] > 1000: # 当响应时间>=100ms且可用带宽大于1000KB时
mnode2.append(node)
if node['ping'] < 100:
if node['net'] > 3000: break # 有节点可用带宽大于3000时,不再检查其它节点
if mnode1: # 优选低延迟高带宽
mnode = sorted(mnode1, key=lambda x: x['net'], reverse=True)
elif mnode3: # 备选低延迟,中等带宽
mnode = sorted(mnode3, key=lambda x: x['net'], reverse=True)
else: # 终选中等延迟,中等带宽
mnode = sorted(mnode2, key=lambda x: x['ping'], reverse=False)
from urllib.parse import urlparse
return urlparse(url).hostname
except Exception:
return None
if not mnode: return 'https://node.aapanel.com'
new_node_keys = new_node_list.keys()
for i in range(len(node_list)):
if node_list[i]['address'] in new_node_keys:
node_list[i]['ping'] = new_node_list[node_list[i]['address']]
else:
node_list[i]['ping'] = 500
def _build_node_host_index():
"""host -> [node_type] 反索引 存在一对多的情况注意"""
idx = {}
for node_type, nodes_list in NODES_CONFIG.items():
for node_url in nodes_list:
host = _host_of(node_url)
if host:
idx.setdefault(host, []).append(node_type)
return idx
new_node_list = sorted(node_list, key=lambda x: x['ping'], reverse=False)
writeFile(nodeFile, json.dumps(new_node_list))
node_url = mnode[0]['protocol'] + mnode[0]['address'] + ':' + mnode[0]['port']
cache_set(pkey, node_url, 86400)
return node_url
except:
return 'https://node.aapanel.com'
def _is_node_ok(status_code):
"""节点健康判定:status_code==0失败/非2xx-3xx/403/404 均失败"""
return status_code and 200 <= status_code < 400 and status_code not in [403, 404]
def _load_state():
"""current 状态: {node_type: url}"""
try:
return json.loads(readFile(_STATE_FILE) or '{}')
except Exception:
return {}
def _save_state(data):
"""原子写状态"""
tmp = _STATE_FILE + '.tmp'
with open(tmp, 'w') as f:
json.dump(data, f)
os.replace(tmp, _STATE_FILE)
def _set_state(node_type, url):
"""更新单 node_type 的 current; 返回是否发生变化"""
import fcntl
with open(_STATE_FILE + '.lock', 'w') as lf:
fcntl.flock(lf, fcntl.LOCK_EX)
try:
data = _load_state()
if data.get(node_type) == url:
return False
data[node_type] = url
_save_state(data)
return True
finally:
fcntl.flock(lf, fcntl.LOCK_UN)
def _record_node_result(node_type, success):
"""记录节点请求结果; 失败且当前仍是 Primary 则返回 True(应切 Secondary)。阈值=1: 任一次失败即切"""
if success:
return False
primary = NODES_CONFIG[node_type][0]
return _load_state().get(node_type, primary) == primary
def _switch_to_secondary(node_type):
nodes = NODES_CONFIG[node_type]
secondary = nodes[1] if len(nodes) > 1 else nodes[0]
if not _set_state(node_type, secondary):
return
print_log('[AAPANEL] [failover] {} primary {} down, switched to secondary {}'.format(
node_type, nodes[0], secondary))
# 恢复探测由 BTTask node_failover_recovery 定时兜底(常驻跨进程), 本进程不起探测线程
def _record_node_failure(url):
"""url 探测/下载失败且为某 node_type 的 Primary 时喂判定"""
host = _host_of(url)
if not host:
return
for node_type, nodes in NODES_CONFIG.items():
if host == _host_of(nodes[0]):
if _record_node_result(node_type, False):
_switch_to_secondary(node_type)
break
def _switch_back_to_primary(node_type):
primary = NODES_CONFIG[node_type][0]
if not _set_state(node_type, primary):
return
print_log('[AAPANEL] [failover] {} primary {} recovered, switched back from secondary'.format(node_type, primary))
def _try_recover_to_primary(node_type):
"""单次探测 Primary 恢复: current 已 Primary→'primary'; 探测 OK 切回→'recovered'; 否则→'still_down'. 仅 BTTask node_failover_recovery 调用"""
primary = NODES_CONFIG[node_type][0]
if _load_state().get(node_type, primary) == primary:
return 'primary'
try:
import http_requests
res = http_requests.get(primary, timeout=(2, 3))
if _is_node_ok(res.status_code):
_switch_back_to_primary(node_type)
return 'recovered'
except Exception:
pass
return 'still_down'
def _patched_session_request(self, method, url, *args, **kwargs):
"""requests.Session.request:命中节点 Primary host 的请求被动计数,连续失败切 Secondary
非节点 url / stream 下载 直透, 依赖其他失败来切换"""
host = _host_of(url)
node_types = _NODE_HOST_INDEX.get(host) if host else None
if not node_types or kwargs.get('stream'):
return _original_session_request(self, method, url, *args, **kwargs)
if 'timeout' not in kwargs: # 防御:节点请求未指定 timeout 时兜底
kwargs['timeout'] = 10
success = False
try:
resp = _original_session_request(self, method, url, *args, **kwargs)
success = _is_node_ok(resp.status_code)
return resp
except Exception:
success = False
raise
finally:
for nt in node_types:
# 仅当请求的 host 是该 node_type 的 Primary host 才计 streak(P1)
if _host_of(NODES_CONFIG[nt][0]) == host:
if _record_node_result(nt, success):
_switch_to_secondary(nt)
def install_node_failover_patch():
"""开局全局补丁(被动判定+切换 current); 恢复探测交 BTTask node_failover_recovery 定时兜底, 本进程不做"""
global _original_session_request, _NODE_HOST_INDEX
import requests
if _original_session_request is None:
_original_session_request = requests.Session.request
requests.Session.request = _patched_session_request
_NODE_HOST_INDEX = _build_node_host_index()
def smart_get_node(node_type: str = "offical_base"):
"""
查询指定类型的当前可用节点 url(跨进程共享)
node_type名字 详见 NODES_CONFIG
"""
nodes_list = NODES_CONFIG.get(node_type)
if not nodes_list:
return ''
return _load_state().get(node_type, nodes_list[0])
# ================== 节点容灾切换 End ==================
# 过滤输入
def checkInput(data):
@@ -1470,13 +1608,59 @@ def get_requests_headers():
return {"Content-type": "application/x-www-form-urlencoded", "User-Agent": "BT-Panel"}
def downloadFile(url, filename):
def _alt_node_url(url):
"""url 命中节点时返回替换节点(Primary↔Secondary)的 url(保留 path/query);否则 None"""
try:
from urllib.parse import urlparse
p = urlparse(url)
host = p.hostname
if not host:
return None
suffix = p.path + (('?' + p.query) if p.query else '')
for nodes in NODES_CONFIG.values():
if len(nodes) < 2:
continue
if host == _host_of(nodes[0]):
return nodes[1] + suffix
if host == _host_of(nodes[1]):
return nodes[0] + suffix
return None
except Exception:
return None
def _download_candidates(url):
"""下载候选 url:当前节点优先 + 替换节点(去重)"""
cands = [url]
alt = _alt_node_url(url)
if alt and alt not in cands:
cands.append(alt)
return cands
def _probe_node_reachable(url, timeout=3):
"""TCP 探测节点可达性(短 timeout connect),可达返回 True"""
try:
import socket
from urllib.parse import urlparse
p = urlparse(url)
host = p.hostname
if not host:
return False
port = p.port or (443 if p.scheme == 'https' else 80)
with socket.create_connection((host, port), timeout=timeout):
return True
except Exception:
return False
def _download_once(url, filename):
"""单次下载:Py3 urlretrieve / Py2 requests,wget 兜底。成功(文件非空)返回 True"""
ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.135 Safari/537.36'
try:
if sys.version_info[0] == 2:
import requests
headers = {
'User-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.135 Safari/537.36'}
r = requests.get(url, headers=headers, verify=False)
r = requests.get(url, headers={'User-agent': ua}, verify=False)
with open(filename, "wb") as f:
f.write(r.content)
else:
@@ -1484,12 +1668,41 @@ def downloadFile(url, filename):
import ssl
ssl._create_default_https_context = ssl._create_unverified_context
opener = urllib.request.build_opener()
opener.addheaders = [('User-agent',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.135 Safari/537.36')]
opener.addheaders = [('User-agent', ua)]
urllib.request.install_opener(opener)
urllib.request.urlretrieve(url, filename=filename)
except:
ExecShell("wget -O {} {} --no-check-certificate".format(filename, url))
return os.path.exists(filename) and os.path.getsize(filename) > 0
def downloadFile(url, filename):
"""下载文件;下载前 TCP 探测候选节点选可达的再下载 """
for candidate in _download_candidates(url):
if not _probe_node_reachable(candidate):
print_log('[AAPANEL] [failover] downloadFile {} unreachable, skip'.format(candidate))
_record_node_failure(candidate)
continue
if _download_once(candidate, filename):
if candidate != url:
print_log('[AAPANEL] [failover] downloadFile {} unreachable, switched to {}'.format(url, candidate))
return True
print_log('[AAPANEL] [failover] downloadFile {} reachable but download failed, try next'.format(candidate))
_record_node_failure(candidate)
return False
def get_reachable_url(url, timeout=3):
"""探测 url 可达性返回可用下载节点, 同时协同容灾状态(不可达喂判定切 Secondary + 试备用)
downloadFile / OfficialDownloadBase 友好下载类型 共用, 孤儿api跨进程使用"""
if _probe_node_reachable(url, timeout):
return url
_record_node_failure(url)
alt = _alt_node_url(url)
if alt and _probe_node_reachable(alt, timeout):
print_log('[AAPANEL] [failover] {} unreachable, use {}'.format(url, alt))
return alt
return url
def exists_args(args, get):
@@ -1907,14 +2120,12 @@ def checkWebConfig(repair_num=2, path=None):
if repair_num > 0:
repair_num -= 1
change_nginx_old_http2()
# print_log('nginx----2')
return checkWebConfig(repair_num)
if result[1].find('[emerg] invalid parameter "quic" in') != -1 and web_s == "nginx" and not is_nginx_http3():
if repair_num > 0:
repair_num -= 1
remove_nginx_quic()
# print_log('nginx----3')
return checkWebConfig(repair_num)
WriteLog("TYPE_SOFT", 'CONF_CHECK_ERR', (result[1],))
@@ -1976,7 +2187,7 @@ def err_collect(error_info, type, error_id):
# 提交异常报告
if not cache_get(pkey):
try:
run_thread(httpPost, ("https://geterror.aapanel.com/bt_error/index.php", error_infos))
run_thread(httpPost, (f"{OfficialGetErrorBase()}/bt_error/index.php", error_infos))
cache_set(pkey, 1, 1800)
except Exception as e:
pass # 错误信息
@@ -2080,6 +2291,7 @@ def CheckMyCnf():
version = key
break
download_url = smart_get_node('download_base')
shellStr = '''
#!/bin/bash
PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin:~/bin
@@ -2104,7 +2316,7 @@ export PATH
# nodeAddr=$CF
# fi
Download_Url=https://node.aapanel.com
Download_Url=%s
MySQL_Opt()
@@ -2182,7 +2394,7 @@ MySQL_Opt()
wget -O /etc/my.cnf $Download_Url/install/conf/mysql-%s.conf -T 5
chmod 644 /etc/my.cnf
MySQL_Opt
''' % (version,)
''' % (download_url, version)
ExecShell(shellStr)
# 判断是否迁移目录
if os.path.exists('data/datadir.pl'):
@@ -7680,11 +7892,11 @@ def check_auth_ip():
"""
import http_requests
result = {'www': '', 'api': ''}
res = http_requests.post('https://wafapi2.aapanel.com/api/getIpAddress', data={}, timeout=5, headers={})
res = http_requests.post(f'{OfficialWaf2Base()}/api/getIpAddress', data={}, timeout=5, headers={})
if res.status_code == 200:
result['www'] = res.text
res1 = http_requests.post('https://wafapi.aapanel.com/api/getIpAddress', data={}, timeout=5, headers={})
res1 = http_requests.post(f'{OfficialWafBase()}/api/getIpAddress', data={}, timeout=5, headers={})
if res1.status_code == 200:
result['api'] = res1.text
@@ -9182,9 +9394,9 @@ def load_soft_list(force: bool = True, retry_count: int = 0):
time.sleep(2 * retry_count + 1)
cloudUrl = '{}/api/panel/getSoftListEn'.format(OfficialApiBase())
import panelAuth
from panel_auth_v2 import panelAuth
import requests
pdata = panelAuth.panelAuth().create_serverid(None)
pdata = panelAuth().create_serverid(None)
url_headers = {
'user-agent': 'aaPanel/1.0',
}
@@ -9256,20 +9468,44 @@ def load_soft_list(force: bool = True, retry_count: int = 0):
return plugin_list_data
# ======================== 获取节点, 统一风格入口 ============================
# 官网API根地址
def OfficialApiBase():
return 'https://www.aapanel.com'
# return 'http://dev.aapanel.com'
return smart_get_node()
# 官网API服务接口地址
def OfficialApiUrlBase():
return get_reachable_url(smart_get_node("api_base"))
# 部分插件下载地址
def sync_plugin_OfficialApiBase():
return 'https://download.aapanel.com'
def SyncPluginOfficialApiBase():
return smart_get_node("plugin_base")
# 官网下载根地址
def OfficialDownloadBase():
return 'https://node.aapanel.com'
return get_reachable_url(smart_get_node("download_base"))
def OfficialGetErrorBase():
return smart_get_node('geterror_base')
# WAFapi
def OfficialWafBase():
return smart_get_node('waf_base')
# WAFapi2
def OfficialWaf2Base():
return smart_get_node('waf2_base')
# w check
def OfficialWCheckBase():
return smart_get_node('w-check_base')
# WEBshell
def OfficialWebShellCheckBase():
return smart_get_node('webshellcheck_base')
# ======================== 获取节点 End ============================
# 获取安装路径
def get_setup_path():
@@ -9830,9 +10066,9 @@ def progress_acquire_lock(lock_file):
if os.path.exists(lock_file):
os.remove(lock_file)
# 创建新锁文件并写入当前线程ID
# 创建新锁文件并写入当前线程ID(原子操作,避免空窗口期)
with open(lock_file, 'w') as f:
f.write('')
f.write(str(threading.get_ident()))
f.flush()
os.fsync(f.fileno()) # 确保写入磁盘
return True
@@ -10443,4 +10679,21 @@ def replace_conf_without_sub(text, old, new):
new=new,
block_start="#Subdirectory-configuration-start",
block_end="#Subdirectory-configuration-end"
)
)
def get_secret_key():
secret_key_file = get_panel_path() + "/data/panel_secret_key.json"
if os.path.exists(secret_key_file):
try:
data = readFile(secret_key_file)
if data:
data_dict = json.loads(data)
if "secret_key" in data_dict and data_dict["secret_key"]:
return data_dict["secret_key"]
except:
secret_key = GetRandomString(64)+get_mac_address()
writeFile(secret_key_file, json.dumps({"secret_key": secret_key}))
return secret_key
secret_key = GetRandomString(64)+get_mac_address()
writeFile(secret_key_file, json.dumps({"secret_key": secret_key}))
return secret_key
+2 -4
View File
@@ -117,10 +117,8 @@ def del_bak(bak_file: str) -> aap_t_simple_result:
return aap_t_simple_result(int(data.get('status', 0)) == 0, data.get('message', {})['result'])
# 其它途径备份
if not os.path.exists(bak_file):
return aap_t_simple_result(False, get_msg_gettext('File not exists'))
os.remove(bak_file)
if os.path.exists(bak_file):
os.remove(bak_file)
return aap_t_simple_result(True, get_msg_gettext('Remove backup successfully'))
+1 -1
View File
@@ -57,7 +57,7 @@ class main(safeBase):
# data["serverid"]=self.user_info["serverid"]
data["serverid"] = self.user_info["server_id"]
#如果不是我们的用户,那么不返回数据
res = public.httpPost('https://wafapi2.aapanel.com/api/ip/info',data)
res = public.httpPost(f'{public.OfficialWaf2Base()}/api/ip/info', data)
res = json.loads(res)
data = self.get_ip_area_cache()
for key in res:
-2
View File
@@ -80,8 +80,6 @@ NhnIR3Ilo4H2su9/cTNo/JEWIdEQUX/MlMcOaFZ8tGgjCfBcJNHhmedart7PDRhF
NhnIR3Ilo4H2su9/cTNo/E4e/PRk3DEtgyjsUDeOpSOwGpZehbHYaLN6kP4SrhN+PFbXCI6E8Z+8865ULB7IOw==
LVgN8QZ1tkuTtU9+mDfpHbABBExfuPmmfo3E06+A/BM9PmE7jeC8kd00z+BCB2M1v4xu/9vQ4uMzqn0yz/r/zg==
NhnIR3Ilo4H2su9/cTNo/C/mkLay+Rx5WJjwGcYV7pSktyrOzy78W2NcadJ49lO1TawFf1/X1sBVYqIElsTA2w==
UGSctzorKMmnxEYKNtDxBsMiRa+LzvFu7RsagwDRjHDjiVYn4zDsZyr78hpjoOnsS9Yzsz+GIvg2uKsO373vP6MBvlJMwYI2zUo8zjMn5LXpp5dEs5Ye5bPkCCEC4fWv
UGSctzorKMmnxEYKNtDxBvmbpWmtdiTclQjVTr357iIpqijI/y4QfER4haYbO69t
i2/r4l7qMbzsMax2VRyMhtAmCDVM605WJJJXLvaIPWs=
lOh2GtzHjjMM8E9J40AuOcdQKKiNsG4KLWGuABfb2Fk=
p9oVsBgcyiBMjDb8CcPOqAYk8y+JZgXclTIm+iHATKY=
+1 -1
View File
@@ -500,7 +500,7 @@ class ssh_security:
cpath = 'data/msg.json'
try:
if 'force' in get or not os.path.exists(cpath):
public.downloadFile('{}/linux/panel/msg/msg.json'.format("https://node.aapanel.com"),cpath)
public.downloadFile('{}/linux/panel/msg/msg.json'.format(public.OfficialDownloadBase(),cpath))
except : pass
data = {}
+3 -3
View File
@@ -628,7 +628,7 @@ class main(sslBase):
public.M('ssl_info').delete(id=target["id"])
if target["cloud_id"] != -1 and cloud:
url = "https://wafapi2.aapanel.com/api/Cert_cloud_deploy/del_cert"
url = f"{public.OfficialWaf2Base()}/api/Cert_cloud_deploy/del_cert"
try:
res_text = public.httpPost(url, {
"cert_id": target["cloud_id"],
@@ -699,7 +699,7 @@ class main(sslBase):
data["privateKey"] = AES.aes_encrypt(data["privateKey"])
data["certificate"] = AES.aes_encrypt(data["certificate"])
# 对接云端
url = "https://wafapi2.aapanel.com/api/Cert_cloud_deploy/cloud_deploy"
url = f"{public.OfficialWaf2Base()}/api/Cert_cloud_deploy/cloud_deploy"
try:
res_text = public.httpPost(url, data)
@@ -1284,7 +1284,7 @@ class main(sslBase):
AES = AesCryptPy3(key, "CBC", iv, char_set="utf8")
# 对接云端
url = "https://wafapi2.aapanel.com/api/Cert_cloud_deploy/get_cert_list"
url = f"{public.OfficialWaf2Base()}/api/Cert_cloud_deploy/get_cert_list"
try:
res_text = public.httpPost(url, {
"uid": user_info["uid"],
+36 -7
View File
@@ -144,14 +144,43 @@ class ssl_info:
# 取申请时间
result['notBefore'] = self.strf_date(
bytes.decode(x509.get_notBefore())[:-1])
# 取可选名称
# 取可选名称 - 使用cryptography稳定解析
result['dns'] = []
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])
try:
from cryptography.x509 import load_pem_x509_certificate
from cryptography.hazmat.backends import default_backend
from cryptography.x509.oid import ExtensionOID
cert_obj = load_pem_x509_certificate(pem_data.encode(), default_backend())
try:
san_ext = cert_obj.extensions.get_extension_for_oid(
ExtensionOID.SUBJECT_ALTERNATIVE_NAME
)
dns_list = []
for dns in san_ext.value:
val = dns.value
if isinstance(val, bytes):
try:
import ipaddress
val = str(ipaddress.ip_address(val))
except Exception:
val = val.decode('utf-8', errors='ignore')
elif not isinstance(val, str):
val = str(val)
dns_list.append(val)
result['dns'] = dns_list
except Exception:
pass
except Exception:
# fallback
try:
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])
except:
pass
subject = x509.get_subject().get_components()
# 取主要认证名称
if len(subject) == 1:
+3 -3
View File
@@ -432,7 +432,7 @@ class SSLManger:
AES = AesCryptPy3(key, "CBC", iv, char_set="utf8")
# 对接云端
url = "https://wafapi2.aapanel.com/api/Cert_cloud_deploy/get_cert_list"
url = f"{public.OfficialWaf2Base()}/api/Cert_cloud_deploy/get_cert_list"
try:
res_text = public.httpPost(url, {
"uid": user_info["uid"],
@@ -630,7 +630,7 @@ class SSLManger:
ssl_db.connection().delete(id=target["id"])
if target["cloud_id"] != -1:
url = "https://wafapi2.aapanel.com/api/Cert_cloud_deploy/del_cert"
url = f"{public.OfficialWaf2Base()}/api/Cert_cloud_deploy/del_cert"
try:
res_text = public.httpPost(url, {
"cert_id": target["cloud_id"],
@@ -677,7 +677,7 @@ class SSLManger:
data["privateKey"] = AES.aes_encrypt(data["privateKey"])
data["certificate"] = AES.aes_encrypt(data["certificate"])
# 对接云端
url = "https://wafapi2.aapanel.com/api/Cert_cloud_deploy/cloud_deploy"
url = f"{public.OfficialWaf2Base()}/api/Cert_cloud_deploy/cloud_deploy"
try:
res_text = public.httpPost(url, data)
+2 -2
View File
@@ -993,12 +993,12 @@ class system:
#修复面板
def RepPanel(self,get):
public.writeFile('data/js_random.pl','1')
public.ExecShell("wget --no-check-certificate -O update.sh " + public.get_url() + "/install/update_7.x_en.sh && bash update.sh")
public.ExecShell("wget --no-check-certificate -O update.sh " + public.get_url() + "/install/update_panel_en.sh && bash update.sh")
self.ReWeb(None)
return True
#升级到专业版
def UpdatePro(self,get):
public.ExecShell("wget --no-check-certificate -O update.sh " + public.get_url() + "/install/update_7.x_en.sh && bash update.sh")
public.ExecShell("wget --no-check-certificate -O update.sh " + public.get_url() + "/install/update_panel_en.sh && bash update.sh")
self.ReWeb(None)
return True
+2
View File
@@ -75,6 +75,8 @@ class userLang:
name = post.name
if name == "":
return
if len(name) > 5:
return
# 登录阶段不设置 只记录
path = "/www/server/panel/BTPanel/languages/language.pl"
+1 -1
View File
@@ -220,7 +220,7 @@ class userlogin:
# 提交
if not public.cache_get(pkey):
try:
public.run_thread(public.httpPost, ("https://geterror.aapanel.com/bt_error/index.php", error_infos))
public.run_thread(public.httpPost, (f"{public.OfficialGetErrorBase()}/bt_error/index.php", error_infos))
public.cache_set(pkey, 1, 1800)
except Exception:
pass