# coding: utf-8 # ------------------------------------------------------------------- # YakPanel # ------------------------------------------------------------------- # Copyright (c) 2014-2099 YakPanel(www.yakpanel.com) All rights reserved. # ------------------------------------------------------------------- # Author: yakpanel # ------------------------------------------------------------------- # ------------------------------ # aaDNS app # ------------------------------ import json import os import random import sys import time from dataclasses import dataclass, fields from functools import wraps from typing import Optional, Type, TypeVar, List sys.path.insert(0, '/www/server/panel/class') sys.path.insert(0, '/www/server/panel/class_v2') import public from public.hook_import import hook_import hook_import() try: from YakPanel import cache except: pass from ssl_dnsV2.model import DnsResolve from ssl_dnsV2.helper import DnsParser from ssl_dnsV2.conf import * from public.exceptions import HintException from ssl_domainModelV2.service import DomainValid, SyncService from ssl_domainModelV2.model import DnsDomainProvider, dns_logger PANEL_PATH = public.get_panel_path() S = TypeVar("S", bound="Soa") TIMEOUT = 1 # 用于验证ns超时 def backup_file(path: str, suffix: str = None) -> None: """创建 pdns的备份文件""" if not os.path.exists(path): return if suffix is None: if path == ZONES: suffix = "aabak" # 主配置 elif path.startswith(ZONES_DIR): suffix = "aadef" # 区域文件 else: suffix = "bak" backup_path = f"{path}_{suffix}" public.ExecShell(f"cp -a {path} {backup_path}") def pdns_rollback(path: str) -> None: """根据 pdns文件路径回滚自己得备份文件""" if path.startswith(ZONES_DIR): suffix = "aadef" else: suffix = "aabak" backup_path = f"{path}_{suffix}" if os.path.exists(backup_path): public.ExecShell(f"cp -a {backup_path} {path}") def clean_record_cache(func): # 对记录操作后强清缓存 @wraps(func) def wrapper(self, *args, **kwargs): result = func(self, *args, **kwargs) try: domain = kwargs.get("domain") if not domain and args: domain = args[0] if domain and self.yakpanel_dns_obj and cache: key = f"yakDomain_{self.yakpanel_dns_obj.id}_{domain}" cache.delete(key) except Exception: pass return result return wrapper @dataclass(slots=True) class Soa: domain: str nameserver: str admin_mail: str refresh: int retry: int expire: int minimum: int @classmethod def from_dict(cls: Type[S], data: dict) -> S: class_fields = {f.name for f in fields(cls)} filtered_data = {k: v for k, v in data.items() if k in class_fields} return cls(**filtered_data) class Templater: @staticmethod def generate_zone(domain: str) -> str: template = """ zone "%s" IN { type master; file "/var/named/chroot/var/named/%s.zone"; allow-update { none; }; }; """ % (domain, domain) public.writeFile(ZONES, template, "a+") return template @staticmethod def generate_record(domain: str, ns1: str, ns2: str, soa: str, ip: str, **kwargs) -> str: # 确保FQDN格式 for k in [ns1, ns2, soa]: if not k.endswith("."): k += "." ttl = str(kwargs.get("ttl", "14400")) priority = str(kwargs.get("priority", "10")) r_type = "A" if ":" not in ip else "AAAA" serial = time.strftime("%Y%m%d", time.localtime()) + "01" template = f"""$TTL 86400 {domain}. IN SOA {soa} admin.{domain}. ( {serial} ; serial 3600 ; refresh 1800 ; retry 1209600 ; expire 1800 ) ; minimum {domain}. 86400 IN NS {ns1} {domain}. 86400 IN NS {ns2} {domain}. {ttl} IN {r_type} {ip} {domain}. {ttl} IN MX {priority} mail.{domain}. www {ttl} IN {r_type} {ip} mail {ttl} IN {r_type} {ip} ns1 {ttl} IN {r_type} {ip} ns2 {ttl} IN {r_type} {ip} """ public.writeFile(os.path.join(ZONES_DIR, f"{domain}.zone"), template) return template class MailManager: def __init__(self, domain: str = None): self.domain = domain self.dns_manager = DnsManager() def _get_provider(self, provider: DnsDomainProvider = None) -> DnsDomainProvider: if not provider: provider = self.dns_manager.yakpanel_dns_obj if provider: return provider raise HintException("DNS provider 'YakPanelDns' not found.") return provider def _get_records(self) -> list: return self.dns_manager.parser.get_zones_records(self.domain) def __append_spf_record(self, spf_record: dict, new_spf_ip: str) -> None: parts = spf_record["value"].strip('"').split() all_index = -1 for i, part in enumerate(parts): if part.endswith("all"): all_index = i break if all_index != -1: parts.insert(all_index, f"+{new_spf_ip}") else: parts.append(f"+{new_spf_ip}") new_spf_value = " ".join(parts) self.dns_manager.update_record( domain=self.domain, name=spf_record["name"], type="TXT", value=spf_record["value"], new_record={ "name": spf_record["name"], "type": "TXT", "ttl": spf_record["ttl"], "value": f'"{new_spf_value}"', } ) def add_dmarc(self, policy: str = "none", provider: DnsDomainProvider = None, **kwargs) -> bool: """ 添加 DMARC 记录 DMARC 策略 "none" (接受) "quarantine" (隔离) 或 "reject" (拒绝) """ if policy not in ["none", "quarantine", "reject"]: raise HintException("Invalid DMARC policy specified.") for record in self._get_records(): if record.get("name") == "_dmarc" and record.get("type") == "TXT": dns_logger(f"domain [{self.domain}] has DMARC record, skipping addition.") return True dmarc_value = f'"v=DMARC1; p={policy}; rua=mailto:admin@{self.domain}"' body = { "domain": self.domain, "record": "_dmarc", "record_type": "TXT", "record_value": dmarc_value, "ttl": kwargs.get("ttl", 600), "ps": "Auto DMARC Record", } self._get_provider(provider).model_create_dns_record(body) return True def add_spf(self, provider: DnsDomainProvider = None, **kwargs) -> bool: records = self._get_records() server_ip = public.GetLocalIp() new_spf_ip = f"ip4:{server_ip}" if DomainValid.is_ip4(server_ip) else f"ip6:{server_ip}" spf_record = None for record in records: if all([ record.get("type") == "TXT", record.get("name") == "@" or record.get("name") == self.domain, record.get("value", "").startswith("v=spf1") or record.get("value", "").startswith('"v=spf1'), ]): if spf_record is not None: dns_logger( f"domain [{self.domain}] has multiple SPF records, it will auto fix it," f" keep the first one." ) # 多个spf记录异常 self.dns_manager.delete_record( domain=self.domain, name=record["name"], type="TXT", value=record["value"] ) else: spf_record = record # 存在spf if spf_record: if new_spf_ip not in spf_record.get("value", ""): self.__append_spf_record(spf_record, new_spf_ip) dns_logger( f"domain [{self.domain}] SPF record updated to include server IP: [{server_ip}]." f"Current SPF: {spf_record.get('value', '')}" ) return True else: dns_logger(f"domain [{self.domain}] has SPF record with server IP, skipping addition.") return True # 不存在SPF记录 body = { "domain": self.domain, "record": "@", "record_type": "TXT", "record_value": f"v=spf1 +mx +a +{new_spf_ip} -all", "ttl": kwargs.get("ttl", 600), "ps": "Auto SPF Record", } self._get_provider(provider).model_create_dns_record(body) return True def add_dkim(self, provider: DnsDomainProvider = None, **kwargs) -> bool: dkim_name = "default._domainkey" records = self._get_records() for record in records: if record.get("name") == dkim_name and record.get("type") == "TXT": dns_logger(f"domain [{self.domain}] has DKIM record, skipping addition.") return True try: plugin_path = "/www/server/panel/plugin/mail_sys" if os.path.exists(plugin_path) and plugin_path not in sys.path: sys.path.insert(0, plugin_path) from plugin.mail_sys.mail_sys_main import mail_sys_main dkim_value = mail_sys_main()._get_dkim_value(self.domain) if dkim_value == "": mail_sys_main().set_rspamd_dkim_key(self.domain) dkim_value = mail_sys_main()._get_dkim_value(self.domain) except ImportError: dns_logger(f"domain [{self.domain}] Mail System Plugin ImportError, not installed...") raise HintException("Mail System Plugin ImportError, not installed.") except Exception: dns_logger(f"domain [{self.domain}] Failed to retrieve DKIM value from mail system.") raise HintException("Failed to retrieve DKIM value from mail system.") if not dkim_value: dns_logger(f"domain [{self.domain}] DKIM value is empty, cannot add record.") raise HintException("DKIM value is empty, cannot add record.") body = { "domain": self.domain, "record": dkim_name, "record_type": "TXT", "record_value": f'"{dkim_value}"', "ttl": kwargs.get("ttl", 600), "ps": "Auto SPF Record", } self._get_provider(provider).model_create_dns_record(body) return True class DnsManager: def __init__(self): self.parser = DnsParser() self.config = self.parser.config @property def yakpanel_dns_obj(self) -> Optional[DnsDomainProvider]: return DnsDomainProvider.objects.filter(name="YakPanelDns").first() def _quotes(self, s: str) -> bool: if not s or not isinstance(s, str): return False if s.startswith('"') and s.endswith('"'): return True return False def _get_glb_ns(self) -> List[tuple]: """获取公共NS服务器, 打乱""" shuffled_servers = [x for x in PUBLIC_SERVER] random.shuffle(shuffled_servers) return shuffled_servers def makesuer_port(self) -> None: try: if not public.S("firewall_new").where( "ports = ? AND protocol = ?", ("53", "tcp/udp") ).count(): try: from firewallModelV2.comModel import main as firewall_main get = public.dict_obj() get.protocol = "all" get.port = "53" get.choose = "all" get.types = "accept" get.strategy = "accept" get.chain = "INPUT" get.brief = "DNS Service Port" get.operation = "add" firewall_main().set_port_rule(get) except Exception as e: dns_logger("Add Firewall Port 53 Error: {}".format(e)) public.print_log("add firewall port 53 error: {}".format(e)) except Exception: try: from firewalld_v2 import firewalld firewalld().AddAcceptPort(53, "tcp") except Exception: pass def query_dns(self, q_name: str, record_type: str, ns_server: Optional[list] = None, time_out: int = None) -> list: """ DNS查询 :param q_name: 要查询的名称 :param record_type: 记录类型 :param ns_server: 要使用的DNS服务器列表。如果为None,则使用系统默认 :param time_out: 超时 s :return: 记录值字符串的列表 :raises: HintException 如果查询失败 """ try: import dns.resolver except ImportError: dns_logger("dnspython ImportError, not installed...") public.ExecShell("btpip install dnspython") public.print_log("dnspython not installed, skipping DNS query.") return [] resolver = dns.resolver.Resolver() if time_out: resolver.timeout = time_out resolver.lifetime = time_out if ns_server: resolver.nameservers = ns_server time.sleep(0.5) try: answers = resolver.resolve(q_name, record_type) found_records = [] for rdata in answers: if record_type == "TXT": record_str = "".join( s.decode("utf-8", errors="replace") if isinstance(s, bytes) else s for s in rdata.strings ) else: record_str = str(rdata) # 格式化 if record_type == "CAA" and self._quotes(record_str): record_str = record_str[1:-1] elif record_type == "MX": # rdata.to_text() -> '10 mail.example.com.' parts = record_str.split(" ", 1) if len(parts) == 2: record_str = parts[1] elif record_type == "SRV": # rdata.to_text() -> '10 5 5060 target.example.com.' parts = record_str.split(" ", 3) if len(parts) == 4: record_str = " ".join(parts) found_records.append(record_str.rstrip('.')) return found_records except (dns.resolver.Timeout, dns.resolver.LifetimeTimeout): raise HintException(f"DNS query for {q_name} ({record_type}) timed out.") except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.resolver.NoNameservers): # 没有找到记录 return [] except Exception as e: raise HintException(f"DNS query for {q_name} ({record_type}) failed: {e}") def validate_with_resolver(self, action: str, domain: str, **kwargs) -> bool: # 本地校验dns记录 if not action: return False record_data = kwargs if action == "update" and "new_record" in kwargs: record_data = kwargs["new_record"] record_type = record_data.get("type", "A").upper() name = record_data.get("name", "@") value_to_check = record_data.get("value") if name == "@" or name == domain: q_name = f"{domain}." elif name.endswith("."): q_name = name else: q_name = f"{name}.{domain}" found_records = self.query_dns( q_name=q_name, record_type=record_type, ns_server=["127.0.0.1"] ) time.sleep(1) if not found_records: if action in ["create", "update"]: # 对于创建||更新,是失败的 raise HintException( f"Validation failed: Record {q_name} ({record_type}) not found after {action}." ) if action == "delete": # 对于删除,是成功的 return True # 被花括号包围时去引号, 去尾点, 去空白 def _clean_value(v: str) -> str: if not v: return v v = v.strip() if self._quotes(v): v = v[1:-1] v = v.rstrip(".") # TXT记录去除所有空白,用于长值比较 if record_type == "TXT": v = v.replace(" ", "").replace("\t", "") return v value_to_check_cleaned = _clean_value(value_to_check) found_records_cleaned = [_clean_value(r) for r in found_records] if action in ["create", "update"]: if value_to_check_cleaned not in found_records_cleaned: raise HintException( f"Validation failed: Record {q_name} ({record_type})" f" with value '{value_to_check}' not found after {action}." ) return True if action == "delete": # 宽松检查, 保持删除 return True # if value_to_check_cleaned in found_records: # raise HintException( # f"Validation failed: Record {q_name} ({record_type})" # f" with value '{value_to_check}' still exists after delete." # ) # return True return False def _validate_NS_A(self, ns_list: List[str], time_out: int = None) -> bool: """备用尝试直接验证A记录方法: 直接查询ns1和ns2的A记录""" time_out = int(time_out) if time_out else TIMEOUT server_ip = public.GetLocalIp() try: for n in ns_list: ns_ip = self.query_dns( q_name=n, record_type="A", time_out=time_out ) if server_ip not in ns_ip: return False return True except Exception as e: public.print_log(f"Warning: DNS query to NS A record failed: {e}.") return False def _validate_NS(self, domain: str, ns_list: List[str], addr_list: list, time_out: int = None) -> bool: try: time_out = int(time_out) if time_out else TIMEOUT found_ns = self.query_dns( q_name=domain, record_type="NS", ns_server=addr_list, time_out=time_out ) if found_ns: ns_set = {ns.rstrip(".").lower() for ns in found_ns} # 是否子集 if set(ns_list).issubset(ns_set): return True return False except Exception: # public.print_log(f"Warning: DNS query to NS record failed: {e}.") return False def _validate_any_ns_msg(self, domain: str, ns_list: List[str], serv_name: str, addr_list: list) -> Optional[str]: """单次验证域名的权威""" # NS 阶段 if self._validate_NS(domain, ns_list, addr_list): return (f"Validate NameServer Success: NS records are correctly set. " f"Found in Global Public DNS '{serv_name}' {addr_list}") # public.print_log( # f"Warning: NS for {domain} are Not Found! " # f"which do not match provided [{', '.join(ns_list)}]. Proceeding to fallback validation." # ) # fallback 尝试直接解析NS主机的A记录 if self._validate_NS_A(ns_list): return ( f"Validate NameServer Success: NS A records point to server IP." f"Found in Global Public DNS '{serv_name}' {addr_list}" ) return None def _any_ns_hit(self, domain: str, ns_list: List[str]) -> str: """ any one策略 验证域名的权威NS记录。 1. 优先查询域名的权威NS记录是否已在公网指向ns1/ns2。 2. 如果查询失败或不匹配(例如新域名、未设置胶水记录),则回退到备用验证。 3. 备用验证:直接查询ns1和ns2的A记录,看它们是否指向本机IP。 成功返回str, 失败抛异常。 """ ns_list = [x.rstrip(".").lower() for x in ns_list if x] for server_name, addr_list in self._get_glb_ns(): try: any_tips = self._validate_any_ns_msg( domain, ns_list, server_name, addr_list ) # 返回成功信息tips if any_tips and isinstance(any_tips, str): body = { "ns_resolve": 0, "a_resolve": 0, "tips": any_tips, } if "NS records" in any_tips: body["ns_resolve"] = 1 body["a_resolve"] = 0 elif "NS A records" in any_tips: body["ns_resolve"] = 0 body["a_resolve"] = 1 DnsResolve.update_or_create(domain, **body) return any_tips else: continue except Exception as e: public.print_log(e) # for loop end, all failed raise HintException("Validate NameServer Failed: NS records are not correctly set.") def __read_zone_lines(self, zone_file: str) -> list: """读取zone文件返回行列表""" content = public.readFile(zone_file) or "" if not content: raise HintException(f"Zone file [{zone_file}] is empty.") # 移除末尾可能存在的空行 lines = content.rstrip("\n").split("\n") if content else [] if not lines: raise HintException(f"Zone file [{zone_file}] is empty.") return lines def _update_soa(self, lines: list, soa_obj: Optional[Soa] = None) -> bool: """更新SOA 必然更新序列号, 如果带了 soa_obj 参数必须全提供""" serial_update = False other_update = True if soa_obj is not None else False ns_admin_mail_changed = False refresh_changed = False retry_changed = False expire_changed = False minimum_changed = False for i, line in enumerate(lines): if "IN SOA" in line: # SOA 行开始 # ns 和 admin mail if soa_obj and soa_obj.nameserver and soa_obj.admin_mail: new_soa_line = ( f"{soa_obj.domain}. IN SOA " f"{soa_obj.nameserver}. " f"{soa_obj.admin_mail}. (" ) lines[i] = new_soa_line ns_admin_mail_changed = True # 剩余参数 go_on = lines[i:] for j, soa_line in enumerate(go_on): # '; serial' 注释结尾, (看模板) if ";" in soa_line and "serial" in soa_line: serial_str = soa_line.split(';')[0].strip() if serial_str.isdigit(): today_prefix = time.strftime("%Y%m%d", time.localtime()) if serial_str.startswith(today_prefix): new_serial = str(int(serial_str) + 1) else: new_serial = today_prefix + "01" # SOA 序列号变更 lines[i + j] = soa_line.replace(serial_str, new_serial, 1) serial_update = True elif "; refresh" in soa_line and soa_obj and soa_obj.refresh: lines[i + j] = (f" " f"{soa_obj.refresh} ; refresh") refresh_changed = True elif "; retry" in soa_line and soa_obj and soa_obj.retry: lines[i + j] = (f" " f"{soa_obj.retry} ; retry") retry_changed = True elif "; expire" in soa_line and soa_obj and soa_obj.expire: lines[i + j] = (f" " f"{soa_obj.expire} ; expire") expire_changed = True elif "; minimum" in soa_line and soa_obj and soa_obj.minimum: lines[i + j] = (f" " f"{soa_obj.minimum} ) ; minimum") minimum_changed = True break if other_update: if not (ns_admin_mail_changed and refresh_changed and retry_changed and expire_changed and minimum_changed): return False if serial_update: return True return False def _build_record_line(self, domain: str, **kwargs) -> str: """构建DNS记录行""" def _get_params(kw: dict): name = kw.get("name", "@") ttl = kw.get("ttl", "600") ttl = "600" if int(ttl) == 1 else str(ttl) record_type = kw.get("type", "A").upper() value = kw.get("value") priority = kw.get("priority", -1) if not value: raise HintException("value is required!") return name, record_type, ttl, value, priority name, record_type, ttl, value, priority = _get_params(kwargs) if "new_record" in kwargs: name, record_type, ttl, value, priority = _get_params(kwargs["new_record"]) # === 仅校验 === if record_type == "A" and not DomainValid.is_ip4(value): raise HintException(f"Invalid A record value: {value}") elif record_type == "AAAA" and not DomainValid.is_ip6(value): raise HintException(f"Invalid AAAA record value: {value}") elif record_type in ["CNAME", "NS"]: if not DomainValid.is_valid_domain(value): raise HintException(f"Invalid {record_type} record value: {value}") # 完全限定域名FQDN if not value.endswith("."): value = f"{value}." elif record_type == "CAA": parts = value.split(None, 2) if len(parts) != 3: raise HintException(f"Invalid CAA record format: {value}") flags, tag, ca_value = parts if not flags.isdigit() or not (0 <= int(flags) <= 255): raise HintException(f"Invalid CAA flags: {flags}. Must be 0-255.") if tag not in ["issue", "issuewild", "iodef"]: raise HintException(f"Invalid CAA tag: {tag}. Must be 'issue', 'issuewild', or 'iodef'.") ca_value = ca_value.strip('"') if tag == "iodef": if not (ca_value.startswith("mailto:") or DomainValid.is_valid_domain(ca_value)): raise HintException(f"Invalid CAA iodef value: {ca_value}") elif not DomainValid.is_valid_domain(ca_value): raise HintException(f"Invalid CAA domain value: {ca_value}") elif record_type == "TXT": # TXT 记录值如果包含空格,使用引号包裹 if (" " in value or ";" in value) and not self._quotes(value): value = f'"{value}"' # TXT 记录值统一使用引号包裹(RFC 推荐) if not self._quotes(value): value = f'"{value}"' # === 参数追加尾部作为 整体value 写入 conf === elif record_type == "SRV": weight = kwargs.get("weight", "5") port = kwargs.get("port") if not port: raise HintException("SRV record requires a 'port'.") if not all(str(p).isdigit() for p in [priority, weight, port]): raise HintException("SRV priority, weight, and port must be integers.") if not DomainValid.is_valid_domain(value): raise HintException(f"Invalid SRV target domain: {value}") value = f"{priority} {weight} {port} {value}" if record_type == "MX": if not DomainValid.is_valid_domain(value): raise HintException(f"Invalid MX record value: {value}") # 如果value不是完全限定域名FQDN(不以.结尾),则补充点 if not value.endswith("."): value = f"{value}." value = f"{priority} {value}" # 最后格式化 FQDN record_name = name if record_type in ["A", "AAAA"]: if record_name == "@" or record_name == domain: record_name = f"{domain}." # 其他情况保持原样,不转换为FQDN else: # 其他记录类型强制保持FQDN格式 if record_name == "@" or record_name == domain: record_name = f"{domain}." elif not record_name.endswith("."): record_name = f"{record_name}.{domain}." # 构造兼容旧格式 return f"{record_name}\t{ttl}\tIN\t{record_type}\t{value}" def _find_record_line_index(self, lines: list, **kwargs) -> Optional[int]: """查找DNS记录行索引""" name = kwargs.get("name", "@") record_type = kwargs.get("type", "A").upper() value = kwargs.get("value") # 剥离引号 if record_type in ["TXT", "CAA"] and value and self._quotes(value): value = value[1:-1] for i, line in enumerate(lines): match = record_pattern.match(line) if match: r_name, _, _, r_type, r_value_raw = match.groups() r_type = r_type.upper() r_value = r_value_raw.strip() if r_type in ["TXT", "CAA"]: if self._quotes(r_value): r_value = r_value[1:-1] # 剥离引号 else: # 如果没有引号,可能存在的注释 r_value = r_value.split(';', 1)[0].strip() elif r_type == "MX": # MX格式 "优先级 目标主机" try: r_value = r_value.split(";", 1)[0].strip() r_value_clean = r_value.split(";", 1)[0].strip() _, r_value = r_value_clean.split(None, 1) except Exception: import traceback public.print_log(f"find record error: {traceback.format_exc()}") if r_name == name and r_type.upper() == record_type and r_value == value: return i return None def _modify_record(self, domain: str, action: str, **kwargs) -> bool: # C, U, D zone_file = os.path.join(ZONES_DIR, f"{domain}.zone") if not os.path.exists(zone_file): raise HintException("Zone file not found!") # 开事务 backup_file(zone_file) try: lines = self.__read_zone_lines(zone_file) # 更新SOA序列号 if not self._update_soa(lines): raise HintException("SOA record not found, cannot update serial number.") modify = False line_index = self._find_record_line_index(lines, **kwargs) if action.lower() == "create": if line_index is not None: raise HintException("Record already exists, skipping creation.") new_record_line = self._build_record_line(domain, **kwargs) lines.append(new_record_line) modify = True elif action.lower() in ["update", "delete"]: if line_index is None: raise HintException("Record not found!") if action.lower() == "delete": lines.pop(line_index) modify = True elif action.lower() == "update": updated_line = self._build_record_line(domain, **kwargs) lines[line_index] = updated_line modify = True if modify: public.writeFile(zone_file, "\n".join(lines) + "\n") return True return False except Exception as e: pdns_rollback(zone_file) import traceback public.print_log(traceback.format_exc()) raise HintException(e) def __apply_and_validate_change(self, action: str, domain: str, **kwargs) -> None: """配置变动和验证管理""" zone_file = os.path.join(ZONES_DIR, f"{domain}.zone") try: if domain not in self.parser.get_zones(): raise HintException("Domian Not Found!") if not self._modify_record(domain, action, **kwargs): return self.reload_service() self.validate_with_resolver(action, domain, **kwargs) # 校验后对区域文件进行备份稳定版本 backup_file(zone_file) except Exception as e: import traceback public.print_log(traceback.format_exc()) pdns_rollback(zone_file) self.reload_service() raise HintException(f"Record {action} failed: {e}") # ================= script ==================== def _generate_auth_body(self, domain: str, ns_res: list, a_res: list, shuffled_servers: list) -> dict: body = { "domain": domain, "ns_resolve": 0, "a_resolve": 0, "tips": "", } if not shuffled_servers: return body ns_rate = len(ns_res) / len(shuffled_servers) a_rate = len(a_res) / len(shuffled_servers) threshold = 0.3 ns_ok = ns_rate >= threshold a_ok = a_rate >= threshold body["ns_resolve"] = 1 if ns_ok else 0 body["a_resolve"] = 1 if a_ok else 0 tips_parts = [] # 前缀 if ns_ok and a_ok: tips_parts.append("Validate NameServer Success: ") else: tips_parts.append("Validate NameServer Failed: ") # NS if ns_ok: tips_parts.append("NS records are correctly set.") else: tips_parts.append("NS records are not correctly set.") # A if a_ok: tips_parts.append("NS A records point to server IP.") else: tips_parts.append("NS A records do not point to server IP.") body["tips"] = " ".join(tips_parts) return body def builtin_dns_checker(self, dns_obj: Optional[DnsDomainProvider] = None) -> None: """验证域名的权威NS记录, 用于任务""" obj = self.yakpanel_dns_obj if not dns_obj else dns_obj if os.path.exists(DNS_AUTH_LOCK) or not obj or obj.status == 0 or len(obj.domains) == 0: return with open(DNS_AUTH_LOCK, "w") as f: f.write("0") try: SyncService(obj.id).process(nohup=True) for domain in obj.domains: try: ns_list = [ x.get("value", "").rstrip(".").lower() for x in self.parser.get_zones_records(domain) if x.get("type", "").upper() == "NS" ] shuffled_servers = self._get_glb_ns() ns_res = [] a_res = [] for server_name, addr_list in shuffled_servers: try: if self._validate_NS(domain, ns_list, addr_list, time_out=5): ns_res.append(server_name) if self._validate_NS_A(ns_list, time_out=5): a_res.append(server_name) except Exception: continue body = self._generate_auth_body( domain, ns_res, a_res, shuffled_servers ) DnsResolve.update_or_create(**body) dns_logger(f"domain [{domain}] dns auth : {body.get('tips', 'Msg Not Found')}") except Exception as e: public.print_log(f"builtin_dns_auth error : {e}") continue except Exception as e: public.print_log(f"builtin_dns_auth outer error : {e}") finally: public.ExecShell(f"rm -f {DNS_AUTH_LOCK}") # ================= public ==================== def change_service_status(self, service_name: str = "pdns", status: str = "restart") -> bool: if service_name == "pdns": service_packname = self.config.pdns_paths['service_name'] elif service_name == "bind": service_packname = self.config.bind_paths['service_name'] else: raise HintException("Unsupported service!") if status not in ["stop", "restart", "reload", "start"]: raise HintException("Invalid action specified!") if status != "stop": status = "restart" self.makesuer_port() if service_name == "pdns": # bind 互斥 stop_name = self.config.bind_paths['service_name'] else: # pdns 互斥 stop_name = self.config.pdns_paths['service_name'] a0, e0 = public.ExecShell(f"ps -ef|grep {stop_name}|grep -v grep") if a0: # 关闭互斥服务 public.ExecShell(f"systemctl stop {stop_name}") _, e = public.ExecShell(f"systemctl {status} {service_packname}") if e: raise HintException(f"{status} {service_name} service failed error: {e}") # 变更账号状态 provider = self.yakpanel_dns_obj if provider: status_map = {"restart": 1, "stop": 0} provider.status = status_map[status] provider.save() return True def reload_service(self, service_name: str = "pdns") -> bool: return self.change_service_status(service_name, "reload") def add_zone(self, domain: str, ns1: str, ns2: str, soa: str, ip: str = "127.0.0.1") -> Optional[str]: domain = domain.strip().rstrip(".") if domain in self.parser.get_zones(): dns_logger(f"Add Zone Failed: zone [{domain}] Already Exists") raise HintException("Zone Already Exists!") # 权威解析 try: # add zone 会更新创建首次解析记录 ns_res_msg = str(self._any_ns_hit(domain, [ns1, ns2])) except HintException as he: ns_res_msg = f"\nHowever, {str(he)}" DnsResolve.update_or_create(domain, **{"ns_resolve": 0, "a_resolve": 0, "tips": ns_res_msg}) except Exception as e: ns_res_msg = (f"\nHowever, An error occurred during NS validation: {e}." f" Your DNS Resolution May Not be Active in the internet.") DnsResolve.update_or_create(domain, **{"ns_resolve": 0, "a_resolve": 0, "tips": ns_res_msg}) zone_file = os.path.join(ZONES_DIR, f"{domain}.zone") backup_file(ZONES) # 备份主配置文件 try: Templater.generate_zone(domain) # 追加zone 配置 Templater.generate_record(domain, ns1, ns2, soa, ip) # 生成zone区域文件 self.reload_service() result_msg = f"Zone [{domain}] Added Successfully. " if ns_res_msg and isinstance(ns_res_msg, str): result_msg += ns_res_msg dns_logger(result_msg) return result_msg except Exception as e: pdns_rollback(ZONES) if os.path.exists(zone_file): try: os.remove(zone_file) # 移除异常的区域文件 os.remove(f"{zone_file}_aadef") # 移除其备份 except: pass self.reload_service() dns_logger(f"Add Zone Failed: {e}") if isinstance(e, HintException): raise e raise HintException("Add Zone Failed Error: {}".format(e)) finally: # 同步DNS服务 provider = self.yakpanel_dns_obj if provider: sync = SyncService(provider.id) sync.force = True sync.sync_dns_domains() def delete_zone(self, domain: str) -> Optional[str]: zone_file = os.path.join(ZONES_DIR, f"{domain}.zone") # 备份主配置文件和区域文件 backup_file(ZONES) backup_file(zone_file) try: zones_content = public.readFile(ZONES) or "" lines = zones_content.splitlines() new_lines: list = [] in_block_to_delete = False brace_count = 0 for line in lines: stripped_line = line.strip() if stripped_line.startswith(f'zone "{domain}"'): in_block_to_delete = True if in_block_to_delete: # 配对大括号 brace_count += line.count("{") brace_count -= line.count("}") if brace_count <= 0: in_block_to_delete = False continue # 只保留一个换行 if not stripped_line and new_lines and not new_lines[-1].strip(): continue new_lines.append(line) # 只保留一个换行 while new_lines and not new_lines[-1].strip(): new_lines.pop() final_content = "\n".join(new_lines) if final_content: final_content += "\n" public.writeFile(ZONES, final_content) try: os.remove(zone_file) os.remove(zone_file + "_aadef") except Exception as ex: public.print_log(f"Error removing zone file {zone_file}: {ex}") self.reload_service() msg = f"Zone [{domain}] Deleted Successfully." dns_logger(msg) DnsResolve.objects.filter(domain=domain).delete() return msg except Exception as e: import traceback public.print_log(traceback.format_exc()) pdns_rollback(ZONES) pdns_rollback(zone_file) self.reload_service() dns_logger(f"Delete Zone Failed: {e}") raise HintException("Delete zone failed error: {}".format(e)) finally: # 同步DNS服务 provider = self.yakpanel_dns_obj if provider: sync = SyncService(provider.id) sync.force = True sync.sync_dns_domains() @clean_record_cache def add_record(self, domain: str, **kwargs) -> bool: self.__apply_and_validate_change("create", domain, **kwargs) return True @clean_record_cache def delete_record(self, domain: str, **kwargs) -> bool: self.__apply_and_validate_change("delete", domain, **kwargs) return True @clean_record_cache def update_record(self, domain: str, **kwargs) -> bool: if not kwargs.get("new_record"): raise HintException("update record is required for update operation.") self.__apply_and_validate_change("update", domain, **kwargs) return True def get_domains(self) -> list: return self.parser.get_zones() or [] def get_default_nameserver(self) -> dict: try: if os.path.exists(aaDNS_CONF): ns = public.readFile(aaDNS_CONF) return json.loads(ns) if ns else {} return {} except Exception as e: public.print_log("Error reading nameserver config: {}".format(e)) return {} def set_default_nameserver(self, n1: str, n2: str) -> bool: for k in [n1, n2]: if not k.endswith("."): k += "." default = {"NS1": n1, "NS2": n2} self.config.ns_server = default public.writeFile(aaDNS_CONF, json.dumps(default)) dns_logger("Default Nameserver Set To: {}".format(default)) return True def get_soa(self, domain: str) -> dict: if not domain: return {} for record in self.parser.get_zones_records(domain=domain, witSOA=True): if record.get("type") == "SOA": return { "nameserver": record.get("nameserver"), "admin_mail": record.get("admin_mail"), "serial": record.get("serial"), "refresh": record.get("refresh"), "retry": record.get("retry"), "expire": record.get("expire"), "minimum": record.get("minimum"), } return {} def set_soa(self, **kwargs) -> bool: soa_obj = Soa.from_dict(kwargs) zone_file = os.path.join(ZONES_DIR, f"{soa_obj.domain}.zone") if not os.path.exists(zone_file): raise HintException(f"Zone file for domain '{soa_obj.domain}' not found!") backup_file(zone_file) try: lines = self.__read_zone_lines(zone_file) # 更新整个SOA if not self._update_soa(lines, soa_obj): raise HintException("SOA record not found, cannot update serial number.") public.writeFile(zone_file, "\n".join(lines) + "\n") except Exception as e: pdns_rollback(zone_file) import traceback public.print_log(traceback.format_exc()) raise HintException(f"Set Soa Failed: {e}") return True def get_logger(self, p: int = 1, limit: int = 20, search: str = None) -> dict: if search is None: sql = ("type = ?", ("DnsSSLManager",)) count = public.S("logs").where(*sql).count() else: sql = ("type = ? AND log LIKE ?", ("DnsSSLManager", f"%{search}%")) count = public.S("logs").where(*sql).count() logs = public.S("logs").where(*sql).order("id", "DESC").limit(limit, (p - 1) * limit).field( "log, addtime" ).select() length = 150 if logs and isinstance(logs, list): for log_entry in logs: if "log" in log_entry and len(log_entry["log"]) > length: original_log = log_entry["log"] chunks = [original_log[i:i + length] for i in range(0, len(original_log), length)] log_entry["log"] = "\n".join(chunks) return {"data": logs or [], "count": count or 0} def clear_logger(self) -> bool: public.S("logs").where("type = ?", ("DnsSSLManager",)).delete() return True @clean_record_cache def fix_zone(self, domain: str) -> str: # 记录去重修复 domain = domain.strip().rstrip(".") if domain not in self.parser.get_zones(): dns_logger(f"Fix Zone Failed: zone [{domain}] Not Found!") raise HintException("Zone Not Found!") zone_file = os.path.join(ZONES_DIR, f"{domain}.zone") if not os.path.exists(zone_file): raise HintException("Zone file not found!") # 备份 backup_file(zone_file) try: lines = self.__read_zone_lines(zone_file) org_lines_counts = len(lines) soa_lines = [] record_lines = [] soa_block = False find_block = 0 # SOA块 for line in lines: if "IN SOA" in line: soa_block = True if soa_block: soa_lines.append(line) find_block += line.count("(") find_block -= line.count(")") if find_block <= 0: soa_block = False elif line.strip() and not line.strip().startswith(('$', ';')): record_lines.append(line) # 去重 cleaned_records = [] seen_records = set() spf_record_found = False # spf 修复 for line in record_lines: match = record_pattern.match(line) if match: # (name, type, value) 唯一标识 r_name, _, _, r_type, r_value_raw = match.groups() r_type = r_type.upper() r_value = r_value_raw.strip().split(";", 1)[0].strip() # spf 唯一性处理 is_spf = ( r_type == "TXT" and (r_value.startswith('"v=spf1') or r_value.startswith('v=spf1')) ) if is_spf: # 首次次标记 if not spf_record_found: spf_record_found = True else: # 后续丢弃 dns_logger(f"Fix Zone [{domain}]: Removed duplicate SPF record: {line.strip()}") continue record_tuple = (r_name.lower(), r_type, r_value.lower()) if record_tuple not in seen_records: seen_records.add(record_tuple) cleaned_records.append(line) else: dns_logger(f"Fix Zone [{domain}]: Removed duplicate record: {line.strip()}") if len(cleaned_records) + len(soa_lines) == org_lines_counts: msg = f"Zone [{domain}] is already clean. No changes made." dns_logger(msg) return msg # 更新 new_lines = soa_lines + cleaned_records if not self._update_soa(new_lines): raise HintException("Failed to update SOA serial number.") public.writeFile(zone_file, "\n".join(new_lines) + "\n") self.reload_service() backup_file(zone_file) msg = f"Zone [{domain}] has been fixed successfully." dns_logger(msg) return msg except Exception as e: pdns_rollback(zone_file) import traceback public.print_log(traceback.format_exc()) raise HintException(f"Fix Zone Failed: {e}") @clean_record_cache def domian_record_type_ttl_batch_set(self, domain: str, record_type: str, ttl: int) -> bool: # 批量设置单个域名的指定类型TTL zone_file = os.path.join(ZONES_DIR, f"{domain}.zone") if not os.path.exists(zone_file): return False backup_file(zone_file) try: lines = self.__read_zone_lines(zone_file) if not lines: return False # 批量更新 updated = False for i, line in enumerate(lines): match = record_pattern.match(line) if match: r_name, _, _, r_type, r_value_raw = match.groups() r_type = r_type.upper() public.print_log(f"r_name={r_name}, r_type={r_type}, r_value_raw={r_value_raw}") if r_type == record_type.upper(): parts = line.split() public.print_log(f"parts = {parts}") if len(parts) >= 4: parts[1] = str(ttl) # 更新TTL lines[i] = "\t".join(parts) updated = True if not updated: return True # 更新SOA序列号 if not self._update_soa(lines): raise HintException("Failed to update SOA serial number.") public.writeFile(zone_file, "\n".join(lines) + "\n") self.reload_service() backup_file(zone_file) return True except Exception as e: pdns_rollback(zone_file) import traceback public.print_log(traceback.format_exc()) raise HintException(f"Set TTL Batch Failed: {e}") if __name__ == '__main__': DnsManager().builtin_dns_checker()