diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..d85980f --- /dev/null +++ b/.env.dist @@ -0,0 +1,8 @@ +username="max_mustermann" +password="s3cr3t" +apiKey="netcup DNS API Key" +apiPassword="netcup DNS API Password" +customerId="netcup customer ID" +debug=true +log=true +logFile=log.json \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de03f38 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +.env diff --git a/README.md b/README.md index 7b18ff2..1c58bb7 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,37 @@ # ownDynDNS -Self-hosted dynamic DNS php script for FRITZ!Box and netcup DNS API +Self-hosted dynamic DNS php script to update netcup DNS API from Router like AVM FRITZ!Box ## Authors * Felix Kretschmer [@fernwerker](https://github.com/fernwerker) * Philipp Tempel [@philipptempel](https://github.com/philipptempel) +* Branko Wilhelm [@b2un0](https://github.com/b2un0) ## Usage ### Installation * Copy all files to your webspace -* Edit the first lines of update.php - * username -> The username for your FRITZ!Box to authenticate (so not everyone can update your DNS) - * password -> password for your FRITZ!Box - * debug -> enables debug mode and generates output of update.php (normal operation has no output) - * apiKey -> API key which is generated in netcup CCP - * apiPassword -> API password which is generated in netcup CCP +* create a copy of `.env.dist` as `.env` and configure: + * `username` -> The username for your Router to authenticate (so not everyone can update your DNS) + * `password` -> password for your Router + * `apiKey` -> API key which is generated in netcup CCP + * `apiPassword` -> API password which is generated in netcup CCP + * `customerId` -> your netcup Customer ID + * `debug` -> true|false enables debug mode and generates output of update.php (normal operation has no output) -* Create each host record in your netcup CCP before using the script. The script does not create non-existent records. +* Create each host record in your netcup CCP before using the script. The script does not create any missing records. -### FRITZ!Box Settings -* Go to "Internet" -> "DynDNS" -* Choose "custom" +### AVM FRITZ!Box Settings +* Go to "Internet" -> "Freigaben" -> "DynDNS" +* Choose "Benutzerdefiniert" * Update-URL: `https:///update.php?user=&password=&ipv4=&ipv6=&domain=` - * only the url needs to be adjusted, the rest is automatically filled by the FRITZ!Box + * only the url needs to be adjusted, the rest is automatically filled by your AVM FRITZ!Box * http or https is possible if valid SSL certificate (e.g. Let's Encrypt) * Domainname: `` * Username: `` * Password: `` +# run as cronjob on a **nix based device +* see [examples](./examples) + ## References * DNS API Documentation: https://ccp.netcup.net/run/webservice/servers/endpoint.php * Source of dnsapi.php: https://ccp.netcup.net/run/webservice/servers/endpoint.php?PHPSOAPCLIENT diff --git a/examples/update-dyndns.sh b/examples/update-dyndns.sh new file mode 100755 index 0000000..b244874 --- /dev/null +++ b/examples/update-dyndns.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +# you can run this script from **ix bases device to update (different) Records + +USER="max_mustermann" +PASS="s3cr3t" +DOMAIN="my-home-nas.de" +#DOMAIN="nas.my-home.de" +SCRIPT="https:///update.php" +FORCE=0 + +IPV4=$(curl -4 -q ident.me) +IPV6=$(curl -6 -q ident.me) + +echo ${IPV4} +echo ${IPV6} + +# PAYLOAD_IPV4="force=${FORCE}&user=${USER}&password=${PASS}&ipv4=${IPV4}&domain=${DOMAIN}" +# curl -X POST --data "${PAYLOAD_IPV4}" ${SCRIPT} + +# PAYLOAD_IPV6="force=${FORCE}&user=${USER}&password=${PASS}&ipv6=${IPV6}&domain=${DOMAIN}" +# curl -X POST --data "${PAYLOAD_IPV6}" ${SCRIPT} + +PAYLOAD_BOTH="force=${FORCE}&user=${USER}&password=${PASS}&ipv4=${IPV4}&ipv6=${IPV6}&domain=${DOMAIN}" +curl -X POST --data "${PAYLOAD_BOTH}" ${SCRIPT} diff --git a/src/Config.php b/src/Config.php new file mode 100644 index 0000000..309aa06 --- /dev/null +++ b/src/Config.php @@ -0,0 +1,122 @@ + $val) { + if (isset($config[$key])) { + $this->$key = $config[$key]; + } + } + } + + /** + * @return bool + */ + public function isValid() + { + return + !empty($this->username) && + !empty($this->password) && + !empty($this->apiKey) && + !empty($this->apiPassword) && + !empty($this->customerId) && + !empty($this->logFile); + + } + + /** + * @return string + */ + public function getUsername() + { + return $this->username; + } + + /** + * @return string + */ + public function getPassword() + { + return $this->password; + } + + /** + * @return string + */ + public function getApiKey() + { + return $this->apiKey; + } + + /** + * @return string + */ + public function getApiPassword() + { + return $this->apiPassword; + } + + /** + * @return int + */ + public function getCustomerId() + { + return $this->customerId; + } + + /** + * @return string + */ + public function getLogFile() + { + return $this->logFile; + } + + /** + * @return bool + */ + public function isDebug() + { + return $this->debug; + } +} \ No newline at end of file diff --git a/src/Handler.php b/src/Handler.php new file mode 100644 index 0000000..f0ade92 --- /dev/null +++ b/src/Handler.php @@ -0,0 +1,181 @@ +config = new Config($config); + + if (!$this->config->isValid()) { + throw new RuntimeException('configuration invalid'); + } + + $this->payload = new Payload($payload); + + if (!$this->payload->isValid()) { + throw new RuntimeException('payload invalid'); + } + + if ( + $this->config->getUsername() !== $this->payload->getUser() || + $this->config->getPassword() !== $this->payload->getPassword() + ) { + throw new RuntimeException('credentials wrong'); + } + + if (is_readable($this->config->getLogFile())) { + $this->log = json_decode(file_get_contents($this->config->getLogFile()), true); + } else { + $this->log[$this->payload->getDomain()] = []; + } + } + + public function __destruct() + { + $this->doExit(); + } + + /** + * @param string $msg + * + * @return self + */ + private function doLog($msg) + { + $this->log[$this->payload->getDomain()][] = sprintf('[%s] %s', date('c'), $msg); + + if ($this->config->isDebug()) { + printf('[DEBUG] %s %s', $msg, PHP_EOL); + } + + return $this; + } + + private function doExit() + { + // save only the newest 100 log entries for each domain + $this->log[$this->payload->getDomain()] = array_reverse(array_slice(array_reverse($this->log[$this->payload->getDomain()]), 0, 100)); + + if (!is_writable($this->config->getLogFile()) || !file_put_contents($this->config->getLogFile(), json_encode($this->log, JSON_PRETTY_PRINT))) { + printf('[ERROR] unable to write %s %s', $this->config->getLogFile(), PHP_EOL); + } + } + + /** + * + * @return self + */ + public function doRun() + { + $clientRequestId = md5($this->payload->getDomain() . time()); + + $dnsClient = new Soap\DomainWebserviceSoapClient(); + + $loginHandle = $dnsClient->login( + $this->config->getCustomerId(), + $this->config->getApiKey(), + $this->config->getApiPassword(), + $clientRequestId + ); + + if (2000 === $loginHandle->statuscode) { + $this->doLog('api login successful'); + } else { + $this->doLog(sprintf('api login failed, message: %s', $loginHandle->longmessage)); + } + + $infoHandle = $dnsClient->infoDnsRecords( + $this->payload->getHostname(), + $this->config->getCustomerId(), + $this->config->getApiKey(), + $loginHandle->responsedata->apisessionid, + $clientRequestId + ); + + + $changes = false; + + foreach ($infoHandle->responsedata->dnsrecords as $key => $record) { + $recordHostnameReal = ($record->hostname !== '@') ? $record->hostname . '.' . $this->payload->getHostname() : $this->payload->getHostname(); + + if ($recordHostnameReal === $this->payload->getDomain()) { + + // update A Record if exists and IP has changed + if ('A' === $record->type && $this->payload->getIpv4() && + ( + $this->payload->isForce() || + $record->destination !== $this->payload->getIpv4() + ) + ) { + $record->destination = $this->payload->getIpv4(); + $this->doLog(sprintf('IPv4 for %s set to %s', $recordHostnameReal, $this->payload->getIpv4())); + $changes = true; + } + + // update AAAA Record if exists and IP has changed + if ('AAAA' === $record->type && $this->payload->getIpv6() && + ( + $this->payload->isForce() + || $record->destination !== $this->payload->getIpv6() + ) + ) { + $record->destination = $this->payload->getIpv6(); + $this->doLog(sprintf('IPv6 for %s set to %s', $recordHostnameReal, $this->payload->getIpv6())); + $changes = true; + } + } + } + + if (true === $changes) { + $recordSet = new Soap\Dnsrecordset(); + $recordSet->dnsrecords = $infoHandle->responsedata->dnsrecords; + + $dnsClient->updateDnsRecords( + $this->payload->getHostname(), + $this->config->getCustomerId(), + $this->config->getApiKey(), + $loginHandle->responsedata->apisessionid, + $clientRequestId, + $recordSet + ); + + $this->doLog('dns recordset updated'); + } else { + $this->doLog('dns recordset NOT updated (no changes)'); + } + + $logoutHandle = $dnsClient->logout( + $this->config->getCustomerId(), + $this->config->getApiKey(), + $loginHandle->responsedata->apisessionid, + $clientRequestId + ); + + if (2000 === $logoutHandle->statuscode) { + $this->doLog('api logout successful'); + } else { + $this->doLog(sprintf('api logout failed, message: %s', $loginHandle->longmessage)); + } + + return $this; + } +} \ No newline at end of file diff --git a/src/Payload.php b/src/Payload.php new file mode 100644 index 0000000..efb5c74 --- /dev/null +++ b/src/Payload.php @@ -0,0 +1,156 @@ + $val) { + if (isset($payload[$key])) { + $this->$key = $payload[$key]; + } + } + } + + /** + * @return bool + */ + public function isValid() + { + return + !empty($this->user) && + !empty($this->password) && + !empty($this->domain) && + ( + ( + !empty($this->ipv4) && $this->isValidIpv4() + ) + || + ( + !empty($this->ipv6) && $this->isValidIpv6() + ) + ); + } + + /** + * @return string + */ + public function getUser() + { + return $this->user; + } + + /** + * @return string + */ + public function getPassword() + { + return $this->password; + } + + /** + * @return string + */ + public function getDomain() + { + return $this->domain; + } + + /** + * there is no good way to get the correct "registrable" Domain without external libs! + * + * @see https://github.com/jeremykendall/php-domain-parser + * + * this method is still tricky, because: + * + * works: nas.tld.com + * works: nas.tld.de + * works: tld.com + * failed: nas.tld.co.uk + * failed: nas.home.tld.de + * + * @return string + */ + public function getHostname() + { + // hack if top level domain are used for dynDNS + if (1 === substr_count($this->domain, '.')) { + return $this->domain; + } + + $domainParts = explode('.', $this->domain); + array_shift($domainParts); // remove sub domain + return implode('.', $domainParts); + } + + /** + * @return string + */ + public function getIpv4() + { + return $this->ipv4; + } + + /** + * @return bool + */ + public function isValidIpv4() + { + return (bool)filter_var($this->ipv4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4); + } + + /** + * @return string + */ + public function getIpv6() + { + return $this->ipv6; + } + + /** + * @return bool + */ + public function isValidIpv6() + { + return (bool)filter_var($this->ipv6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); + } + + /** + * @return bool + */ + public function isForce() + { + return $this->force; + } +} \ No newline at end of file diff --git a/dnsapi.php b/src/Soap.php similarity index 99% rename from dnsapi.php rename to src/Soap.php index 3256451..52095d5 100644 --- a/dnsapi.php +++ b/src/Soap.php @@ -1,5 +1,10 @@ "); - } - exit(); +if (!file_exists('.env')) { + throw new RuntimeException('.env file missing'); } -// is called to append log to data object and enable debug output -function logging($text){ - global $data, $debug, $getDomain; - array_push($data[$getDomain]['log'], $text); - if($debug == true){ - echo("[DEBUG] $text
"); - } -} -// function to get domain and reduce host from it -function reduceHost($domain){ - $domainParts = explode('.', $domain); - array_shift($domainParts); - $tld = implode('.', $domainParts); - return $tld; -} +$config = parse_ini_file('.env', false, INI_SCANNER_TYPED); -// INIT -if($debug == true){ - error_reporting( E_ALL ); - ini_set('display_errors', 1); -} - -// PRESET VALIDATION -// get data from object, create if not existent -if(file_exists($dataFile)){ - $data = json_decode(@file_get_contents($dataFile), true); -} else { - touch($dataFile); -} - -// check for domain parameter and exit if NULL -if(empty($getDomain)){ - logging("no domain given. exiting..."); - write_and_exit(); -} -// init log array -$data[$getDomain]['log'] = []; - -// authenticate, store number of failed logins -if($getUsername != $username or $getPassword != $password){ - logging("authentication failed. exiting..."); - $data[$getDomain]['failed_logins']++; - write_and_exit(); -} else { - $data[$getDomain]['failed_logins']=0; -} - -// DOMAIN SPECIFIC PROCESSING -// write current timestamp to data array -$data[$getDomain]['timestamp'] = time(); - -// validate IP addresses (v4 and v6) and write to data array -if(filter_var($getIpv4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)){ - logging("valid IPv4"); - // write to data array - $data[$getDomain]['ipv4'] = $getIpv4; -} else { - logging("no valid IPv4"); - // write to data array - $data[$getDomain]['ipv4'] = NULL; -} - -if(filter_var($getIpv6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)){ - logging("valid IPv6"); - // write to data array - $data[$getDomain]['ipv6'] = $getIpv6; -} else { - logging("no valid IPv6"); - // write to data array - $data[$getDomain]['ipv6'] = NULL; -} - -if($data[$getDomain]['ipv4'] == NULL && $data[$getDomain]['ipv6'] == NULL){ - logging("no valid IP found"); - write_and_exit(); -} - -// broadcast new IP to DNS API -$clientRequestId = md5($getDomain); -$dnsClient = new DomainWebserviceSoapClient(); -$clientHandle = $dnsClient->login($customerId, $apiKey, $apiPassword, $clientRequestId); -$infoHandle = $dnsClient->infoDnsRecords(reduceHost($getDomain), $customerId, $apiKey, $clientHandle->responsedata->apisessionid, $clientRequestId);# - -$dnsrecords = $infoHandle->responsedata->dnsrecords; -foreach($dnsrecords as $key => &$record){ - // write IPv4 update set if valid address and existent record - if($record->hostname == explode('.', "$getDomain")[0] && $record->type == "A" && $data[$getDomain]['ipv4'] != NULL){ - $record->destination = $data[$getDomain]['ipv4']; - } - // write IPv6 update set if valid address and existent record - if($record->hostname == explode('.', "$getDomain")[0] && $record->type == "AAAA" && $data[$getDomain]['ipv6'] != NULL){ - $record->destination = $data[$getDomain]['ipv6']; - } -} - -//$clientHandle->clientrequestid = md5(microtime(true)); -$recordSet = new Dnsrecordset(); -$recordSet->dnsrecords = $dnsrecords; - -$updateHandle = $dnsClient->updateDnsRecords(reduceHost($getDomain), $customerId, $apiKey, $clientHandle->responsedata->apisessionid, $clientRequestId, $recordSet); -logging("dns recordset updated"); - -$result = $dnsClient->logout($customerId, $apiKey, $clientHandle->responsedata->apisessionid, $clientRequestId); -logging("api logout"); -// finish -write_and_exit(); -?> +(new netcup\DNS\API\Handler($config, $_REQUEST))->doRun();