ISPConfig 3 Danymic DNS (DDNS) Module

Discussion in 'Plugins/Modules/Addons' started by mhofer, Nov 12, 2021.

  1. mhofer

    mhofer New Member

    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
     
    Last edited: Nov 12, 2021
    mccharlet, pzajda, webguyz and 4 others like this.
  2. mhofer

    mhofer New Member

    Here are some Screenshots
    Overview:
    upload_2021-11-12_10-38-25.png
    New/Edit token:
    upload_2021-11-12_10-40-31.png
    Update URLs:
    upload_2021-11-12_10-42-53.png
     
    xxfog, Ignacio Garcia and ahrasis like this.
  3. till

    till Super Moderator Staff Member ISPConfig Developer

    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.
     
    mhofer likes this.
  4. ahrasis

    ahrasis Well-Known Member HowtoForge Supporter

    Nice module. Thank you.
     
    mhofer likes this.
  5. Wow! Thanks so much for your addon! Can't wait to try it
     
  6. Th0m

    Th0m ISPConfig Developer Staff Member ISPConfig Developer

    Would you be interested in merging this to the main project, so all users can potentially benefit?
     
    atle likes this.
  7. mhofer

    mhofer New Member

    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?
     
  8. Th0m

    Th0m ISPConfig Developer Staff Member ISPConfig Developer

    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.
     
    Last edited: Mar 3, 2022
  9. mrbcast

    mrbcast New Member

    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.
     
  10. tweyhr3156

    tweyhr3156 New Member

    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 :-(
     
  11. Th0m

    Th0m ISPConfig Developer Staff Member ISPConfig Developer

    tweyhr3156 and ahrasis like this.
  12. xxfog

    xxfog Member HowtoForge Supporter

    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
     
  13. remkoh

    remkoh Well-Known Member HowtoForge Supporter

    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.
     
    xxfog and ahrasis like this.
  14. xxfog

    xxfog Member HowtoForge Supporter

    thank you
     
  15. omolinete

    omolinete New Member

    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!
     
  16. remkoh

    remkoh Well-Known Member HowtoForge Supporter

    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.
     
    omolinete likes this.
  17. omolinete

    omolinete New Member

    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!
     
    Last edited: Jul 24, 2025
  18. remkoh

    remkoh Well-Known Member HowtoForge Supporter

    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.
     
    omolinete likes this.
  19. omolinete

    omolinete New Member

    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!
     
    /dev/null/, ahrasis and remkoh like this.
  20. omolinete

    omolinete New Member

    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:

    1. Split the header by commas to extract individual IP addresses
    2. Iterate through each IP and skip invalid values:
      • Empty strings
      • The literal string "(null)"
      • Any value that fails filter_var() validation
    3. 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.
     

    Attached Files:

    Last edited: Feb 18, 2026 at 12:50 AM
    till likes this.

Share This Page