diff --git a/dns/ddclient/src/opnsense/mvc/app/controllers/OPNsense/DynDNS/forms/dialogAccount.xml b/dns/ddclient/src/opnsense/mvc/app/controllers/OPNsense/DynDNS/forms/dialogAccount.xml index 28d40b7e42..d2f87e1f42 100644 --- a/dns/ddclient/src/opnsense/mvc/app/controllers/OPNsense/DynDNS/forms/dialogAccount.xml +++ b/dns/ddclient/src/opnsense/mvc/app/controllers/OPNsense/DynDNS/forms/dialogAccount.xml @@ -62,7 +62,7 @@ account.zone text - + Zone containing the host entry. diff --git a/dns/ddclient/src/opnsense/mvc/app/models/OPNsense/DynDNS/DynDNS.xml b/dns/ddclient/src/opnsense/mvc/app/models/OPNsense/DynDNS/DynDNS.xml index 0888c87ace..139d884ce5 100644 --- a/dns/ddclient/src/opnsense/mvc/app/models/OPNsense/DynDNS/DynDNS.xml +++ b/dns/ddclient/src/opnsense/mvc/app/models/OPNsense/DynDNS/DynDNS.xml @@ -53,6 +53,7 @@ DNSExit DynDNS.com DnsPark + dnspodcn DSLReports DonDominio Duck DNS diff --git a/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/dnspod_cn.py b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/dnspod_cn.py new file mode 100644 index 0000000000..e95ffbe5d6 --- /dev/null +++ b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/dnspod_cn.py @@ -0,0 +1,301 @@ +""" + Copyright (c) 2024 AnShen + Copyright (c) 2023 Ad Schellevis + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. +""" +import json +import syslog +import time +import hashlib +import hmac +import requests +from datetime import datetime +from . import BaseAccount + + +# dnspod api 3.0 +# https://cloud.tencent.com/document/api/1427 + +class DNSPod_CN(BaseAccount): + _priority = 65535 + + _services = { + 'dnspodcn': 'dnspod.tencentcloudapi.com' + } + + def __init__(self, account: dict): + super().__init__(account) + self.service = 'dnspod' + + @staticmethod + def known_services(): + return {'dnspodcn': 'dnspodcn'} + + @staticmethod + def match(account): + return account.get('service') in DNSPod_CN._services + + + @staticmethod + def _sign(key, msg): + """ + Generate HMAC-SHA256 signature. + + Args: + key (bytes): Signing key + msg (str): Message to sign + + Returns: + bytes: Signature digest + """ + return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest() + + def generate_signature(self, action, payload="{}"): + """ + Generate signature and headers for a Tencent Cloud API request. + + Args: + action (str): API action name + payload (str or dict, optional): Request payload. Defaults to "{}". + + Returns: + tuple: Request headers and canonical request + """ + # Ensure payload is a string + payload = json.dumps(payload) if isinstance(payload, dict) else payload + + # Get current timestamp + timestamp = int(time.time()) + date = datetime.utcfromtimestamp(timestamp).strftime("%Y-%m-%d") + + # Step 1: Create Canonical Request + http_request_method = "POST" + canonical_uri = "/" + canonical_querystring = "" + ct = "application/json; charset=utf-8" + canonical_headers = f"content-type:{ct}\nhost:{self._services[self.settings.get('service')]}\nx-tc-action:{action.lower()}\n" + signed_headers = "content-type;host;x-tc-action" + hashed_request_payload = hashlib.sha256(payload.encode("utf-8")).hexdigest() + + canonical_request = ( + f"{http_request_method}\n" + f"{canonical_uri}\n" + f"{canonical_querystring}\n" + f"{canonical_headers}\n" + f"{signed_headers}\n" + f"{hashed_request_payload}" + ) + + # Step 2: Create String to Sign + algorithm = "TC3-HMAC-SHA256" + credential_scope = f"{date}/{self.service}/tc3_request" + hashed_canonical_request = hashlib.sha256(canonical_request.encode("utf-8")).hexdigest() + + string_to_sign = ( + f"{algorithm}\n" + f"{timestamp}\n" + f"{credential_scope}\n" + f"{hashed_canonical_request}" + ) + + # Step 3: Calculate Signature + secret_date = self._sign(("TC3" + self.settings.get('password')).encode("utf-8"), date) + secret_service = self._sign(secret_date, self.service) + secret_signing = self._sign(secret_service, "tc3_request") + signature = hmac.new(secret_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest() + + # Step 4: Create Authorization Header + authorization = ( + f"{algorithm} " + f"Credential={self.settings.get('username', '')}/{credential_scope}, " + f"SignedHeaders={signed_headers}, " + f"Signature={signature}" + ) + + # Prepare headers + headers = { + "Authorization": authorization, + "Content-Type": "application/json; charset=utf-8", + "Host": self._services[self.settings.get('service')], + "X-TC-Action": action, + "X-TC-Timestamp": str(timestamp), + "X-TC-Version": "2021-03-23", + 'User-Agent': 'OPNsense-dyndns', + } + + return headers, payload + + def send_request(self, action, payload="{}", region="", token=""): + """ + Send a request to the Tencent Cloud API. + + Args: + action (str): API action name + payload (str or dict, optional): Request payload. Defaults to "{}". + region (str, optional): Optional region parameter + token (str, optional): Optional token parameter + + Returns: + dict: API response JSON + """ + # Get headers and prepared payload + headers, payload = self.generate_signature(action, payload) + + # Add optional headers + if region: + headers["X-TC-Region"] = region + if token: + headers["X-TC-Token"] = token + + try: + # Send request using requests library + response = requests.post( + url=f"https://{self._services[self.settings.get('service')]}", + headers=headers, + data=payload, + timeout=10 + ) + + # Raise an exception for bad responses + response.raise_for_status() + + # Return JSON response + return response + + except requests.RequestException as err: + print(f"Request error: {err}") + + # If there's a response, print its content for debugging + if hasattr(err, 'response') and err.response is not None: + print(f"Response content: {err.response.text}") + + return None + + + def execute(self): + if super().execute(): + # IPv4/IPv6 + recordType = "AAAA" if str(self.current_address).find(':') > 1 else "A" + + subdomains = [] + hostnames = self.settings.get('hostnames').split(',') + for _subdomain in hostnames: + if _subdomain == self.settings.get('zone') or _subdomain == '@': + subdomains.append('@') + else: + subdomains.append(_subdomain.replace(f".{self.settings.get('zone')}", '')) + + if len(subdomains) < 1: + syslog.syslog( + syslog.LOG_ERR, + "Account %s hostnames format error" % self.description + ) + return False + + # Get record ID + response = self.send_request( + action='DescribeRecordList', + payload={ + 'RecordType': recordType, + 'Domain': self.settings.get('zone') + } + ) + try: + payload = response.json() + except requests.exceptions.JSONDecodeError: + payload = {} + if 'Response' in payload and 'Error' in payload['Response']: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error parsing JSON response [ZoneID] %s" % (self.description, payload['Response']['Error']['Code']) + ) + return False + if not payload['Response'].get('RecordList', False): + syslog.syslog( + syslog.LOG_ERR, + "Account %s error receiving ZoneID [%s]" % (self.description, response.text) + ) + return False + + record_id_list = [x['RecordId'] for x in payload['Response']['RecordList'] if x['Name'] in subdomains] + if len(record_id_list) < 1: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error Not Found Record [%s]" % (self.description, self.settings.get('hostnames')) + ) + return False + + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s ZoneID for %s %s" % (self.description, self.settings.get('zone'), record_id_list) + ) + + # Send IP address update + response = self.send_request( + action='ModifyRecordBatch', + payload={ + 'RecordIdList': record_id_list, + 'Change': 'value', + 'ChangeTo': str(self.current_address), + } + ) + try: + payload = response.json() + except requests.exceptions.JSONDecodeError: + payload = {} + if 'Response' in payload and 'Error' in payload['Response']: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error parsing JSON response [UpdateIP] %s" % (self.description, payload['Response']['Error']['Code']) + ) + return False + if len(payload['Response']['DetailList']) < 1: + syslog.syslog( + syslog.LOG_ERR, + "Account %s failed to set new ip %s [%s]" % (self.description, self.current_address, response.text) + ) + return False + + record_list = payload['Response']['DetailList'][0].get('RecordList', False) + if record_list and len(record_list) == len(subdomains): + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s set new ip %s %s" % ( + self.description, + self.current_address, + subdomains + ) + ) + + self.update_state(address=self.current_address) + return True + + syslog.syslog( + syslog.LOG_ERR, + "Account %s failed to set new ip %s %s" % (self.description, self.current_address, subdomains) + ) + + + return False diff --git a/dns/ddclient/src/opnsense/service/templates/OPNsense/ddclient/ddclient.conf b/dns/ddclient/src/opnsense/service/templates/OPNsense/ddclient/ddclient.conf index 333d478a83..d7386a0be2 100644 --- a/dns/ddclient/src/opnsense/service/templates/OPNsense/ddclient/ddclient.conf +++ b/dns/ddclient/src/opnsense/service/templates/OPNsense/ddclient/ddclient.conf @@ -36,7 +36,7 @@ use=cmd, cmd="/usr/local/opnsense/scripts/ddclient/checkip -t {{account.force_ss {% if account.service == 'custom' %} protocol={{account.protocol}}, \ server={{account.server}}, \ -{% elif account.service in ['cloudflare', 'digitalocean'] %} +{% elif account.service in ['cloudflare', 'digitalocean', 'dnspodcn'] %} protocol={{account.service}}, \ zone={{account.zone}}, \ {% elif account.service == 'cloudns' %}