Initial commit
This commit is contained in:
0
core/__init__.py
Normal file
0
core/__init__.py
Normal file
BIN
core/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
core/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/admin.cpython-312.pyc
Normal file
BIN
core/__pycache__/admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/apps.cpython-312.pyc
Normal file
BIN
core/__pycache__/apps.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/goedge_client.cpython-312.pyc
Normal file
BIN
core/__pycache__/goedge_client.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/models.cpython-312.pyc
Normal file
BIN
core/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/tests.cpython-312.pyc
Normal file
BIN
core/__pycache__/tests.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/utils.cpython-312.pyc
Normal file
BIN
core/__pycache__/utils.cpython-312.pyc
Normal file
Binary file not shown.
25
core/admin.py
Normal file
25
core/admin.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from django.contrib import admin
|
||||
from .models import SystemSettings, OperationLog
|
||||
|
||||
|
||||
@admin.register(SystemSettings)
|
||||
class SystemSettingsAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
'goedge_base_url',
|
||||
'admin_access_key_id',
|
||||
'default_node_cluster_id',
|
||||
'edge_access_token',
|
||||
'edge_token_expires_at',
|
||||
'default_free_traffic_gb_per_domain',
|
||||
'cname_template',
|
||||
'updated_at',
|
||||
)
|
||||
readonly_fields = ('edge_access_token', 'edge_token_expires_at')
|
||||
|
||||
|
||||
@admin.register(OperationLog)
|
||||
class OperationLogAdmin(admin.ModelAdmin):
|
||||
list_display = ('actor', 'action', 'target', 'created_at')
|
||||
search_fields = ('actor__username', 'action', 'target')
|
||||
|
||||
# Register your models here.
|
||||
6
core/apps.py
Normal file
6
core/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'core'
|
||||
653
core/goedge_client.py
Normal file
653
core/goedge_client.py
Normal file
@@ -0,0 +1,653 @@
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
from urllib.parse import urlparse
|
||||
from requests import RequestException
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import SystemSettings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
DEFAULT_TIMEOUT = (5, 15) # (connect_timeout, read_timeout)
|
||||
|
||||
|
||||
class GoEdgeClient:
|
||||
"""
|
||||
GoEdge 管理员 API 客户端封装:
|
||||
- 令牌获取与缓存(SystemSettings + 环境变量回退)
|
||||
- 网站创建(HTTP 反向代理)
|
||||
- 流量统计读取(按日、带宽峰值)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
settings = self._get_settings()
|
||||
self.base_url: str = settings.goedge_base_url or ''
|
||||
self.admin_access_key_id: str = settings.admin_access_key_id or os.getenv('GOEDGE_ACCESS_KEY_ID', '')
|
||||
self.admin_access_key: str = settings.admin_access_key or os.getenv('GOEDGE_ACCESS_KEY', '')
|
||||
self._token: str = settings.edge_access_token or os.getenv('GOEDGE_ACCESS_TOKEN', '')
|
||||
self._token_exp: Optional[datetime.datetime] = settings.edge_token_expires_at
|
||||
# 基础 URL 校验(提前在初始化阶段进行)
|
||||
self._validate_base_url()
|
||||
|
||||
@staticmethod
|
||||
def _get_settings() -> SystemSettings:
|
||||
# 只取第一条设置
|
||||
settings = SystemSettings.objects.order_by('id').first()
|
||||
if not settings:
|
||||
settings = SystemSettings.objects.create()
|
||||
return settings
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
token = self._ensure_token()
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Edge-Access-Token': token,
|
||||
}
|
||||
|
||||
def _ensure_token(self) -> str:
|
||||
# 若令牌存在且未知过期或仍在有效期,则优先使用令牌
|
||||
if self._token:
|
||||
if not self._token_exp or self._token_exp > timezone.now():
|
||||
return self._token
|
||||
# 通过 AccessKeyId/AccessKey 获取令牌
|
||||
if not (self.base_url and self.admin_access_key_id and self.admin_access_key):
|
||||
raise RuntimeError('GoEdge API配置不完整:需 base_url、access_key_id、access_key 或提供现成令牌')
|
||||
url = self._join('/APIAccessTokenService/getAPIAccessToken')
|
||||
payload = {
|
||||
'type': 'admin',
|
||||
'accessKeyId': self.admin_access_key_id,
|
||||
'accessKey': self.admin_access_key,
|
||||
}
|
||||
resp = requests.post(url, headers={'Content-Type': 'application/json'}, data=json.dumps(payload))
|
||||
try:
|
||||
resp = requests.post(url, headers={'Content-Type': 'application/json'}, data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'获取AccessToken请求失败:{e}')
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if int(data.get('code', 500)) != 200:
|
||||
raise RuntimeError(f'获取AccessToken失败: {data}')
|
||||
token = data['data']['token']
|
||||
expires_at = data['data'].get('expiresAt')
|
||||
# 更新缓存与数据库
|
||||
self._token = token
|
||||
if expires_at:
|
||||
try:
|
||||
exp_dt = datetime.datetime.fromtimestamp(int(expires_at), tz=timezone.utc)
|
||||
except Exception:
|
||||
exp_dt = timezone.now() + datetime.timedelta(hours=6)
|
||||
else:
|
||||
exp_dt = timezone.now() + datetime.timedelta(hours=6)
|
||||
self._token_exp = exp_dt
|
||||
settings = self._get_settings()
|
||||
settings.edge_access_token = token
|
||||
settings.edge_token_expires_at = exp_dt
|
||||
settings.save(update_fields=['edge_access_token', 'edge_token_expires_at'])
|
||||
return token
|
||||
|
||||
def _join(self, path: str) -> str:
|
||||
return (self.base_url.rstrip('/') + '/' + path.lstrip('/'))
|
||||
|
||||
def _validate_base_url(self) -> None:
|
||||
"""校验 base_url 的格式与端口范围,便于快速定位配置错误。"""
|
||||
if not self.base_url:
|
||||
raise RuntimeError('GoEdge API配置不完整:缺少 base_url (GOEDGE_ADMIN_API_BASE_URL)')
|
||||
p = urlparse(self.base_url)
|
||||
if not p.scheme or not p.netloc:
|
||||
raise RuntimeError(f'GOEDGE_ADMIN_API_BASE_URL无效:{self.base_url}')
|
||||
# 校验端口
|
||||
if ':' in p.netloc:
|
||||
host, port_str = p.netloc.rsplit(':', 1)
|
||||
try:
|
||||
port = int(port_str)
|
||||
if port < 1 or port > 65535:
|
||||
raise ValueError('port out of range')
|
||||
except Exception:
|
||||
raise RuntimeError(f'GOEDGE_ADMIN_API_BASE_URL端口非法(需在1-65535):{self.base_url}')
|
||||
|
||||
# ---------- 业务方法 ----------
|
||||
def create_basic_http_server(
|
||||
self,
|
||||
domains: List[str],
|
||||
origin_addrs: List[str],
|
||||
user_id: int = 0,
|
||||
enable_websocket: bool = False,
|
||||
node_cluster_id: int = 0,
|
||||
ssl_cert_ids: Optional[List[int]] = None,
|
||||
) -> int:
|
||||
"""调用 createBasicHTTPServer 快速创建HTTP网站并返回 serverId。"""
|
||||
# 计算有效的 nodeClusterId:优先入参,其次 SystemSettings,最后环境变量
|
||||
effective_cluster_id = node_cluster_id
|
||||
if not effective_cluster_id:
|
||||
s = self._get_settings()
|
||||
effective_cluster_id = s.default_node_cluster_id or 0
|
||||
if not effective_cluster_id:
|
||||
env_val = os.getenv('GOEDGE_DEFAULT_NODE_CLUSTER_ID', '').strip()
|
||||
if env_val:
|
||||
try:
|
||||
effective_cluster_id = int(env_val)
|
||||
except Exception:
|
||||
raise RuntimeError(f"GOEDGE_DEFAULT_NODE_CLUSTER_ID 非法:{env_val}")
|
||||
if not effective_cluster_id:
|
||||
raise RuntimeError("未配置 nodeClusterId:请在 SystemSettings.default_node_cluster_id 或 .env(GOEDGE_DEFAULT_NODE_CLUSTER_ID) 设置有效集群ID")
|
||||
url = self._join('/ServerService/createBasicHTTPServer')
|
||||
payload = {
|
||||
'nodeClusterId': effective_cluster_id,
|
||||
'userId': user_id,
|
||||
'domains': domains,
|
||||
'sslCertIds': ssl_cert_ids or [],
|
||||
'originAddrs': origin_addrs,
|
||||
'enableWebsocket': enable_websocket,
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'创建HTTP网站请求失败:{e}')
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
server_id = data.get('serverId')
|
||||
if not server_id:
|
||||
raise RuntimeError(f'创建HTTP网站失败: {data}')
|
||||
return int(server_id)
|
||||
|
||||
def create_server_http_proxy(
|
||||
self,
|
||||
name: str,
|
||||
domains: List[str],
|
||||
reverse_proxy_json: Dict[str, Any],
|
||||
user_id: int = 0,
|
||||
node_cluster_id: int = 0,
|
||||
server_group_ids: Optional[List[int]] = None,
|
||||
) -> int:
|
||||
"""更灵活的 createServer 创建 httpProxy 类型服务。"""
|
||||
url = self._join('/ServerService/createServer')
|
||||
# serverNamesJSON 与 reverseProxyJSON 需要 Base64
|
||||
server_names_json = base64.b64encode(json.dumps({
|
||||
'names': [{'name': d} for d in domains]
|
||||
}).encode('utf-8')).decode('utf-8')
|
||||
reverse_proxy_b64 = base64.b64encode(json.dumps(reverse_proxy_json).encode('utf-8')).decode('utf-8')
|
||||
# 计算有效的 nodeClusterId:优先入参,其次 SystemSettings,最后环境变量
|
||||
effective_cluster_id = node_cluster_id
|
||||
if not effective_cluster_id:
|
||||
s = self._get_settings()
|
||||
effective_cluster_id = s.default_node_cluster_id or 0
|
||||
if not effective_cluster_id:
|
||||
env_val = os.getenv('GOEDGE_DEFAULT_NODE_CLUSTER_ID', '').strip()
|
||||
if env_val:
|
||||
try:
|
||||
effective_cluster_id = int(env_val)
|
||||
except Exception:
|
||||
raise RuntimeError(f"GOEDGE_DEFAULT_NODE_CLUSTER_ID 非法:{env_val}")
|
||||
if not effective_cluster_id:
|
||||
raise RuntimeError("未配置 nodeClusterId:请在 SystemSettings.default_node_cluster_id 或 .env(GOEDGE_DEFAULT_NODE_CLUSTER_ID) 设置有效集群ID")
|
||||
payload = {
|
||||
'type': 'httpProxy',
|
||||
'name': name,
|
||||
'description': name,
|
||||
'userId': user_id,
|
||||
'nodeClusterId': effective_cluster_id,
|
||||
'serverNamesJSON': server_names_json,
|
||||
'reverseProxyJSON': reverse_proxy_b64,
|
||||
'serverGroupIds': server_group_ids or [],
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'创建网站请求失败:{e}')
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
server_id = data.get('serverId')
|
||||
if not server_id:
|
||||
raise RuntimeError(f'创建网站失败: {data}')
|
||||
return int(server_id)
|
||||
|
||||
def find_latest_server_daily_stats(self, server_id: int, days: int = 1) -> List[Dict[str, Any]]:
|
||||
url = self._join('/ServerDailyStatService/findLatestServerDailyStats')
|
||||
payload = {'serverId': server_id, 'days': days}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'读取每日统计请求失败:{e}')
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get('stats', [])
|
||||
|
||||
def find_latest_server_hourly_stats(self, server_id: int, hours: int = 24) -> List[Dict[str, Any]]:
|
||||
url = self._join('/ServerDailyStatService/findLatestServerHourlyStats')
|
||||
payload = {'serverId': int(server_id), 'hours': int(hours)}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'读取每小时请求数失败:{e}')
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get('stats', [])
|
||||
|
||||
def find_daily_bandwidth_stats(self, server_id: int, days: int = 1, algo: str = 'avg') -> List[Dict[str, Any]]:
|
||||
url = self._join('/ServerBandwidthStatService/findDailyServerBandwidthStats')
|
||||
payload = {'serverId': server_id, 'days': days, 'algo': algo}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'读取带宽统计请求失败:{e}')
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get('stats', [])
|
||||
|
||||
# ---------- 配置查询与更新(Web / HTTPS / 策略) ----------
|
||||
def find_server_web_id(self, server_id: int) -> Optional[int]:
|
||||
"""通过 findEnabledServerConfig 解析 serverJSON 获取 webId。"""
|
||||
url = self._join('/ServerService/findEnabledServerConfig')
|
||||
payload = {'serverId': server_id}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'查询网站配置失败:{e}')
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
b64 = data.get('serverJSON')
|
||||
if not b64:
|
||||
return None
|
||||
try:
|
||||
conf = json.loads(base64.b64decode(b64).decode('utf-8'))
|
||||
except Exception:
|
||||
return None
|
||||
web_id = conf.get('webId')
|
||||
if web_id is None:
|
||||
# 兼容嵌套结构(某些版本可能放在 web 字段)
|
||||
web_id = (conf.get('web') or {}).get('webId')
|
||||
try:
|
||||
return int(web_id) if web_id is not None else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def update_http_web_access_log(self, http_web_id: int, is_on: bool, policy_id: Optional[int] = None) -> None:
|
||||
"""更新 Web 访问日志设置;当开启时可引用既有访问日志策略ID。"""
|
||||
url = self._join('/HTTPWebService/updateHTTPWebAccessLog')
|
||||
payload_obj: Dict[str, Any] = {'isOn': bool(is_on)}
|
||||
if is_on and policy_id:
|
||||
payload_obj['policyRef'] = {'isOn': True, 'policyId': int(policy_id)}
|
||||
access_log_b64 = base64.b64encode(json.dumps(payload_obj).encode('utf-8')).decode('utf-8')
|
||||
payload = {'httpWebId': http_web_id, 'accessLogJSON': access_log_b64}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'更新访问日志配置失败:{e}')
|
||||
resp.raise_for_status()
|
||||
|
||||
def update_http_web_websocket(self, http_web_id: int, is_on: bool) -> None:
|
||||
"""更新 WebSocket 开关(最简配置)。"""
|
||||
url = self._join('/HTTPWebService/updateHTTPWebWebsocket')
|
||||
ws_b64 = base64.b64encode(json.dumps({'isOn': bool(is_on)}).encode('utf-8')).decode('utf-8')
|
||||
payload = {'httpWebId': http_web_id, 'websocketJSON': ws_b64}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'更新WebSocket配置失败:{e}')
|
||||
resp.raise_for_status()
|
||||
|
||||
def find_server_ssl_policy_id(self, server_id: int) -> Optional[int]:
|
||||
"""通过网站配置解析 sslPolicyId。可能存在 https.sslPolicyId 或 https.sslPolicyRef.policyId。"""
|
||||
url = self._join('/ServerService/findEnabledServerConfig')
|
||||
payload = {'serverId': server_id}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'查询网站配置失败:{e}')
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
b64 = data.get('serverJSON')
|
||||
if not b64:
|
||||
return None
|
||||
try:
|
||||
conf = json.loads(base64.b64decode(b64).decode('utf-8'))
|
||||
except Exception:
|
||||
return None
|
||||
https_conf = conf.get('https') or {}
|
||||
ssl_policy_id = None
|
||||
if isinstance(https_conf, dict):
|
||||
ssl_policy_id = https_conf.get('sslPolicyId')
|
||||
if not ssl_policy_id:
|
||||
ref = https_conf.get('sslPolicyRef') or {}
|
||||
ssl_policy_id = ref.get('sslPolicyId') or ref.get('policyId')
|
||||
try:
|
||||
return int(ssl_policy_id) if ssl_policy_id is not None else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def update_ssl_policy_http3(self, ssl_policy_id: int, enabled: bool) -> None:
|
||||
"""切换 SSL 策略的 HTTP/3 开关。"""
|
||||
url = self._join('/SSLPolicyService/updateSSLPolicy')
|
||||
payload = {'sslPolicyId': int(ssl_policy_id), 'http3Enabled': bool(enabled)}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'更新SSL策略失败:{e}')
|
||||
resp.raise_for_status()
|
||||
|
||||
def update_http_web_firewall(self, http_web_id: int, is_on: bool, policy_id: Optional[int] = None) -> None:
|
||||
"""更新 Web 防火墙(WAF)设置;可引用既有防火墙策略ID。"""
|
||||
url = self._join('/HTTPWebService/updateHTTPWebFirewall')
|
||||
firewall_obj: Dict[str, Any] = {'isOn': bool(is_on)}
|
||||
if is_on and policy_id:
|
||||
firewall_obj['firewallPolicyRef'] = {'isOn': True, 'policyId': int(policy_id)}
|
||||
firewall_b64 = base64.b64encode(json.dumps(firewall_obj).encode('utf-8')).decode('utf-8')
|
||||
payload = {'httpWebId': http_web_id, 'firewallJSON': firewall_b64}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'更新防火墙配置失败:{e}')
|
||||
resp.raise_for_status()
|
||||
|
||||
# ---------- 查询状态与策略校验 ----------
|
||||
def find_http_web_config(self, http_web_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""查询 HTTPWeb 配置并返回解析后的 JSON。"""
|
||||
url = self._join('/HTTPWebService/findEnabledHTTPWebConfig')
|
||||
payload = {'httpWebId': http_web_id}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'查询HTTPWeb配置失败:{e}')
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
b64 = data.get('httpWebJSON')
|
||||
if not b64:
|
||||
return None
|
||||
try:
|
||||
conf = json.loads(base64.b64decode(b64).decode('utf-8'))
|
||||
except Exception:
|
||||
return None
|
||||
return conf
|
||||
|
||||
def get_server_feature_status(self, server_id: int) -> Dict[str, Any]:
|
||||
"""汇总服务器的特性开关状态(访问日志、WebSocket、WAF、HTTP/3)。"""
|
||||
status: Dict[str, Any] = {
|
||||
'serverId': server_id,
|
||||
'webId': None,
|
||||
'accessLog': {'isOn': None, 'policyId': None},
|
||||
'websocket': {'isOn': None},
|
||||
'firewall': {'isOn': None, 'policyId': None},
|
||||
'sslPolicy': {'id': None, 'http3Enabled': None},
|
||||
}
|
||||
# webId
|
||||
try:
|
||||
web_id = self.find_server_web_id(server_id)
|
||||
status['webId'] = web_id
|
||||
except Exception:
|
||||
logger.exception('find webId failed')
|
||||
web_id = None
|
||||
# httpWeb 状态
|
||||
try:
|
||||
if web_id:
|
||||
web_conf = self.find_http_web_config(web_id) or {}
|
||||
# 访问日志
|
||||
al = web_conf.get('accessLog') or web_conf.get('accessLogRef') or {}
|
||||
status['accessLog']['isOn'] = bool(al.get('isOn')) if isinstance(al, dict) else None
|
||||
status['accessLog']['policyId'] = (al.get('policyId') or al.get('policyRef', {}).get('policyId')) if isinstance(al, dict) else None
|
||||
# WebSocket
|
||||
ws = web_conf.get('websocket') or web_conf.get('websocketRef') or {}
|
||||
status['websocket']['isOn'] = bool(ws.get('isOn')) if isinstance(ws, dict) else None
|
||||
# 防火墙
|
||||
fw = web_conf.get('firewall') or web_conf.get('firewallRef') or web_conf.get('firewallPolicyRef') or {}
|
||||
status['firewall']['isOn'] = bool(fw.get('isOn')) if isinstance(fw, dict) else None
|
||||
status['firewall']['policyId'] = (fw.get('policyId') or fw.get('firewallPolicyId') or fw.get('policyRef', {}).get('policyId')) if isinstance(fw, dict) else None
|
||||
except Exception:
|
||||
logger.exception('parse httpWeb config failed')
|
||||
# SSL策略与HTTP/3
|
||||
try:
|
||||
ssl_id = self.find_server_ssl_policy_id(server_id)
|
||||
status['sslPolicy']['id'] = ssl_id
|
||||
if ssl_id is not None:
|
||||
url = self._join('/SSLPolicyService/findEnabledSSLPolicyConfig')
|
||||
payload = {'sslPolicyId': int(ssl_id)}
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
b64 = data.get('sslPolicyJSON')
|
||||
if b64:
|
||||
try:
|
||||
conf = json.loads(base64.b64decode(b64).decode('utf-8'))
|
||||
status['sslPolicy']['http3Enabled'] = bool(conf.get('http3Enabled')) if isinstance(conf, dict) else None
|
||||
except Exception:
|
||||
status['sslPolicy']['http3Enabled'] = None
|
||||
except Exception:
|
||||
logger.exception('query sslPolicy/http3 failed')
|
||||
return status
|
||||
|
||||
def check_access_log_policy_exists(self, policy_id: int) -> bool:
|
||||
"""校验访问日志策略是否存在/启用。"""
|
||||
url = self._join('/HTTPAccessLogPolicyService/findHTTPAccessLogPolicy')
|
||||
payload = {'httpAccessLogPolicyId': int(policy_id)}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'查询访问日志策略失败:{e}')
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
policy = data.get('httpAccessLogPolicy') or data.get('policy')
|
||||
return bool(policy and (policy.get('id') or policy.get('policyId')))
|
||||
|
||||
def check_firewall_policy_exists(self, policy_id: int) -> bool:
|
||||
"""校验防火墙策略是否存在/启用。"""
|
||||
url = self._join('/HTTPFirewallPolicyService/findEnabledHTTPFirewallPolicy')
|
||||
payload = {'httpFirewallPolicyId': int(policy_id)}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'查询防火墙策略失败:{e}')
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
policy = data.get('httpFirewallPolicy') or data.get('policy')
|
||||
return bool(policy and (policy.get('id') or policy.get('policyId')))
|
||||
|
||||
def update_http_web_cache(self, http_web_id: int, cache_conf: Dict[str, Any]) -> None:
|
||||
url = self._join('/HTTPWebService/updateHTTPWebCache')
|
||||
cache_b64 = base64.b64encode(json.dumps(cache_conf).encode('utf-8')).decode('utf-8')
|
||||
payload = {'httpWebId': http_web_id, 'cacheJSON': cache_b64}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'更新缓存配置失败:{e}')
|
||||
resp.raise_for_status()
|
||||
|
||||
def update_http_web_locations(self, http_web_id: int, locations_conf: Dict[str, Any]) -> None:
|
||||
url = self._join('/HTTPWebService/updateHTTPWebLocations')
|
||||
locations_b64 = base64.b64encode(json.dumps(locations_conf).encode('utf-8')).decode('utf-8')
|
||||
payload = {'httpWebId': http_web_id, 'locationsJSON': locations_b64}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'更新路径规则失败:{e}')
|
||||
resp.raise_for_status()
|
||||
|
||||
def update_http_web_rewrite_rules(self, http_web_id: int, rewrite_conf: Dict[str, Any]) -> None:
|
||||
url = self._join('/HTTPWebService/updateHTTPWebRewriteRules')
|
||||
rewrite_b64 = base64.b64encode(json.dumps(rewrite_conf).encode('utf-8')).decode('utf-8')
|
||||
payload = {'httpWebId': http_web_id, 'rewriteRulesJSON': rewrite_b64}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'更新重写规则失败:{e}')
|
||||
resp.raise_for_status()
|
||||
|
||||
def update_http_web_redirect_to_https(self, http_web_id: int, redirect_conf: Dict[str, Any]) -> None:
|
||||
url = self._join('/HTTPWebService/updateHTTPWebRedirectToHTTPS')
|
||||
b64 = base64.b64encode(json.dumps(redirect_conf).encode('utf-8')).decode('utf-8')
|
||||
payload = {'httpWebId': http_web_id, 'redirectToHTTPSJSON': b64}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'更新HTTPS跳转失败:{e}')
|
||||
resp.raise_for_status()
|
||||
|
||||
def update_http_web_referers(self, http_web_id: int, referers_conf: Dict[str, Any]) -> None:
|
||||
url = self._join('/HTTPWebService/updateHTTPWebReferers')
|
||||
b64 = base64.b64encode(json.dumps(referers_conf).encode('utf-8')).decode('utf-8')
|
||||
payload = {'httpWebId': http_web_id, 'referersJSON': b64}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'更新防盗链配置失败:{e}')
|
||||
resp.raise_for_status()
|
||||
|
||||
def update_http_web_remote_addr(self, http_web_id: int, remote_addr_conf: Dict[str, Any]) -> None:
|
||||
url = self._join('/HTTPWebService/updateHTTPWebRemoteAddr')
|
||||
b64 = base64.b64encode(json.dumps(remote_addr_conf).encode('utf-8')).decode('utf-8')
|
||||
payload = {'httpWebId': http_web_id, 'remoteAddrJSON': b64}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'更新客户端IP解析失败:{e}')
|
||||
resp.raise_for_status()
|
||||
|
||||
def update_http_web_shutdown(self, http_web_id: int, shutdown_conf: Dict[str, Any]) -> None:
|
||||
url = self._join('/HTTPWebService/updateHTTPWebShutdown')
|
||||
b64 = base64.b64encode(json.dumps(shutdown_conf).encode('utf-8')).decode('utf-8')
|
||||
payload = {'httpWebId': http_web_id, 'shutdownJSON': b64}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'更新停服配置失败:{e}')
|
||||
resp.raise_for_status()
|
||||
|
||||
def update_http_web_request_limit(self, http_web_id: int, request_limit_conf: Dict[str, Any]) -> None:
|
||||
url = self._join('/HTTPWebService/updateHTTPWebRequestLimit')
|
||||
b64 = base64.b64encode(json.dumps(request_limit_conf).encode('utf-8')).decode('utf-8')
|
||||
payload = {'httpWebId': http_web_id, 'requestLimitJSON': b64}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'更新请求限速失败:{e}')
|
||||
resp.raise_for_status()
|
||||
|
||||
def list_http_access_logs(
|
||||
self,
|
||||
server_id: int,
|
||||
day: Optional[str] = None,
|
||||
size: int = 50,
|
||||
hour_from: Optional[str] = None,
|
||||
hour_to: Optional[str] = None,
|
||||
reverse: bool = False,
|
||||
ip: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
request_id: Optional[str] = None,
|
||||
partition: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
url = self._join('/HTTPAccessLogService/listHTTPAccessLogs')
|
||||
payload: Dict[str, Any] = {
|
||||
'serverId': int(server_id),
|
||||
'size': int(size),
|
||||
}
|
||||
if day:
|
||||
payload['day'] = day
|
||||
if hour_from:
|
||||
payload['hourFrom'] = hour_from
|
||||
if hour_to:
|
||||
payload['hourTo'] = hour_to
|
||||
if reverse:
|
||||
payload['reverse'] = True
|
||||
if ip:
|
||||
payload['ip'] = ip
|
||||
if keyword:
|
||||
payload['keyword'] = keyword
|
||||
if request_id:
|
||||
payload['requestId'] = request_id
|
||||
if partition is not None:
|
||||
payload['partition'] = int(partition)
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'查询访问日志失败:{e}')
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return {
|
||||
'logs': data.get('httpAccessLogs') or [],
|
||||
'requestId': data.get('requestId'),
|
||||
'hasMore': bool(data.get('hasMore')),
|
||||
}
|
||||
|
||||
def find_http_access_log_partitions(self, day: str) -> Dict[str, Any]:
|
||||
url = self._join('/HTTPAccessLogService/findHTTPAccessLogPartitions')
|
||||
payload = {'day': day}
|
||||
try:
|
||||
resp = requests.post(url, headers=self._headers(), data=json.dumps(payload), timeout=DEFAULT_TIMEOUT)
|
||||
except RequestException as e:
|
||||
raise RuntimeError(f'查询访问日志分区失败:{e}')
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return {
|
||||
'partitions': data.get('partitions') or [],
|
||||
'reversePartitions': data.get('reversePartitions') or [],
|
||||
}
|
||||
|
||||
def aggregate_status_codes(
|
||||
self,
|
||||
server_id: int,
|
||||
day: Optional[str] = None,
|
||||
hour_from: Optional[str] = None,
|
||||
hour_to: Optional[str] = None,
|
||||
size: int = 1000,
|
||||
max_pages_per_partition: int = 200,
|
||||
) -> Dict[str, Any]:
|
||||
if not day:
|
||||
day = timezone.now().strftime('%Y%m%d')
|
||||
parts = []
|
||||
try:
|
||||
part_res = self.find_http_access_log_partitions(day)
|
||||
parts = part_res.get('partitions') or []
|
||||
except Exception:
|
||||
parts = [None]
|
||||
counts: Dict[str, int] = {}
|
||||
bins: Dict[str, int] = {'2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0}
|
||||
for p in parts:
|
||||
req_id = None
|
||||
pages = 0
|
||||
while pages < max_pages_per_partition:
|
||||
pages += 1
|
||||
try:
|
||||
res = self.list_http_access_logs(
|
||||
server_id=server_id,
|
||||
day=day,
|
||||
size=size,
|
||||
hour_from=hour_from,
|
||||
hour_to=hour_to,
|
||||
reverse=False,
|
||||
request_id=req_id,
|
||||
partition=(p if p is not None else None),
|
||||
)
|
||||
except Exception:
|
||||
break
|
||||
logs = res.get('logs') or []
|
||||
for item in logs:
|
||||
code = str(item.get('status')) if isinstance(item, dict) else None
|
||||
if not code:
|
||||
continue
|
||||
try:
|
||||
c = int(code)
|
||||
except Exception:
|
||||
continue
|
||||
counts[code] = counts.get(code, 0) + 1
|
||||
if 200 <= c <= 299:
|
||||
bins['2xx'] += 1
|
||||
elif 300 <= c <= 399:
|
||||
bins['3xx'] += 1
|
||||
elif 400 <= c <= 499:
|
||||
bins['4xx'] += 1
|
||||
elif 500 <= c <= 599:
|
||||
bins['5xx'] += 1
|
||||
req_id = res.get('requestId')
|
||||
has_more = bool(res.get('hasMore'))
|
||||
if not has_more:
|
||||
break
|
||||
total = sum(counts.values())
|
||||
top = sorted(counts.items(), key=lambda x: -x[1])[:10]
|
||||
return {'total': total, 'bins': bins, 'top': top}
|
||||
|
||||
|
||||
__all__ = ['GoEdgeClient']
|
||||
93
core/management/commands/seed_demo_data.py
Normal file
93
core/management/commands/seed_demo_data.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
|
||||
from plans.models import Plan
|
||||
from domains.models import Domain
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "导入演示套餐并可选创建演示域名。使用 --user-id 为指定用户创建域名。"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--user-id', type=int, help='为该用户创建示例域名(可选)')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
free, _ = Plan.objects.get_or_create(
|
||||
name='Free',
|
||||
defaults={
|
||||
'description': '基础免费套餐,每域名每月包含 15GB。',
|
||||
'billing_mode': 'per_domain_monthly',
|
||||
'base_price_per_domain': 0,
|
||||
'included_traffic_gb_per_domain': 15,
|
||||
'overage_price_per_gb': 0.50,
|
||||
'allow_overage': True,
|
||||
'is_active': True,
|
||||
'is_public': True,
|
||||
'features': {
|
||||
'waf_enabled': False,
|
||||
'http3_enabled': False,
|
||||
'logs_enabled': False,
|
||||
'websocket_enabled': True,
|
||||
}
|
||||
}
|
||||
)
|
||||
pro, _ = Plan.objects.get_or_create(
|
||||
name='Pro',
|
||||
defaults={
|
||||
'description': '专业版,每域名每月包含 200GB,支持更多功能。',
|
||||
'billing_mode': 'per_domain_monthly',
|
||||
'base_price_per_domain': 49.00,
|
||||
'included_traffic_gb_per_domain': 200,
|
||||
'overage_price_per_gb': 0.30,
|
||||
'allow_overage': True,
|
||||
'is_active': True,
|
||||
'is_public': True,
|
||||
'features': {
|
||||
'waf_enabled': True,
|
||||
'http3_enabled': True,
|
||||
'logs_enabled': True,
|
||||
'websocket_enabled': True,
|
||||
}
|
||||
}
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f"已准备套餐:Free({free.id}), Pro({pro.id})"))
|
||||
|
||||
user_id = options.get('user_id')
|
||||
if not user_id:
|
||||
self.stdout.write(self.style.NOTICE("未提供 --user-id,跳过示例域名创建。"))
|
||||
return
|
||||
user = User.objects.filter(id=user_id).first()
|
||||
if not user:
|
||||
self.stdout.write(self.style.ERROR(f"用户 {user_id} 不存在,跳过域名创建。"))
|
||||
return
|
||||
|
||||
today = timezone.now().date()
|
||||
month_start = today.replace(day=1)
|
||||
# 下月第一天
|
||||
if month_start.month == 12:
|
||||
next_month_start = month_start.replace(year=month_start.year + 1, month=1, day=1)
|
||||
else:
|
||||
next_month_start = month_start.replace(month=month_start.month + 1, day=1)
|
||||
current_cycle_end = next_month_start - timezone.timedelta(days=1)
|
||||
|
||||
d, created = Domain.objects.get_or_create(
|
||||
user=user,
|
||||
name='example.com',
|
||||
defaults={
|
||||
'status': Domain.STATUS_PENDING,
|
||||
'current_plan': free,
|
||||
'current_cycle_start': month_start,
|
||||
'current_cycle_end': current_cycle_end,
|
||||
'cname_targets': {'www.example.com': 'www.cdn.example.com'},
|
||||
'origin_config': {'host': 'origin.example.com', 'protocol': 'http', 'port': 80},
|
||||
'edge_server_id': None,
|
||||
}
|
||||
)
|
||||
if created:
|
||||
self.stdout.write(self.style.SUCCESS(f"已创建演示域名:{d.name}(用户ID {user.id})"))
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING(f"演示域名已存在:{d.name}(用户ID {user.id})"))
|
||||
50
core/migrations/0001_initial.py
Normal file
50
core/migrations/0001_initial.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-07 07:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SystemSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('goedge_base_url', models.URLField(blank=True, default='')),
|
||||
('edge_access_token', models.CharField(blank=True, default='', max_length=255)),
|
||||
('default_free_traffic_gb_per_domain', models.PositiveIntegerField(default=15)),
|
||||
('default_overage_policy', models.JSONField(blank=True, default=dict)),
|
||||
('cname_template', models.CharField(blank=True, default='{sub}.cdn.example.com', max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '系统设置',
|
||||
'verbose_name_plural': '系统设置',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OperationLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('action', models.CharField(max_length=100)),
|
||||
('target', models.CharField(blank=True, default='', max_length=200)),
|
||||
('detail', models.TextField(blank=True, default='')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '操作日志',
|
||||
'verbose_name_plural': '操作日志',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-07 07:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='systemsettings',
|
||||
name='admin_access_key',
|
||||
field=models.CharField(blank=True, default='', max_length=128),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='systemsettings',
|
||||
name='admin_access_key_id',
|
||||
field=models.CharField(blank=True, default='', max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='systemsettings',
|
||||
name='edge_token_expires_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-07 08:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_systemsettings_admin_access_key_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='systemsettings',
|
||||
name='default_node_cluster_id',
|
||||
field=models.BigIntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-08 08:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0003_systemsettings_default_node_cluster_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='systemsettings',
|
||||
name='anomaly_detection_enabled',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='systemsettings',
|
||||
name='anomaly_min_gb',
|
||||
field=models.FloatField(default=1.0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='systemsettings',
|
||||
name='anomaly_threshold_multiplier',
|
||||
field=models.FloatField(default=3.0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='systemsettings',
|
||||
name='anomaly_window_days',
|
||||
field=models.PositiveIntegerField(default=7),
|
||||
),
|
||||
]
|
||||
18
core/migrations/0005_systemsettings_captcha_enabled.py
Normal file
18
core/migrations/0005_systemsettings_captcha_enabled.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-09 08:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_systemsettings_anomaly_detection_enabled_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='systemsettings',
|
||||
name='captcha_enabled',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
21
core/migrations/0006_systemsettings_policy_ids.py
Normal file
21
core/migrations/0006_systemsettings_policy_ids.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_systemsettings_captcha_enabled'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='systemsettings',
|
||||
name='default_http_access_log_policy_id',
|
||||
field=models.BigIntegerField(null=True, blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='systemsettings',
|
||||
name='default_http_firewall_policy_id',
|
||||
field=models.BigIntegerField(null=True, blank=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-17 12:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0006_systemsettings_policy_ids'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='systemsettings',
|
||||
name='epay_api_base_url',
|
||||
field=models.URLField(blank=True, default=''),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='systemsettings',
|
||||
name='epay_key',
|
||||
field=models.CharField(blank=True, default='', max_length=128),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='systemsettings',
|
||||
name='epay_pid',
|
||||
field=models.CharField(blank=True, default='', max_length=64),
|
||||
),
|
||||
]
|
||||
0
core/migrations/__init__.py
Normal file
0
core/migrations/__init__.py
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-312.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
core/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
core/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
49
core/models.py
Normal file
49
core/models.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class SystemSettings(models.Model):
|
||||
goedge_base_url = models.URLField(blank=True, default='')
|
||||
# 令牌与认证
|
||||
edge_access_token = models.CharField(max_length=255, blank=True, default='')
|
||||
edge_token_expires_at = models.DateTimeField(null=True, blank=True)
|
||||
admin_access_key_id = models.CharField(max_length=64, blank=True, default='')
|
||||
admin_access_key = models.CharField(max_length=128, blank=True, default='')
|
||||
# 默认节点集群(用于创建网站时的 nodeClusterId)
|
||||
default_node_cluster_id = models.BigIntegerField(null=True, blank=True)
|
||||
default_free_traffic_gb_per_domain = models.PositiveIntegerField(default=15)
|
||||
default_overage_policy = models.JSONField(default=dict, blank=True)
|
||||
cname_template = models.CharField(max_length=255, blank=True, default='{sub}.cdn.example.com')
|
||||
# 默认策略/资源引用(用于域名功能同步)
|
||||
default_http_access_log_policy_id = models.BigIntegerField(null=True, blank=True)
|
||||
default_http_firewall_policy_id = models.BigIntegerField(null=True, blank=True)
|
||||
# 异常流量检测配置
|
||||
anomaly_detection_enabled = models.BooleanField(default=True)
|
||||
anomaly_threshold_multiplier = models.FloatField(default=3.0)
|
||||
anomaly_window_days = models.PositiveIntegerField(default=7)
|
||||
anomaly_min_gb = models.FloatField(default=1.0)
|
||||
# 简单图形验证码开关(登录/注册)
|
||||
captcha_enabled = models.BooleanField(default=False)
|
||||
epay_api_base_url = models.URLField(blank=True, default='')
|
||||
epay_pid = models.CharField(max_length=64, blank=True, default='')
|
||||
epay_key = models.CharField(max_length=128, blank=True, default='')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '系统设置'
|
||||
verbose_name_plural = '系统设置'
|
||||
|
||||
|
||||
class OperationLog(models.Model):
|
||||
actor = models.ForeignKey('auth.User', null=True, blank=True, on_delete=models.SET_NULL)
|
||||
action = models.CharField(max_length=100)
|
||||
target = models.CharField(max_length=200, blank=True, default='')
|
||||
detail = models.TextField(blank=True, default='')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '操作日志'
|
||||
verbose_name_plural = '操作日志'
|
||||
ordering = ['-created_at']
|
||||
|
||||
# Create your models here.
|
||||
26
core/tests.py
Normal file
26
core/tests.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from core.utils import bytes_to_gb
|
||||
from core.goedge_client import GoEdgeClient
|
||||
|
||||
|
||||
class UtilsTests(TestCase):
|
||||
def test_bytes_to_gb(self):
|
||||
self.assertEqual(bytes_to_gb(0), 0)
|
||||
self.assertEqual(bytes_to_gb(1024 ** 3), 1)
|
||||
self.assertEqual(bytes_to_gb(5 * (1024 ** 3)), 5)
|
||||
|
||||
|
||||
class GoEdgeClientTests(TestCase):
|
||||
def test_validate_base_url_invalid(self):
|
||||
client = None
|
||||
try:
|
||||
client = GoEdgeClient()
|
||||
except RuntimeError:
|
||||
client = None
|
||||
# 若环境未配置 base_url,GoEdgeClient 构造将抛错,此处仅断言不会引发测试崩溃
|
||||
self.assertIsNone(client)
|
||||
64
core/utils.py
Normal file
64
core/utils.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from typing import Dict, List, Any
|
||||
|
||||
from dns import resolver, exception
|
||||
|
||||
|
||||
def resolve_cname(hostname: str) -> List[str]:
|
||||
"""Resolve CNAME records for a hostname and return targets without trailing dots.
|
||||
|
||||
If there is no CNAME record, this returns an empty list.
|
||||
Raises dns.exception.DNSException on resolver errors.
|
||||
"""
|
||||
answers = resolver.resolve(hostname, "CNAME")
|
||||
targets: List[str] = []
|
||||
for rdata in answers:
|
||||
# rdata.target may be a Name object; to_text() yields with trailing dot
|
||||
target = getattr(rdata, "target", None)
|
||||
if target is None:
|
||||
# some dnspython versions use .to_text() directly on rdata
|
||||
text = rdata.to_text()
|
||||
else:
|
||||
text = target.to_text()
|
||||
targets.append(text.rstrip("."))
|
||||
return targets
|
||||
|
||||
|
||||
def check_cname_map(cname_map: Dict[str, str]) -> List[Dict[str, Any]]:
|
||||
"""Check a mapping of hostname -> expected CNAME target.
|
||||
|
||||
Returns a list of result dicts: {
|
||||
'hostname': str,
|
||||
'expected': str,
|
||||
'actual': List[str],
|
||||
'ok': bool,
|
||||
'error': Optional[str]
|
||||
}
|
||||
"""
|
||||
results: List[Dict[str, Any]] = []
|
||||
for hostname, expected in cname_map.items():
|
||||
expected_norm = expected.rstrip(".").lower()
|
||||
try:
|
||||
actual_targets = resolve_cname(hostname)
|
||||
actual_norm = [t.lower() for t in actual_targets]
|
||||
ok = expected_norm in actual_norm
|
||||
results.append({
|
||||
"hostname": hostname,
|
||||
"expected": expected,
|
||||
"actual": actual_targets,
|
||||
"ok": ok,
|
||||
"error": None,
|
||||
})
|
||||
except exception.DNSException as e:
|
||||
results.append({
|
||||
"hostname": hostname,
|
||||
"expected": expected,
|
||||
"actual": [],
|
||||
"ok": False,
|
||||
"error": str(e),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def bytes_to_gb(num_bytes: int) -> float:
|
||||
"""Convert bytes to gigabytes (GB, 1024^3)."""
|
||||
return round(num_bytes / (1024 ** 3), 3)
|
||||
3
core/views.py
Normal file
3
core/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
Reference in New Issue
Block a user