diff --git a/README.md b/README.md index f30a8bd..5dbc4fa 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,10 @@ ## Table of contents +* πŸ†• [What is new](#what-is-new) * [What this script does](#what-this-script-does) * [Before you begin](#before-you-begin) -* [How to install](#how-to-install) +* πŸ†• [How to install](#how-to-install) * [Troubleshooting and known issues](#troubleshooting-and-known-issues) + [CloudFlare API free domains limitation](#cloudflare-api-free-domains-limitation) + [Connection test failed or error returned](#connection-test-failed-or-error-returned) @@ -15,12 +16,19 @@ * [Default Cloudflare ports](#default-cloudflare-ports) * [Debug script](#debug) +## What is new + +- πŸ†• New hostname input format: `subdomain1.mydomain.com|subdomain2.mydomain.com` (each domain is separated by three dashes: `|`) used to be with `---` separator +- πŸ†• Hostname input uses a new source of data (account) and support 256 symbols limit (DSM UI limit) +- πŸ†• Autodetect IPv4 and IPv6 addresses +- πŸ†• Optimised request to Cloudflare API +- πŸ†• Installer script ## What this script does * A PHP script for Synology DSM (and potentially Synology SRM devices) adding support for Cloudflare to Network Centre > Dynamic DNS (DDNS). * Supports single domains, multidomains, subdomains and regional domains, or any combination thereof (example: dev.my.domain.com.au, domain.com.uk etc) -* Easy instalation process +* πŸ†• Easy installation process (added auto install script) * Based on CloudFlare API v4 * [Supports dual stack IPv4 and IPv6](https://github.com/mrikirill/SynologyDDNSCloudflareMultidomain/pull/13) @@ -47,7 +55,7 @@ Before starting the installation process, make sure you have (and know) the foll **Include** > **All zones from an account** > `` - 3. *DNS settings:* + 2. *DNS settings:* Ensure the DNS A record(s) for the domain/zone(s) you wish to update with this script have been created (More information: [Managing DNS records](https://support.cloudflare.com/hc/en-us/articles/360019093151-Managing-DNS-records-in-Cloudflare)). @@ -57,7 +65,7 @@ Before starting the installation process, make sure you have (and know) the foll ![image](https://github.com/mrikirill/SynologyDDNSCloudflareMultidomain/blob/master/docs/example1.png) -4. *SSH access to your Synology device:* +3. *SSH access to your Synology device:* If you haven't setup this access, see the following Synology Knowledge Base article: [How can I sign in to DSM/SRM with root privilege via SSH?[(https://kb.synology.com/en-id/DSM/tutorial/How_to_login_to_DSM_with_root_permission_via_SSH_Telnet) @@ -86,30 +94,20 @@ For assistance with vi commands, see: 2. **Connect via SSH:** Connect to your supported device via SSH and execute command -* For DSM Users +* πŸ†• For DSM Users ``` - wget https://raw.githubusercontent.com/mrikirill/SynologyDDNSCloudflareMultidomain/master/cloudflare.php -O /usr/syno/bin/ddns/cloudflare.php && sudo chmod 755 /usr/syno/bin/ddns/cloudflare.php + wget https://raw.githubusercontent.com/mrikirill/SynologyDDNSCloudflareMultidomain/master/install.sh -O install.sh && sudo bash install.sh ``` -* For SRM Users +* πŸ†• For SRM Users Note: Ensure you are connected as root in your SSH session ``` - wget https://raw.githubusercontent.com/mrikirill/SynologyDDNSCloudflareMultidomain/master/cloudflare.php -O /usr/syno/bin/ddns/cloudflare.php && chmod 755 /usr/syno/bin/ddns/cloudflare.php + wget https://raw.githubusercontent.com/mrikirill/SynologyDDNSCloudflareMultidomain/master/install.sh -O install.sh && sudo bash install.sh ``` **Note:** For SRM users, you must connect to your device as root. No other username will allow these commands to run. -3. **Update DDNS provider list:** Using a command line editor, insert the text below to your DMS file (Location : __/etc.defaults/ddns_provider.conf__), to add DDNS support via Cloudflare: - - ``` - [Cloudflare] - modulepath=/usr/syno/bin/ddns/cloudflare.php - queryurl=https://www.cloudflare.com/ - ``` - - **Note:** For SRM users, break out this [Vim cheat sheet](https://coderwall.com/p/adv71w/basic-vim-commands-for-getting-started), as it's the only text editor available to you. - -4. **Update your DDNS settings:** +3. **Update your DDNS settings:** a. *For DSM Users:* Navigate to __Control Panel > External Access > DDNS__ then add new DDNS @@ -118,20 +116,20 @@ For assistance with vi commands, see: Add/Update the DDNS settings screen as follows: * Service provider: Select Cloudflare - * Hostname: - For a single domain: __mydomain.com__ -For multiple domains: __subdomain.mydomain.com---vpn.mydomain.com__ - (ensure each domain is seperated by three dashes: ---) + * πŸ†•Hostname: this field is not used anymore, you can put any value here + * Username: +For a single domain: __mydomain.com__ +For multiple domains: __subdomain.mydomain.com|vpn.mydomain.com__ + πŸ†•(ensure each domain is separated: `|`)πŸ†• - __Note: there is 128 symbols limit on Hostname input__ - * Username: The email address you use for logging in to Cloudflare (optional since the API key is sufficient) + __Note: there is 256 symbols limit on Hostname input__ * Password: Your created Cloudflare API Key ![image](https://github.com/mrikirill/SynologyDDNSCloudflareMultidomain/blob/master/docs/example3.png) Finally, press the test connection button to confirm all information is correctly entered, before pressing Ok to save and confirm your details. -5. Enjoy 🍺 and __don't forget to deactivate SSH (step 1) if you don't need it__. +4. Enjoy 🍺 and __don't forget to deactivate SSH (step 1) if you don't need it__. ## Troubleshooting and known issues @@ -179,15 +177,15 @@ If this occurs, simply [repeat the How to install steps](#how-to-install) shown ## Default Cloudflare ports Source [Identifying network ports compatible with Cloudflare's proxy](https://support.cloudflare.com/hc/en-us/articles/200169156-Identifying-network-ports-compatible-with-Cloudflare-s-proxy) -|HTTP ports supported by Cloudflare | HTTPS ports supported by Cloudflare | -|--|--| -| 80 | 443 | -| 8080 | 2053 | -| 8880 | 2083 | -| 2052 | 2087 | -| 2082 | 2096 | -| 2086 | 8443 | -| 2095 | | +| HTTP ports supported by Cloudflare | HTTPS ports supported by Cloudflare | +|------------------------------------|-------------------------------------| +| 80 | 443 | +| 8080 | 2053 | +| 8880 | 2083 | +| 2052 | 2087 | +| 2082 | 2096 | +| 2086 | 8443 | +| 2095 | | ## Debug @@ -198,12 +196,7 @@ You can run this script directly to see output logs * Run this command: ``` -/usr/bin/php -d open_basedir=/usr/syno/bin/ddns -f /usr/syno/bin/ddns/cloudflare.php "" "your-CloudFlare-token" "your---domains---divided---by---dashes" "ip-address" +/usr/bin/php -d open_basedir=/usr/syno/bin/ddns -f /usr/syno/bin/ddns/cloudflare.php "" "domain1.com|vpn.domain2.com" "your-CloudFlare-token" "" "" ``` * Check output logs - -## Credits - -Table of contents generated with markdown-toc -DB Tech - creating API keys and using Cloudflare CNAME for single updates diff --git a/cloudflare.php b/cloudflare.php index 6da04c1..562ea47 100755 --- a/cloudflare.php +++ b/cloudflare.php @@ -1,306 +1,473 @@ #!/usr/bin/php -d open_basedir=/usr/syno/bin/ddns makeUpdateDNS(); - -class Output +/** + * Note: + * Cloudflare API Key - $argv[2] + * Hostname - $argv[1] we use username as hostname source cause it supports 256 symbols +*/ +$cf = new SynologyCloudflareDDNSAgent($argv[2], $argv[1]); +$cf->setDnsRecords(); +$cf->updateDnsRecords(); + +class SynologyOutput { - // Confirmed & logged interpreted/translated messages by Synology - const SUCCESS = 'good'; // geeft niets? - geeft succesfully registered in logs - const NO_CHANGES = 'nochg'; // geeft niets? - geeft succesfully registered in logs - const HOSTNAME_DOES_NOT_EXIST = 'nohost'; // [The hostname specified does not exist. Check if you created the hostname on the website of your DNS provider] - const HOSTNAME_BLOCKED = 'abuse'; // [The hostname specified is blocked for update abuse] - const HOSTNAME_FORMAT_IS_INCORRECT = 'notfqdn'; // [The format of hostname is not correct] - const AUTHENTICATION_FAILED = 'badauth'; // [Authentication failed] - const DDNS_PROVIDER_DOWN = '911'; // [Server is broken][De DDNS-server is tijdelijk buiten dienst. Neem contact op met de Internet-provider.] - const BAD_HTTP_REQUEST = 'badagent'; // [DDNS function needs to be modified, please contact synology support] - const HOSTNAME_FORMAT_INCORRECT = 'badparam'; // [The format of hostname is not correct] - const BAD_PARAMS = 'badparam'; - // Not logged messages, didn't trigger/work while testing on DSM - const PROVIDER_ADDRESS_NOT_RESOLVED = 'badresolv'; - const PROVIDER_TIMEOUT_CONNECTION = 'badconn'; - - // Console only - custom error messages (not triggered by DSM) - const INSUFFICIENT_OR_UNKNOWN_PARAMETERS = 'Insufficient parameters'; + const SUCCESS = 'good'; // Update successfully + const NO_HOSTNAME = 'nohost'; // The hostname specified does not exist in this user account + const HOSTNAME_INCORRECT = 'notfqdn'; // The hostname specified is not a fully-qualified domain name + const AUTH_FAILED = 'badauth'; // Authenticate failed + const DDNS_FAILED = '911'; // There is a problem or scheduled maintenance on provider side + const BAD_HTTP_REQUEST = 'badagent'; // HTTP method/parameters is not permitted + const BAD_PARAMS = 'badparam'; // Bad params } /** - * DDNS auto updater for Synology NAS - * Base on Cloudflare API v4 - * Supports multidomains and sundomains + * Cloudflare api client + * @link https://developers.cloudflare.com/api/ */ -class updateCFDDNS +class CloudflareAPI { const API_URL = 'https://api.cloudflare.com'; - var $account, $apiKey, $hostList, $ipv4; // argument properties - $ipv4 is provided by DSM itself - var $ip, $dnsRecordIdList = array(), $ipv6 = false; + const ZONES_PER_PAGE = 50; + private $apiKey; - function __construct($argv) + public function __construct($apiKey) { - // Not used: $account ($argv[1]), Used: $apikey ($argv[2]), $hostslist ($argv[3]), $ipv4 ($argv[4]) - $this->apiKey = (string) $argv[2]; // CF Global API Key - $hostnames = (string) $argv[3]; // example: example.com.uk---sundomain.example1.com---example2.com - - $this->ipv6 = $this->getIpAddressIpify(); - - if($this->ipv6) - $this->validateIp((string) $this->ipv6); // Validates IPV6 - - // Since DSM is only providing an IP(v4) address (DSM 6/7 doesn't deliver IPV6) - // I override above IPV4 detection & rely on DSM instead for now - $this->validateIp((string) $argv[4]); - - // Before runs DNS update checks API token is valid or not - if(!$this->isCFTokenValid()) { - $this->badParam(); - } + $this->apiKey = $apiKey; + } - // safer than explode: in case of wrong formatting with --- separations (empty elements removed automatically) - $arHost = preg_split('/(---)/', $hostnames, -1, PREG_SPLIT_NO_EMPTY); + /** + * Makes an API call to the specified Cloudflare endpoint. + * + * @param string $method The HTTP method to use (GET, POST, PUT, PATCH). + * @param string $path The API endpoint path to call. + * @param array $data Optional data to send with the request. + * @return array The JSON-decoded response from the API call. + * @throws Exception If an error occurs during the API call. + */ + private function call($method, $path, $data = []) + { + $options = [ + CURLOPT_URL => self::API_URL . '/' . $path, + CURLOPT_HTTPHEADER => ["Authorization: Bearer $this->apiKey", "Content-Type: application/json"], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => false, + CURLOPT_VERBOSE => false, + CURLOPT_MAXREDIRS => 10, + CURLOPT_TIMEOUT => 30, + ]; - // parse each array element to check if every dns hostname is properly formatted, unset any garbage element - foreach ($arHost as $value) { - if(!preg_match("/^(?!-)(?:(?:[a-zA-Z\d][a-zA-Z\d\-]{0,61})?[a-zA-Z\d]\.){1,126}(?!\d+)[a-zA-Z\d]{1,63}$/", $value)) { - echo Output::HOSTNAME_FORMAT_INCORRECT; - exit(); - } + switch ($method) { + case "GET": + $options[CURLOPT_HTTPGET] = true; + break; + case "POST": + $options[CURLOPT_POST] = true; + $options[CURLOPT_POSTFIELDS] = json_encode($data); + break; + case "PUT": + $options[CURLOPT_CUSTOMREQUEST] = "PUT"; + $options[CURLOPT_POSTFIELDS] = json_encode($data); + break; + case "PATCH": + $options[CURLOPT_CUSTOMREQUEST] = "PATCH"; + $options[CURLOPT_POSTFIELDS] = json_encode($data); + break; + default: + throw new Exception("Invalid HTTP method: $method"); + } + + $req = curl_init(); + curl_setopt_array($req, $options); + $res = curl_exec($req); - $this->hostList[$value] = [ - 'hostname' => '', - 'fullname' => $value, - 'zoneId' => '', - ]; + if (curl_errno($req)) { + throw new Exception('Curl error: ' . curl_error($req)); } - $this->setZones(); + curl_close($req); + $json = json_decode($res, true); - foreach ($this->hostList as $arHost) { - $this->setRecord($arHost, $this->ipv4, 'A'); - if($this->ipv6) { - $this->setRecord($arHost, $this->ipv6, 'AAAA'); - } + if (!$json['success']) { + throw new Exception('API call failed'); } + + return $json; } - + /** - * Checks CF API Token is valid - * - * @return bool - */ - function isCFTokenValid() + * @link https://developers.cloudflare.com/api/operations/user-api-tokens-verify-token + * @throws Exception + */ + public function verifyToken() { - $res = $this->callCFapi("GET", "client/v4/user/tokens/verify"); - if ($res['success']) { - return true; - } - return false; - } + return $this->call("GET", "client/v4/user/tokens/verify"); + } /** - * Update CF DNS records + * Note: getting max 50 zones see the documentation + * @link https://developers.cloudflare.com/api/operations/zones-get + * @throws Exception */ - function makeUpdateDNS() + public function getZones() { - if(empty($this->hostList)) { - $this->badParam('empty host list'); - } - - foreach($this->dnsRecordIdList as $recordId => $dnsRecord) { - $zoneId = $dnsRecord['zoneId']; - unset($dnsRecord['zoneId']); - - $json = $this->callCFapi("PATCH", "client/v4/zones/${zoneId}/dns_records/${recordId}", $dnsRecord); - - if (!$json['success']) { - echo Output::BAD_HTTP_REQUEST; - exit(); - } - } + return $this->call("GET", "client/v4/zones?per_page=" . self::ZONES_PER_PAGE . "&status=active"); + } - echo Output::SUCCESS; + /** + * @link https://developers.cloudflare.com/api/operations/dns-records-for-a-zone-list-dns-records + * @throws Exception + */ + public function getDnsRecords($zoneId, $type, $name) + { + return $this->call("GET", "client/v4/zones/$zoneId/dns_records?type=$type&name=$name"); } - function badParam($msg = '') + /** + * @link https://developers.cloudflare.com/api/operations/dns-records-for-a-zone-patch-dns-record + * @throws Exception + */ + public function updateDnsRecord($zoneId, $recordId, $body) { - echo (strlen($msg) > 0) ? $msg : Output::BAD_PARAMS; - exit(); + return $this->call("PATCH", "client/v4/zones/$zoneId/dns_records/$recordId", $body); } +} +class Ipify +{ + const API_URL = 'https://api64.ipify.org'; /** - * Evaluates IP address type and assigns to the correct IP property type - * Only public addresses accessible from the internet are valid options - * - * @param $ip - * @return bool + * Universal: IPv4/IPv6 + * @link https://www.ipify.org + * @throws Exception */ - function validateIp($ip) + public function getExternalIpAddress() { - if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE )) { - $this->ipv6 = $ip; - } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE )) { - $this->ipv4 = $ip; - } else { - $this->badParam('invalid ip-address'); + $options = [ + CURLOPT_URL => self::API_URL . "/?format=json", + CURLOPT_HTTPHEADER => ["Content-Type: application/json"], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => false, + CURLOPT_VERBOSE => false, + CURLOPT_HTTPGET => true, + CURLOPT_MAXREDIRS => 10, + CURLOPT_TIMEOUT => 30, + ]; + + $req = curl_init(); + curl_setopt_array($req, $options); + $res = curl_exec($req); + + if (curl_errno($req)) { + throw new Exception('Curl error: ' . curl_error($req)); + } + + curl_close($req); + $json = json_decode($res, true); + + if (!$json['ip']) { + throw new Exception('API call failed: ' . json_encode($json)); } - return true; + return $json['ip']; } +} - /* - * Get ip from ipify.org - * Returns IPV6 address or false boolean in case IP6V is not found - */ - function getIpAddressIpify() { - - $curlhandle = curl_init(); - curl_setopt($curlhandle, CURLOPT_URL, "https://api64.ipify.org"); - curl_setopt($curlhandle, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V6); - curl_setopt($curlhandle, CURLOPT_CONNECTTIMEOUT, 10); - curl_setopt($curlhandle, CURLOPT_TIMEOUT, 30); - curl_setopt($curlhandle, CURLOPT_VERBOSE, false); - curl_setopt($curlhandle, CURLOPT_RETURNTRANSFER, true); - $result = curl_exec($curlhandle); - curl_close($curlhandle); - return $result; +class DnsRecordEntity +{ + public $id; + public $type; + public $hostname; + public $ip; + public $zoneId; + public $ttl; + public $proxied; + + public function __construct($id, $type, $hostname, $ip, $zoneId, $ttl, $proxied) + { + $this->id = $id; + $this->type = $type; + $this->hostname = $hostname; + $this->ip = $ip; + $this->zoneId = $zoneId; + $this->ttl = $ttl; + $this->proxied = $proxied; } - /** - * Set ZoneID for each hosts - */ - function setZones() + public function toArray() { - $json = $this->callCFapi("GET", "client/v4/zones"); - if (!$json['success']) { - if(isset($json['errors'][0]['code'])) { - if($json['errors'][0]['code'] == 9109 || $json['errors'][0]['code'] == 6003) { - echo Output::AUTHENTICATION_FAILED; - exit(); - } - } + return [ + 'id' => $this->id, + 'type' => $this->type, + 'name' => $this->hostname, + 'content' => $this->ip, + 'zoneId' => $this->zoneId, + 'ttl' => $this->ttl, + 'proxied' => $this->proxied + ]; + } +} - $this->badParam('getZone unsuccessful response'); - } - $arZones = []; - foreach ($json['result'] as $ar) { - $arZones[] = [ - 'hostname' => $ar['name'], - 'zoneId' => $ar['id'] - ]; +/** + * DDNS auto update agent for Synology DSM + * Supports multidomains and subdomains + */ +class SynologyCloudflareDDNSAgent +{ + private $ipv4, $ipv6, $dnsRecordList = []; + private $cloudflareAPI; + private $ipify; + + function __construct($apiKey, $hostname) + { + $this->cloudflareAPI = new CloudflareAPI($apiKey); + $this->ipify = new Ipify(); + + try { + $ip = $this->ipify->getExternalIpAddress(); + switch ($this->getIpAddressVersion($ip)) { + case 4: + $this->ipv4 = $ip; + break; + case 6: + $this->ipv6 = $ip; + break; + } + } catch (Exception $e) { + echo 'Error: ' . $e->getMessage(); + $this->exitWithSynologyMsg(); } - foreach ($this->hostList as $hostname => $arHost) { - $res = $this->isZonesContainFullname($arZones, $arHost['fullname']); - if(!empty($res)) { - $this->hostList[$hostname]['zoneId'] = $res['zoneId']; - $this->hostList[$hostname]['hostname'] = $res['hostname']; + try { + if (!$this->isCFTokenValid()) { + $this->exitWithSynologyMsg(SynologyOutput::AUTH_FAILED); } + } catch (Exception $e) { + $this->exitWithSynologyMsg(); + } + + $hostnameList = $this->extractHostnames($hostname); + if (empty($hostnameList)) { + $this->exitWithSynologyMsg(SynologyOutput::HOSTNAME_INCORRECT); } + + $this->matchHostnameWithZone($hostnameList); } /** - * Find hostname for full domain name - * example: domain.com.uk --> vpn.domain.com.uk + * Sets DNS A Records for each host in the DNS record list. + * + * Iterates over the dnsRecordList, retrieves existing DNS records + * from the Cloudflare API, and updates the records' IDs, TTL, and proxied status. + * + * If the dnsRecordList is empty, it exits with a NO_HOSTNAME message. + * If an API call fails, it exits with a DDNS_FAILED message. */ - function isZonesContainFullname($arZones, $fullname) + public function setDnsRecords() { - $res = []; - foreach($arZones as $arZone) { - if (strpos($fullname, $arZone['hostname']) !== false) { - $res = $arZone; - break; + if (empty($this->dnsRecordList)) { + $this->exitWithSynologyMsg(SynologyOutput::NO_HOSTNAME); + } + + try { + foreach ($this->dnsRecordList as &$dnsRecord) { + $json = $this->cloudflareAPI->getDnsRecords($dnsRecord->zoneId, $dnsRecord->type, $dnsRecord->hostname); + if (isset($json['result']['0'])) { + $dnsRecord->id = $json['result']['0']['id']; + $dnsRecord->ttl = $json['result']['0']['ttl']; + $dnsRecord->proxied = $json['result']['0']['proxied']; + } } + } catch (Exception $e) { + $this->exitWithSynologyMsg(SynologyOutput::DDNS_FAILED); } - return $res; } /** - * Set A Records for each host + * Updates Cloudflare DNS records + * + * Verifies each DNS record in the list, attempts to update it via the Cloudflare API, + * and outputs 'SUCCESS' if all updates are completed without errors. + * If the DNS record list is empty, it exits with a 'NO_HOSTNAME' message. + * If an API call fails, it exits with a 'BAD_HTTP_REQUEST' message. */ - function setRecord($arHostData, $ip, $type) + function updateDnsRecords() { - if (empty($arHostData['fullname'])) { - return false; + if (empty($this->dnsRecordList)) { + $this->exitWithSynologyMsg(SynologyOutput::NO_HOSTNAME); + } + foreach ($this->dnsRecordList as $dnsRecord) { + try { + $this->cloudflareAPI->updateDnsRecord($dnsRecord->zoneId, $dnsRecord->id, $dnsRecord->toArray()); + } catch (Exception $e) { + $this->exitWithSynologyMsg(SynologyOutput::BAD_HTTP_REQUEST); + } } - $fullname = $arHostData['fullname']; + echo SynologyOutput::SUCCESS; + } - if (empty($arHostData['zoneId'])) { - unset($this->hostList[$fullname]); - return false; + /** + * Matches hostnames with their corresponding Cloudflare zone. + * + * This method fetches the list of zones from the Cloudflare API, + * iterates over each hostname provided, and stores corresponding DNS records + * in the dnsRecordList property if a match is found. + * + * @param array $hostnameList List of hostnames to be matched with zones. + * @throws Exception If an error occurs during the API call, + * it outputs an appropriate Synology message and exits the script. + */ + private function matchHostnameWithZone($hostnameList = []) + { + try { + $zoneList = $this->cloudflareAPI->getZones(); + $zoneList = $zoneList['result']; + foreach ($zoneList as $zone) { + $zoneId = $zone['id']; + $zoneName = $zone['name']; + foreach ($hostnameList as $hostname) { + if (strpos($hostname, $zoneName) !== false) { + $this->dnsRecordList[$hostname] = new DnsRecordEntity( + '', + $this->getDnsRecordType(), + $hostname, + $this->getIpAddress(), + $zoneId, + '', + '' + ); + } + } + } + } catch (Exception $e) { + $this->exitWithSynologyMsg(SynologyOutput::NO_HOSTNAME); } + } - $zoneId = $arHostData['zoneId']; - - $json = $this->callCFapi("GET", "client/v4/zones/${zoneId}/dns_records?type=${type}&name=${fullname}"); + /** + * Determines the DNS record type (A or AAAA) based on the IP version. + * Returns 'AAAA' if IPv6 is present, or 'A' if only IPv4 is available. + * + * @return string The DNS record type, either 'A' or 'AAAA'. + */ + private function getDnsRecordType() + { + return $this->ipv6 ? 'AAAA' : 'A'; + } - if (!$json['success']) { - $this->badParam('unsuccessful response for getRecord host: ' . $fullname); - } + /** + * Retrieves the current external IP address. + * If both IPv4 and IPv6 are available, returns the IPv6 address. + * + * @return string The current external IP address, either IPv4 or IPv6. + */ + private function getIpAddress() + { + return $this->ipv6 ? $this->ipv6 : $this->ipv4; + } - if(isset($json['result']['0'])){ - $this->dnsRecordIdList[$json['result']['0']['id']]['type'] = $type; - $this->dnsRecordIdList[$json['result']['0']['id']]['name'] = $arHostData['fullname']; - $this->dnsRecordIdList[$json['result']['0']['id']]['content'] = $ip; - $this->dnsRecordIdList[$json['result']['0']['id']]['zoneId'] = $arHostData['zoneId']; - $this->dnsRecordIdList[$json['result']['0']['id']]['ttl'] = $json['result']['0']['ttl']; - $this->dnsRecordIdList[$json['result']['0']['id']]['proxied'] = $json['result']['0']['proxied']; + /** + * Extracts valid hostnames from a given string of hostnames separated by pipes (|). + * + * @param string $hostnames A string of hostnames separated by pipes (|). + * @return array An array of validated and parsed hostnames. + */ + private function extractHostnames($hostnames) + { + $arHost = preg_split('/\|/', $hostnames, -1, PREG_SPLIT_NO_EMPTY); + $hostList = []; + foreach ($arHost as $value) { + if ($this->isValidHostname($value)) { + $hostList[] = $value; + } } + return $hostList; } /** - * Call CloudFlare v4 API @link https://api.cloudflare.com/#getting-started-endpoints + * Validates whether a given value is a fully-qualified domain name (FQDN). + * + * Uses a regular expression pattern to check for valid FQDN structure. + * An FQDN must consist of at least one label, each label must be alphanumeric or hyphenated, + * but cannot begin or end with a hyphen, followed by a top-level domain (TLD) that is 2-6 characters long. + * + * @param string $value The input string to be validated as an FQDN. + * @return bool Returns true if the input string is a valid FQDN, false otherwise. */ - function callCFapi($method, $path, $data = []) + private function isValidHostname($value) { - $options = [ - CURLOPT_URL => self::API_URL . '/' . $path, - CURLOPT_HTTPHEADER => ["Authorization: Bearer $this->apiKey", "Content-Type: application/json"], - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HEADER => false, - CURLOPT_VERBOSE => false, - ]; + $domainPattern = "/^(?!-)(?:(?:[a-zA-Z\d][a-zA-Z\d\-]{0,61})?[a-zA-Z\d]\.){1,126}(?!\d+)[a-zA-Z\d]{1,63}$/"; + return preg_match($domainPattern, $value); + } - if(empty($method)){ - $this->badParam('Empty method'); + /** + * Checks CF API Token is valid + * + * This function verifies if the Cloudflare API token is valid by calling the verifyToken + * method of the CloudflareAPI class. If the token is valid, it returns true. + * If an exception occurs during the verification process, the function catches the exception + * and returns false, indicating that the token is not valid or an error has occurred. + * + * @return bool Returns true if the Cloudflare API token is valid, otherwise false. + */ + private function isCFTokenValid() + { + try { + $res = $this->cloudflareAPI->verifyToken(); + return $res['success']; + } catch (Exception $e) { + return false; } + } - switch($method) { - case "GET": - $options[CURLOPT_HTTPGET] = true; - break; - - case "POST": - $options[CURLOPT_POST] = true; - $options[CURLOPT_HTTPGET] = false; - $options[CURLOPT_POSTFIELDS] = json_encode($data); - break; + /** + * Outputs a message and exits the script. + * + * This function is used to print a specified message and then terminate + * the execution of the script. It is primarily used for handling + * and responding to various error conditions during the DNS update process. + * + * @param string $msg The message to be output before exiting. + * If no message is specified, an empty string is printed. + */ + private function exitWithSynologyMsg($msg = '') + { + echo $msg; + exit(); + } - case "PUT": - $options[CURLOPT_POST] = false; - $options[CURLOPT_HTTPGET] = false; - $options[CURLOPT_CUSTOMREQUEST] = "PUT"; - $options[CURLOPT_POSTFIELDS] = json_encode($data); - break; - case "PATCH": - $options[CURLOPT_POST] = false; - $options[CURLOPT_HTTPGET] = false; - $options[CURLOPT_CUSTOMREQUEST] = "PATCH"; - $options[CURLOPT_POSTFIELDS] = json_encode($data); - break; + /** + * Determines the IP address version (IPv4 or IPv6) of a given IP address. + * + * This method checks if the provided IP address is a valid, public IPv6 or IPv4 address. + * + * @param string $ip The IP address to be evaluated. + * @return int Returns 6 if the IP address is a valid IPv6 address, 4 if it is a valid IPv4 address. + * @throws InvalidArgumentException if the IP address is not valid or is not public. + */ + private function getIpAddressVersion($ip) + { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return 6; + } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return 4; + } else { + throw new InvalidArgumentException('Invalid IP address'); } - - $req = curl_init(); - curl_setopt_array($req, $options); - $res = curl_exec($req); - curl_close($req); - return json_decode($res, true); } } -?> +?> \ No newline at end of file diff --git a/docs/example3.png b/docs/example3.png index 04ff1b4..0b939f6 100644 Binary files a/docs/example3.png and b/docs/example3.png differ diff --git a/docs/index.md b/docs/index.md index 1880da4..6422189 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,24 +1,34 @@ # Synology Dynamic DNS with Cloudflare for both multidomains and subdomains +> Documentation website: https://mrikirill.github.io/SynologyDDNSCloudflareMultidomain/ + ## Table of contents +* πŸ†•[What is new](#what-is-new) * [What this script does](#what-this-script-does) * [Before you begin](#before-you-begin) -* [How to install](#how-to-install) +* πŸ†•[How to install](#how-to-install) * [Troubleshooting and known issues](#troubleshooting-and-known-issues) - + [CloudFlare API free domains limitation](#cloudflare-api-free-domains-limitation) - + [Connection test failed or error returned](#connection-test-failed-or-error-returned) - + [Cloudflare no longer listed as a DDNS provider after a DSM update](#cloudflare-no-longer-listed-as-a-ddns-provider-after-dsm-or-srm-updates) + + [CloudFlare API free domains limitation](#cloudflare-api-free-domains-limitation) + + [Connection test failed or error returned](#connection-test-failed-or-error-returned) + + [Cloudflare no longer listed as a DDNS provider after a DSM update](#cloudflare-no-longer-listed-as-a-ddns-provider-after-dsm-or-srm-updates) * [Default Cloudflare ports](#default-cloudflare-ports) * [Debug script](#debug) +## What is new + +- πŸ†• New hostname input format: `subdomain1.mydomain.com|subdomain2.mydomain.com` (each domain is separated by three dashes: `|`) used to be with `---` separator +- πŸ†• Hostname input uses a new source of data (account) and support 256 symbols limit (DSM UI limit) +- πŸ†• Autodetect IPv4 and IPv6 addresses +- πŸ†• Optimised request to Cloudflare API +- πŸ†• Installer script ## What this script does * A PHP script for Synology DSM (and potentially Synology SRM devices) adding support for Cloudflare to Network Centre > Dynamic DNS (DDNS). * Supports single domains, multidomains, subdomains and regional domains, or any combination thereof (example: dev.my.domain.com.au, domain.com.uk etc) -* Easy instalation process +* πŸ†• Easy installation process (added auto install script) * Based on CloudFlare API v4 * [Supports dual stack IPv4 and IPv6](https://github.com/mrikirill/SynologyDDNSCloudflareMultidomain/pull/13) @@ -26,13 +36,14 @@ Before starting the installation process, make sure you have (and know) the following information, or have completed these steps: - 1. *Cloudflare credentials:* - - a. Know your Cloudflare account username (or [register for an account if you're new to Cloudflare](https://dash.cloudflare.com/sign-up)); and - - b. Have your [API key](https://dash.cloudflare.com/profile/api-tokens) - no need to use your Global API key! (More info: [API keys](https://support.cloudflare.com/hc/en-us/articles/200167836-Managing-API-Tokens-and-Keys)). +1. *Cloudflare credentials:* + + a. Know your Cloudflare account username (or [register for an account if you're new to Cloudflare](https://dash.cloudflare.com/sign-up)); and + + b. Have your [API key](https://dash.cloudflare.com/profile/api-tokens) - no need to use your Global API key! (More info: [API keys](https://support.cloudflare.com/hc/en-us/articles/200167836-Managing-API-Tokens-and-Keys)). + + ![image](https://github.com/mrikirill/SynologyDDNSCloudflareMultidomain/blob/master/docs/example4.png) - ![image](example4.png) c. Create a API key with following (3) permissions: @@ -44,16 +55,16 @@ Before starting the installation process, make sure you have (and know) the foll **Include** > **All zones from an account** > `` - 2. *DNS settings:* - - Ensure the DNS A record(s) for the domain/zone(s) you wish to update with this script have been created (More information: [Managing DNS records](https://support.cloudflare.com/hc/en-us/articles/360019093151-Managing-DNS-records-in-Cloudflare)). +2. *DNS settings:* - Your DNS records should appear (or already be setup as follows) in Cloudflare: - - (Note: Having Proxied turned on for your A records isn't necessary, but it will prevent those snooping around from easily finding out your current IP address) + Ensure the DNS A record(s) for the domain/zone(s) you wish to update with this script have been created (More information: [Managing DNS records](https://support.cloudflare.com/hc/en-us/articles/360019093151-Managing-DNS-records-in-Cloudflare)). + + Your DNS records should appear (or already be setup as follows) in Cloudflare: + + (Note: Having Proxied turned on for your A records isn't necessary, but it will prevent those snooping around from easily finding out your current IP address) + + ![image](https://github.com/mrikirill/SynologyDDNSCloudflareMultidomain/blob/master/docs/example1.png) - ![image](example1.png) - 3. *SSH access to your Synology device:* If you haven't setup this access, see the following Synology Knowledge Base article: @@ -71,64 +82,57 @@ For assistance with vi commands, see: 1. **SSH with root privledges on your supported device:** - a. For DSM Users: - - Navigate to __Control Panel > Terminal & SNMP > Enable SSH service__ - - b. For SRM users: - - Navigate to __Control Panel > Services > System Services > Terminal > Enable SSH service__ - - ![image](example2.png) + a. For DSM Users: + + Navigate to __Control Panel > Terminal & SNMP > Enable SSH service__ + + b. For SRM users: + + Navigate to __Control Panel > Services > System Services > Terminal > Enable SSH service__ + + ![image](https://github.com/mrikirill/SynologyDDNSCloudflareMultidomain/blob/master/docs/example2.png) 2. **Connect via SSH:** Connect to your supported device via SSH and execute command -* For DSM Users +* πŸ†• For DSM Users ``` - wget https://raw.githubusercontent.com/mrikirill/SynologyDDNSCloudflareMultidomain/master/cloudflare.php -O /usr/syno/bin/ddns/cloudflare.php && sudo chmod 755 /usr/syno/bin/ddns/cloudflare.php + wget https://raw.githubusercontent.com/mrikirill/SynologyDDNSCloudflareMultidomain/master/install.sh -O install.sh && sudo bash install.sh ``` -* For SRM Users +* πŸ†• For SRM Users Note: Ensure you are connected as root in your SSH session ``` - wget https://raw.githubusercontent.com/mrikirill/SynologyDDNSCloudflareMultidomain/master/cloudflare.php -O /usr/syno/bin/ddns/cloudflare.php && chmod 755 /usr/syno/bin/ddns/cloudflare.php + wget https://raw.githubusercontent.com/mrikirill/SynologyDDNSCloudflareMultidomain/master/install.sh -O install.sh && sudo bash install.sh ``` - **Note:** For SRM users, you must connect to your device as root. No other username will allow these commands to run. - -3. **Update DDNS provider list:** Using a command line editor, insert the text below to your DMS file (Location : __/etc.defaults/ddns_provider.conf__), to add DDNS support via Cloudflare: + **Note:** For SRM users, you must connect to your device as root. No other username will allow these commands to run. - ``` - [Cloudflare] - modulepath=/usr/syno/bin/ddns/cloudflare.php - queryurl=https://www.cloudflare.com/ - ``` +3. **Update your DDNS settings:** - **Note:** For SRM users, break out this [Vim cheat sheet](https://coderwall.com/p/adv71w/basic-vim-commands-for-getting-started), as it's the only text editor available to you. - -4. **Update your DDNS settings:** + a. *For DSM Users:* Navigate to __Control Panel > External Access > DDNS__ then add new DDNS - a. *For DSM Users:* Navigate to __Control Panel > External Access > DDNS__ then add new DDNS - - b. *For SRM users:* Navigate to __Network Centre > Internet > QuickConnect & DDNS > DDNS__ and press the Add button: + b. *For SRM users:* Navigate to __Network Centre > Internet > QuickConnect & DDNS > DDNS__ and press the Add button: - Add/Update the DDNS settings screen as follows: + Add/Update the DDNS settings screen as follows: * Service provider: Select Cloudflare - * Hostname: - For a single domain: __mydomain.com__ -For multiple domains: __subdomain.mydomain.com---vpn.mydomain.com__ - (ensure each domain is seperated by three dashes: ---) - - __Note: there is 128 symbols limit on Hostname input__ - * Username: The email address you use for logging in to Cloudflare (optional since the API key is sufficient) + * πŸ†•Hostname: this field is not used anymore, you can put any value here + * πŸ†•Username: + + For a single domain: __mydomain.com__ + For multiple domains: __subdomain.mydomain.com|vpn.mydomain.com__ + πŸ†•(ensure each domain is separated: `|`)πŸ†• + + __Note: there is 256 symbols limit on Hostname input__ + + __Note: this script matches max 50 zones__ *per_page* param: https://developers.cloudflare.com/api/operations/zones-get * Password: Your created Cloudflare API Key - ![image](example3.png) + ![image](https://github.com/mrikirill/SynologyDDNSCloudflareMultidomain/blob/master/docs/example3.png) - Finally, press the test connection button to confirm all information is correctly entered, before pressing Ok to save and confirm your details. + Finally, press the test connection button to confirm all information is correctly entered, before pressing Ok to save and confirm your details. -5. Enjoy 🍺 and __don't forget to deactivate SSH (step 1) if you don't need it__. +4. Enjoy 🍺 and __don't forget to deactivate SSH (step 1) if you don't need it__. ## Troubleshooting and known issues @@ -176,31 +180,26 @@ If this occurs, simply [repeat the How to install steps](#how-to-install) shown ## Default Cloudflare ports Source [Identifying network ports compatible with Cloudflare's proxy](https://support.cloudflare.com/hc/en-us/articles/200169156-Identifying-network-ports-compatible-with-Cloudflare-s-proxy) -|HTTP ports supported by Cloudflare | HTTPS ports supported by Cloudflare | -|--|--| -| 80 | 443 | -| 8080 | 2053 | -| 8880 | 2083 | -| 2052 | 2087 | -| 2082 | 2096 | -| 2086 | 8443 | -| 2095 | | +| HTTP ports supported by Cloudflare | HTTPS ports supported by Cloudflare | +|------------------------------------|-------------------------------------| +| 80 | 443 | +| 8080 | 2053 | +| 8880 | 2083 | +| 2052 | 2087 | +| 2082 | 2096 | +| 2086 | 8443 | +| 2095 | | ## Debug You can run this script directly to see output logs -* SSH into your Synology system +* SSH into your Synology system -* Run this command: +* Run this command: ``` -/usr/bin/php -d open_basedir=/usr/syno/bin/ddns -f /usr/syno/bin/ddns/cloudflare.php "" "your-CloudFlare-token" "your---domains---divided---by---dashes" "ip-address" +/usr/bin/php -d open_basedir=/usr/syno/bin/ddns -f /usr/syno/bin/ddns/cloudflare.php "" "domain1.com|vpn.domain2.com" "your-CloudFlare-token" "" "" ``` * Check output logs - -## Credits - -Table of contents generated with markdown-toc -DB Tech - creating API keys and using Cloudflare CNAME for single updates diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..c42bdba --- /dev/null +++ b/install.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +# Define constants +PHP_FILE_URL="https://raw.githubusercontent.com/mrikirill/SynologyDDNSCloudflareMultidomain/master/cloudflare.php" +PHP_FILE_DEST="/usr/syno/bin/ddns/cloudflare.php" +TEMP_FILE="/tmp/cloudflare.php" +DDNS_PROVIDER_CONF="/etc.defaults/ddns_provider.conf" +CLOUDFLARE_ENTRY="[Cloudflare]\n modulepath=/usr/syno/bin/ddns/cloudflare.php\n queryurl=https://www.cloudflare.com/\n" + +print_message() { + echo -e "\n[INFO] $1\n" +} + +# Step 1: Download the PHP file to a temporary location +print_message "Downloading cloudflare.php..." +wget $PHP_FILE_URL -O $TEMP_FILE + +# Step 2: Move the downloaded file to the destination +print_message "Copying cloudflare.php to $PHP_FILE_DEST..." +sudo cp $TEMP_FILE $PHP_FILE_DEST + +# Step 3: Change permissions of the copied file +print_message "Changing permissions of cloudflare.php..." +sudo chmod 755 $PHP_FILE_DEST + +# Step 4: Insert Cloudflare configuration into ddns_provider.conf +print_message "Adding Cloudflare configuration to ddns_provider.conf..." +if grep -q "\[Cloudflare\]" $DDNS_PROVIDER_CONF; then + print_message "Cloudflare configuration already exists in ddns_provider.conf. Skipping..." +else + sudo bash -c "echo -e \"$CLOUDFLARE_ENTRY\" >> $DDNS_PROVIDER_CONF" + print_message "Cloudflare configuration added successfully." +fi + +# Clean up temporary file +rm $TEMP_FILE + +# Step 5: Delete the script itself +print_message "Deleting the installation script..." +rm -- "$0" + +print_message "Installation completed." \ No newline at end of file