Hello I have created a ddns module for ISPConfig 3 and published it on github: https://github.com/mhofer117/ispconfig-ddns-module Unlike the other threads with scripts using the remote api, this one will: - Integrate into the existing DNS menu of ISPConfig 3 - Not require a remote-api user - Allow clients, resellers and admins to create update tokens - Perform updates using a simple URL and the generated token, compatible with many existing consumer solutions without manual cron scripts - Allow restricting a token to individual zones / records / record types - Allow updating A (IPv4) or AAAA (IPv6) In Addition, the token endpoint implements the same authentication rate-limiting as the normal login process. Feedback and suggestions are welcome. Regards
Looks really nice! I moved the post to the ISPconfig addons forum, so it can be found easier. And I'll add a link on the ISPConfig website.
Sure, if this is desired. However there are some things that will need some attention: - On ports other than 443/80, like the default ISPConfig port 8080, it doesnt work with some vendors, namely AVM / FRITZ!Boxes - Putting ISPConfig behind a reverse proxy can also lead to issues if not set up correctly (trusting X-Forwarded-For headers) and is itself a security concern. I thought about providing a proxy script that can be put on any vhost and will communicate with the module. But this will require some config values to be secure and seamless which I would have just added into a config file. If this is integrated there would probably need to be a config interface for this? What other steps would be required from my side to merge this module into the main project?
Good question. The best solution would be something working out of the box, ofcourse. Otherwise, keeping it as a separate module is better. Not sure how we can resolve this easily. Depends on the current code, haven't looked into it yet.
Hey guys, This may seem like a totally stupid question, but I have been using dyndns services for a few years. I have about three remote computers out there that I need to be able to get to without remote services attached. I use the dyndns services for this purpose. That being said I came across the dynamic dns module for ispconfig3 and I am a little confused. Is it setup to use to allow ispconfig to be run on a dynamic ip or is it to allow you to basically run a dynamic dns for what I am needing with ispconfig3? I just don't understand the terminology well enough on dns to understand clearly. If it isn't for what I am needing can you recommend any procedures to allow this? Spending $55 a year for these services isn't terrible, but I would like to do it "in-house" if it would save a little money and time. Thanks so much for any advice and insight you can offer.
Hello, a great module. It's going great for me so far! Can you recommend a client for Windows that is compatible with the module? I haven't found one yet :-(
Hello mhofer, thank you for the work you have done and the pleasure with this module. I have a simple question about the usage: do i have to add zone-entries (A/AAAA Records) by hand before adding a DDNS-Token? I added a token fpr a subdomain "privat.mydomain.tld" but if I open DNS and search inside the "mydomain.tld"-Zone i can find no A or AAAA-Record for "privat". regards Steffan
Creating a DDNS token DOES NOT create a corresponding A or AAAA record in your dns zone. You need to do that yourself. Before or after ddns token creation doesn't matter but it needs to exist before your router/script/whatever you use tries to update it using the ddns token.
Hi everyone, @mhofer — congratulations! This feature is amazing. If the development team could eventually integrate it into the main ISPConfig source code, it would give the project a huge boost and position it above many commercial solutions. As far as I know, to this day, I believe there is still no control panel that offers this functionality. My plan is to test it today, but I have one question regarding updates. When ISPConfig is updated to a new version, will I need to reinstall the module again, including the database part, or will the changes be persistent? Thanks in advance to all!
I've been running it actively for quit some time and haven't had any issues with it after several ISPC updates I've done since. Sofar it has proven to be persistent.
Great! Do you know if I could use the update method via curl like I currently do with Afraid.org? My idea would be to use something like this in my cron: Code: */30 * * * * /usr/bin/curl --silent "https://ddns.domain.tld/ddns/update.php?record=ddnsname.domain.tld&token=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" >/dev/null 2>&1 However, I would need to use the proxy domain method because I have port 8080 protected at iptables level, and since my IP is dynamic, no matter what rule I add to allow access to port 8080, the connection would fail as soon as the IP changes. I also have a few scripts that, every X minutes, resolve the current IP of my DDNS name in Afraid.org and update the dynamic IP in the iptables rule to allow access to port 8080. It’s a pity that iptables doesn’t support FQDNs directly, and paying an additional 25€/month for a static IP is not an option (this is Spain). Thanks again!
You should be able to use any method you want, as long as you're using the correct URL. I use https://<panel proxy hostname>/nic/update?hostname=<ddns hostname>&myip=<my IP> with token as password in several different routers' (mainly Draytek and Mikrotik) ddns config. A proxy between the client and your ISPC panel is no problem as you can see. I have a HAProxy server in between in my testlab and an ispc node nginx webserver as a proxy in another environment.
I just installed the module and ran some tests, and I can confirm it works perfectly on ISPConfig 3.3.0p2. After several tests with curl, I confirm that multiple formats work, but the one I use is this: Code: */30 * * * * /usr/bin/curl --silent "https://ddns.domain.tld/ddns/update.php?record=ddnsname.domain.tld&token=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" >/dev/null 2>&1 Thanks again and best regards!
Hi folks, I'm writing back to this thread after some time because I've continued testing and ran into issues trying to update the IP address from my mobile data connection when using it as a hotspot/tethering for my laptop. Apparently my ISP uses CGNAT, and using the original module code (thanks again for your work @mhofer), it doesn't seem to work in that scenario. Thanks to Claude.ai, I can contribute a small workaround to make it work even when your connection is behind proxies, CGNAT, load balancers, or CDNs. I'll try to outline the steps in order: 1. Apache VirtualHost configuration in ISPConfig: Code: # ddns proxy key header is only required if ispconfig does not trust the x-forwarded-for headers RequestHeader set X-DDNS-Proxy-Key "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" ProxyRequests Off ProxyErrorOverride Off SSLProxyEngine On SSLProxyVerify none SSLProxyCheckPeerCN off ProxyPass /ddns/update.php https://127.0.0.1:8080/ddns/update.php ProxyPass /nic/ https://127.0.0.1:8080/nic/ ProxyPassReverse / https://127.0.0.1:8080/ # Pass client real IP to backend ISPConfig for DDNS detection RequestHeader unset X-Real-IP RequestHeader unset X-Forwarded-For RequestHeader unset X-Forwarded-Proto RequestHeader set X-Real-IP "%{REMOTE_ADDR}e" RequestHeader set X-Forwarded-For "%{REMOTE_ADDR}e" RequestHeader set X-Forwarded-Proto "https" ProxyPreserveHost On 2. Modified content of /usr/local/ispconfig/interface/web/ddns/lib/updater/DdnsUpdater.php Code: <?php require_once(dirname(__FILE__) . '/token/DdnsToken.php'); class DdnsUpdater { /** @var app $_ispconfig */ protected $_ispconfig; /** @var string $_remote_ip */ protected $_remote_ip; /** @var DdnsToken $_token */ protected $_token; /** @var DdnsRequest[] $_requests */ protected $_requests = []; /** @var DdnsResponseWriter $_response_writer */ protected $_response_writer; public function __construct(app $ispconfig, array $config) { $this->_ispconfig = $ispconfig; if ($this->_ispconfig->is_under_maintenance()) { $this->_response_writer->maintenance(); exit; } if (isset($_SERVER['HTTP_X_ORIGINAL_REQUEST_URI'])) { $request_uri = parse_url($_SERVER['HTTP_X_ORIGINAL_REQUEST_URI'], PHP_URL_PATH); } else { $request_uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); } $this->_remote_ip = $this->getRequestIp($config); switch ($request_uri) { case '/nic/dyndns': case '/nic/statdns': # DynDns 1 endpoints require_once(dirname(__FILE__) . '/request/DynDnsRequest.php'); require_once(dirname(__FILE__) . '/response/DynDns1ResponseWriter.php'); $this->_response_writer = new DynDns1ResponseWriter($ispconfig); $this->_requests[] = new DynDnsRequest($_GET['host_id']); break; case '/nic/update': # DynDns 2 endpoint require_once(dirname(__FILE__) . '/request/DynDnsRequest.php'); require_once(dirname(__FILE__) . '/response/DynDns2ResponseWriter.php'); $this->_response_writer = new DynDns2ResponseWriter(); $hostnames = explode(',', $_GET['hostname']); if (sizeof($hostnames) == 0) { $this->_response_writer->missingInput(new DynDnsRequest(null)); exit; } foreach ($hostnames as $hostname) { $this->_requests[] = new DynDnsRequest($hostname); } break; default: require_once(dirname(__FILE__) . '/request/DefaultDdnsRequest.php'); require_once(dirname(__FILE__) . '/response/DefaultDdnsResponseWriter.php'); $this->_response_writer = new DefaultDdnsResponseWriter($ispconfig); $this->_requests[] = new DefaultDdnsRequest(); } $this->_token = new DdnsToken($ispconfig, $this->_remote_ip, $this->getTokenFromRequest(), $this->_response_writer); } public function getRequestIp($config): string { $remote_ip = $_SERVER['REMOTE_ADDR']; if (!isset($_SERVER["HTTP_{$config['PROXY_KEY_HEADER']}"]) || !isset($_SERVER["HTTP_{$config['PROXY_IP_HEADER']}"])) { return $remote_ip; } if ($remote_ip !== $config['TRUSTED_PROXY_IP']) { header("HTTP/1.1 500 Internal Server Error"); echo "Untrusted proxy: '$remote_ip' does not match config TRUSTED_PROXY_IP.\n"; exit; } if (empty($config['TRUSTED_PROXY_KEY']) || $_SERVER["HTTP_{$config['PROXY_KEY_HEADER']}"] !== $config['TRUSTED_PROXY_KEY']) { header("HTTP/1.1 500 Internal Server Error"); echo "Proxy key is invalid.\n"; exit; } $forwarded_ip = $_SERVER["HTTP_{$config['PROXY_IP_HEADER']}"]; // Handle multiple IPs in header (e.g., "ip1, ip2" or "(null), ip") $ips = array_map('trim', explode(',', $forwarded_ip)); $valid_ip = null; foreach ($ips as $ip) { // Skip empty, null, or invalid values if (empty($ip) || $ip === '(null)' || filter_var($ip, FILTER_VALIDATE_IP) === false) { continue; } $valid_ip = $ip; break; // Use first valid IP } if ($valid_ip === null) { header("HTTP/1.1 500 Internal Server Error"); echo "The proxy has forwarded an invalid IP: '$forwarded_ip'.\n"; exit; } return $valid_ip; } protected function getTokenFromRequest(): ?string { if (isset($_GET['token'])) { $token = $_GET['token']; } else if (isset($_SERVER['PHP_AUTH_PW'])) { $token = $_SERVER['PHP_AUTH_PW']; } else { return null; } // only hex characters allowed in token return preg_replace("/[^0-9^a-f]/", "", $token); } public function process(): void { $records = []; foreach ($this->_requests as $request) { $request->autoSetMissingInput($this->_token, $this->_remote_ip); $request->validate($this->_token, $this->_response_writer, $this->_ispconfig); $records[] = $this->loadDnsRecord($request); } $this->updateDnsRecords($records); } protected function loadDnsRecord(DdnsRequest $request): array { // try to load zone $soa = $this->_ispconfig->db->queryOneRecord( "SELECT id,server_id,sys_userid,sys_groupid,origin,ttl,serial FROM dns_soa WHERE origin=?", $request->getZone() ); if ($soa == null || $soa['id'] == null) { $this->_response_writer->dnsNotFound("zone '{$request->getZone()}'"); exit; } // try to load record (for update/delete) $rr = null; $rrResult = $this->_ispconfig->db->query( "SELECT id,data,ttl,serial FROM dns_rr WHERE type=? AND name=? AND zone=?", $request->getRecordType(), $request->getRecord(), $soa['id'] ); if ($rrResult && $rrResult->rows() > 0) { if ($request->getAction() === 'update') { // because we do not work with IDs or 'oldData' (yet), there must be exactly one existing record for update operations if ($rrResult->rows() > 1) { $rrResult->free(); $this->_response_writer->internalError("Found more than one record to update, unable to proceed"); exit; } $rr = $rrResult->get(); } else if ($request->getData() !== null) { // for add / delete, check matching record by data while($record = $rrResult->get()) { if ($record['data'] === $request->getData()) { $rr = $record; break; } } } $rrResult->free(); } if ($request->getAction() !== 'add' && $rr === null) { $this->_response_writer->dnsNotFound( "record '{$request->getRecord()}' of type '{$request->getRecordType()}' in zone '{$request->getZone()}'" ); exit; } if ($request->getAction() === 'add' && $rr !== null) { $this->_response_writer->noUpdateRequired($request); exit; } return [ 'request' => $request, 'soa' => $soa, 'rr' => $rr ]; } protected function updateDnsRecords(array $records): void { $update_performed = false; $unique_soa = []; $longest_ttl = 0; // update DNS records foreach ($records as $record) { $request = $record['request']; $soa = $record['soa']; $rr = $record['rr']; if ($request->getAction() === 'delete') { // delete record if ($rr === null) { // cannot delete non-existing record continue; } $this->_ispconfig->db->datalogDelete('dns_rr', 'id', $rr['id']); $update_performed = true; if ($longest_ttl < (int)$rr['ttl']) { $longest_ttl = (int)$rr['ttl']; } } else if ($request->getAction() === 'update') { // update record if ($rr === null) { $this->_response_writer->internalError("Record is missing for action update, unable to proceed"); exit; } // check if update is required if ($rr['data'] === $request->getData()) { continue; } // Update the RR $rr_update = array( "data" => $request->getData(), "serial" => $this->_ispconfig->validate_dns->increase_serial($rr["serial"]), "stamp" => date('Y-m-d H:i:s') ); $this->_ispconfig->db->datalogUpdate('dns_rr', $rr_update, 'id', $rr['id']); $update_performed = true; if ($longest_ttl < (int)$rr['ttl']) { $longest_ttl = (int)$rr['ttl']; } } else if ($request->getAction() === 'add') { // check record with same data was already found if ($rr !== null && $rr['data'] === $request->getData()) { continue; } // create record // Get the limits of the client $client_group_id = intval($soa["sys_groupid"]); $client = $this->_ispconfig->db->queryOneRecord( "SELECT limit_dns_record FROM sys_group, client WHERE sys_group.client_id = client.client_id and sys_group.groupid = ?", $client_group_id ); // Check if the user may add another record. if($client["limit_dns_record"] >= 0) { $tmp = $this->_ispconfig->db->queryOneRecord( "SELECT count(id) as number FROM dns_rr WHERE sys_groupid = ?", $client_group_id ); if($tmp["number"] >= $client["limit_dns_record"]) { $this->_response_writer->forbidden("new record. dns record limit reached."); exit; } } $rr_insert = array( // "id" auto-generated "server_id" => $soa["server_id"], "sys_userid" => $soa["sys_userid"], "sys_groupid" => $soa["sys_groupid"], "sys_perm_user" => 'riud', "sys_perm_group" => 'riud', "zone" => $soa['id'], "type" => $request->getRecordType(), "ttl" => '3600', "name" => $request->getRecord(), "data" => $request->getData(), "serial" => $this->_ispconfig->validate_dns->increase_serial($rr["serial"]), "active" => 'Y', "stamp" => date('Y-m-d H:i:s') ); // insert the RR $this->_ispconfig->db->datalogInsert('dns_rr', $rr_insert, 'id'); $update_performed = true; if ($longest_ttl < (int)$soa['ttl']) { $longest_ttl = (int)$soa['ttl']; } } if (!array_key_exists($soa['id'], $unique_soa)) { $unique_soa[$soa['id']] = $soa; } } if (!$update_performed) { $this->_response_writer->noUpdateRequired($records[0]['request']); exit; } // Update the serial number of the affected SOA records foreach ($unique_soa as $soa) { //* Update the serial number of the SOA record $soa_update = array( "serial" => $this->_ispconfig->validate_dns->increase_serial($soa["serial"]) ); $this->_ispconfig->db->datalogUpdate('dns_soa', $soa_update, 'id', $soa['id']); // cron runs every full minute, calculate seconds left $cron_eta = 60 - date('s'); } $this->_response_writer->successfulUpdate($records[0]['request'], $longest_ttl, $cron_eta); } } 3. Crontab configuration: Code: # ddns.domain.tld DDNS for IPv4 (CGNAT compatible) # */1 * * * * /usr/bin/curl --silent "https://ddns.domain.tld/ddns/update.php?record=ddnshost.domain.tld&token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&type=A&data=$(curl -s -4 https://api.ipify.org)" &>/dev/null # ddns.domain.tld DDNS for IPv6 -- if available (CGNAT compatible) # #*/1 * * * * /usr/bin/curl --silent "https://ddns.domain.tld/ddns/update.php?record=ddnshost.domain.tld&token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&type=AAAA&data=$(curl -s -6 https://api.ipify.org)" &>/dev/null NOTE: "type=A"+"data=(IPv4_ADDRESS)" or "type=AAAA"+"data=(IPv6_ADDRESS)" are mandatory. 4. Technical Summary of Changes: Code: Bug Fix: Handle Invalid Values in X-Forwarded-For Header Problem: The original `getRequestIp()` function (lines 75-82) failed when the `X-Forwarded-For` header contained multiple comma-separated values with invalid entries like `(null)`. Example header from mobile carrier with CGNAT: X-Forwarded-For: (null), 79.117.75.189 The original code attempted to validate the entire string with `filter_var()`, which failed and threw an error: The proxy has forwarded an invalid IP: '(null), 79.117.75.189'. 5. Solution: Modified the getRequestIp() function to: Split the header by commas to extract individual IP addresses Iterate through each IP and skip invalid values: Empty strings The literal string "(null)" Any value that fails filter_var() validation Return the first valid IP found in the chain 6. Why This Matters: This fix enables DDNS updates to work correctly when... Requests pass through multiple proxy layers Mobile carriers use CGNAT (Carrier-Grade NAT) Intermediate proxies inject placeholder or invalid values Load balancers or CDNs add multiple X-Forwarded-For entries Additional Notes: When using mobile connections or CGNAT, the client must explicitly pass the IP via the data= parameter (or myip= for DynDns endpoint), detected externally via services like ipify.org. PD: I also attach the new file "/usr/local/ispconfig/interface/web/ddns/lib/updater/DdnsUpdater.php" with TXT extension.