├── .editorconfig ├── .gitignore ├── .htaccess ├── LICENSE ├── README.md ├── ddns_token_del.php ├── ddns_token_edit.php ├── ddns_token_list.php ├── form └── ddns_token.tform.php ├── lib ├── admin.conf.php ├── classes │ └── ddns_custom_datasource.inc.php ├── ddns.menu.php ├── lang │ ├── de_ddns_token.lng │ ├── de_ddns_token_list.lng │ ├── en_ddns_token.lng │ └── en_ddns_token_list.lng └── updater │ ├── DdnsUpdater.php │ ├── request │ ├── DdnsRequest.php │ ├── DefaultDdnsRequest.php │ └── DynDnsRequest.php │ ├── response │ ├── DdnsResponseWriter.php │ ├── DefaultDdnsResponseWriter.php │ ├── DynDns1ResponseWriter.php │ └── DynDns2ResponseWriter.php │ └── token │ └── DdnsToken.php ├── list └── ddns_token.list.php ├── migration_1.4.0.sql ├── nic └── .htaccess ├── setup.sql ├── templates ├── ddns_token_edit.htm └── ddns_token_list.htm ├── update.config.local-example.php ├── update.config.php └── update.php /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | # Change these settings to your own preference 10 | indent_style = space 11 | indent_size = 4 12 | max_line_length = 120 13 | 14 | # We recommend you to keep these unchanged 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # idea 2 | *.iml 3 | .idea/ 4 | update.config.local.php 5 | -------------------------------------------------------------------------------- /.htaccess: -------------------------------------------------------------------------------- 1 | # workaround for PHP in CGI mode to pass Authorization header (ddns token) to the PHP scripts 2 | RewriteEngine On 3 | RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Marcel Hofer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ISPConfig 3 Dynamic DNS (DDNS) Module 2 | 3 | - [Features](#features) 4 | - [Screenshots](#screenshots) 5 | - [Installation](#installation) 6 | - [Update](#update) 7 | - [Uninstall](#uninstall) 8 | - [Known/Unknown Issues](#knownunknown-issues) 9 | 10 | For general questions or feedback use the [forum thread on howtoforge](https://www.howtoforge.com/community/threads/ispconfig-3-danymic-dns-ddns-module.87967/). 11 | 12 | ## Features 13 | - Integrated into your ISPConfig 3 DNS menu 14 | - Allows clients, resellers and admins to create ddns tokens 15 | - Updates can be performed with simple GET requests using these tokens 16 | - Updates can be performed using DynDns1 and DynDns2 protocols 17 | - Tokens can be restricted to individual DNS zones, DNS records and records types (A/AAAA) 18 | - Allows updating A (IPv4) and AAAA (IPv6) records 19 | - Allows creating/deleting TXT records. Useful for ACME dns-01 challenges using the custom [certbot-dns-ispconfig-ddns plugin](https://github.com/mhofer117/certbot-dns-ispconfig-ddns) 20 | - The update script shares the same authentication rate-limiting / blocking method from base ISPConfig 21 | - Works in multi-server setups if the DB-Table is created on all servers, [see discussion](https://github.com/mhofer117/ispconfig-ddns-module/issues/4#issuecomment-1437604492) 22 | 23 | ## Screenshots 24 | ![Overview page screenshot](https://user-images.githubusercontent.com/3976393/141506890-7c235b39-6ad9-4519-a482-4f2e8d44740c.png) 25 | ![Edit/New token page screenshot](https://user-images.githubusercontent.com/3976393/141506913-5b56f809-f255-49f8-b7da-fc2dd080c3ff.png) 26 | ![Update URLs modal screenshot](https://user-images.githubusercontent.com/3976393/157296785-6a3c4e00-24b0-431f-91b0-62fc6f32d330.png) 27 | 28 | 29 | 30 | ## Installation 31 | - Create the database table using [`setup.sql`](setup.sql) inside of your existing ispconfig database, usually called "dbispconfig" 32 | - Checkout the repository or download and extract a release on your server 33 | - Move the directory to the correct location: `mv ispconfig-ddns-module /usr/local/ispconfig/interface/web/ddns` 34 | - Set permissions and create symlinks as follows: 35 | ```` 36 | # install module 37 | chown -R ispconfig:ispconfig /usr/local/ispconfig/interface/web/ddns 38 | # setup dependency class 39 | ln -s -f /usr/local/ispconfig/interface/web/ddns/lib/classes/ddns_custom_datasource.inc.php /usr/local/ispconfig/interface/lib/classes/ 40 | chown -h ispconfig:ispconfig /usr/local/ispconfig/interface/lib/classes/ddns_custom_datasource.inc.php 41 | # link menu entries in DNS module 42 | mkdir -p /usr/local/ispconfig/interface/web/dns/lib/menu.d 43 | ln -s -f /usr/local/ispconfig/interface/web/ddns/lib/ddns.menu.php /usr/local/ispconfig/interface/web/dns/lib/menu.d/ 44 | chown -h ispconfig:ispconfig /usr/local/ispconfig/interface/web/dns/lib/menu.d/ddns.menu.php 45 | # link nic directory to support dyndns v1/v2 protocol endpoints 46 | ln -s -f /usr/local/ispconfig/interface/web/ddns/nic /usr/local/ispconfig/interface/web/ 47 | chown -h ispconfig:ispconfig /usr/local/ispconfig/interface/web/nic 48 | ```` 49 | 50 | If you are using nginx, [you will also need to set up a proxy host](https://github.com/mhofer117/ispconfig-ddns-module/wiki/Setup-Proxy-Domain-(nginx)) 51 | 52 | ## Update 53 | If you pulled the module to your server with git, use `git pull`, otherwise download the latest release and override all existing files. 54 | After that, re-run the commands from the installation steps to fix permissions / symlinks. 55 | 56 | ## Uninstall 57 | - Remove module database table ``DROP TABLE IF EXISTS `ddns_token`;`` 58 | - Delete all module files and related symlinks 59 | ```` 60 | rm -f /usr/local/ispconfig/interface/lib/classes/ddns_custom_datasource.inc.php 61 | rm -f /usr/local/ispconfig/interface/web/dns/lib/menu.d/ddns.menu.php 62 | rmdir /usr/local/ispconfig/interface/web/dns/lib/menu.d 63 | rm -rf /usr/local/ispconfig/interface/web/ddns 64 | rm -rf /usr/local/ispconfig/interface/web/nic 65 | ```` 66 | 67 | ## Known Issues 68 | - Paging does not work correctly, show all records on the same page to work around this 69 | - nginx will not respect the .htaccess file used for DynDns, you need to set up a revers proxy domain, 70 | [see the guide in the wiki](https://github.com/mhofer117/ispconfig-ddns-module/wiki/Setup-Proxy-Domain-(nginx)) 71 | - The following clients require ISPConfig on a default port (443 or 80): 72 | - DynDns1 and DynDns2 protocols, for example with [ddclient](https://github.com/ddclient/ddclient) 73 | - FRITZ!Box (tm) (may support :8080 and other ports in a future update) 74 | - maybe others 75 | - Workaround: if you don't want to change the ISPConfig port for some reason, you can set up a proxy domain 76 | [as described in the wiki](https://github.com/mhofer117/ispconfig-ddns-module/wiki/Setup-Proxy-Domain) 77 | -------------------------------------------------------------------------------- /ddns_token_del.php: -------------------------------------------------------------------------------- 1 | auth; 8 | 9 | // From and List definition files 10 | $list_def_file = 'list/ddns_token.list.php'; 11 | $tform_def_file = 'form/ddns_token.tform.php'; 12 | 13 | //* Check permissions for module 14 | $auth->check_module_permissions('dns'); 15 | 16 | // Load the form 17 | $app->uses('tform_actions'); 18 | $app->tform_actions->onDelete(); 19 | 20 | ?> 21 | -------------------------------------------------------------------------------- /ddns_token_edit.php: -------------------------------------------------------------------------------- 1 | auth; 8 | 9 | /****************************************** 10 | * Begin Form configuration 11 | ******************************************/ 12 | 13 | $tform_def_file = 'form/ddns_token.tform.php'; 14 | 15 | /****************************************** 16 | * End Form configuration 17 | ******************************************/ 18 | 19 | //* Check permissions for module 20 | $auth->check_module_permissions('dns'); 21 | 22 | $app->uses('tpl,tform,tform_actions'); 23 | $app->load('tform_actions'); 24 | 25 | // Create a class page_action that extends the tform_actions base class 26 | class page_action extends tform_actions { 27 | 28 | function onBeforeInsert() 29 | { 30 | global $app, $conf; 31 | 32 | if($this->id <= 0) { 33 | try { 34 | // generate 48 character hex string (192 bits of entropy) 35 | $this->dataRecord['token'] = bin2hex(random_bytes(24)); 36 | } catch (Exception $e) { 37 | $app->tform->errorMessage = "Unable to generate random token: " . $e->getMessage(); 38 | } 39 | } 40 | 41 | parent::onBeforeInsert(); 42 | } 43 | 44 | function onShowEnd() { 45 | global $app, $conf; 46 | // If user is admin, we will allow him to select to whom this record belongs 47 | if($_SESSION["s"]["user"]["typ"] == 'admin') { 48 | // Getting all users 49 | $sql = "SELECT sys_group.groupid, sys_group.name, CONCAT(IF(client.company_name != '', CONCAT(client.company_name, ' :: '), ''), client.contact_name, ' (', client.username, IF(client.customer_no != '', CONCAT(', ', client.customer_no), ''), ')') as contactname FROM sys_group, client WHERE sys_group.client_id = client.client_id AND sys_group.client_id > 0 ORDER BY client.company_name, client.contact_name, sys_group.name"; 50 | $clients = $app->db->queryAllRecords($sql); 51 | $clients = $app->functions->htmlentities($clients); 52 | $client_select = ''; 53 | if($_SESSION["s"]["user"]["typ"] == 'admin') $client_select .= ""; 54 | if(is_array($clients)) { 55 | foreach( $clients as $client) { 56 | $selected = @(is_array($this->dataRecord) && ($client["groupid"] == $this->dataRecord['client_group_id'] || $client["groupid"] == $this->dataRecord['sys_groupid']))?'SELECTED':''; 57 | $client_select .= "\r\n"; 58 | } 59 | } 60 | $app->tpl->setVar("client_group_id", $client_select); 61 | } else if($app->auth->has_clients($_SESSION['s']['user']['userid'])) { 62 | 63 | // Get the limits of the client 64 | $client_group_id = intval($_SESSION["s"]["user"]["default_group"]); 65 | $client = $app->db->queryOneRecord("SELECT client.client_id, client.contact_name, CONCAT(IF(client.company_name != '', CONCAT(client.company_name, ' :: '), ''), client.contact_name, ' (', client.username, IF(client.customer_no != '', CONCAT(', ', client.customer_no), ''), ')') as contactname, sys_group.name FROM sys_group, client WHERE sys_group.client_id = client.client_id and sys_group.groupid = ?", $client_group_id); 66 | $client = $app->functions->htmlentities($client); 67 | 68 | // Fill the client select field 69 | $sql = "SELECT sys_group.groupid, sys_group.name, CONCAT(IF(client.company_name != '', CONCAT(client.company_name, ' :: '), ''), client.contact_name, ' (', client.username, IF(client.customer_no != '', CONCAT(', ', client.customer_no), ''), ')') as contactname FROM sys_group, client WHERE sys_group.client_id = client.client_id AND client.parent_client_id = ? ORDER BY client.company_name, client.contact_name, sys_group.name"; 70 | $clients = $app->db->queryAllRecords($sql, $client['client_id']); 71 | $clients = $app->functions->htmlentities($clients); 72 | $tmp = $app->db->queryOneRecord("SELECT groupid FROM sys_group WHERE client_id = ?", $client['client_id']); 73 | $client_select = ''; 74 | //$tmp_data_record = $app->tform->getDataRecord($this->id); 75 | if(is_array($clients)) { 76 | foreach( $clients as $client) { 77 | $selected = @(is_array($this->dataRecord) && ($client["groupid"] == $this->dataRecord['client_group_id'] || $client["groupid"] == $this->dataRecord['sys_groupid']))?'SELECTED':''; 78 | $client_select .= "\r\n"; 79 | } 80 | } 81 | $app->tpl->setVar("client_group_id", $client_select); 82 | 83 | } 84 | parent::onShowEnd(); 85 | } 86 | 87 | function onSubmit() { 88 | global $app, $conf; 89 | 90 | # statically limit the record to this server only (issue #11) 91 | $this->dataRecord['server_id'] = $conf['server_id']; 92 | 93 | if($_SESSION['s']['user']['typ'] != 'admin' && !$app->auth->has_clients($_SESSION['s']['user']['userid'])) unset($this->dataRecord["client_group_id"]); 94 | 95 | parent::onSubmit(); 96 | } 97 | 98 | function onAfterInsert() { 99 | global $app, $conf; 100 | 101 | if($_SESSION["s"]["user"]["typ"] == 'admin' && isset($this->dataRecord["client_group_id"])) { 102 | $client_group_id = $app->functions->intval($this->dataRecord["client_group_id"]); 103 | $app->db->query("UPDATE ddns_token SET sys_groupid = ?, sys_perm_group = 'riud' WHERE id = ?", $client_group_id, $this->id); 104 | } 105 | if($app->auth->has_clients($_SESSION['s']['user']['userid']) && isset($this->dataRecord["client_group_id"])) { 106 | $client_group_id = intval($_SESSION["s"]["user"]["default_group"]); 107 | $client = $app->db->queryOneRecord("SELECT client.client_id, client.contact_name, CONCAT(IF(client.company_name != '', CONCAT(client.company_name, ' :: '), ''), client.contact_name, ' (', client.username, IF(client.customer_no != '', CONCAT(', ', client.customer_no), ''), ')') as contactname, sys_group.name FROM sys_group, client WHERE sys_group.client_id = client.client_id and sys_group.groupid = ?", $client_group_id); 108 | $client = $app->functions->htmlentities($client); 109 | $sql = "SELECT sys_group.groupid, sys_group.name, CONCAT(IF(client.company_name != '', CONCAT(client.company_name, ' :: '), ''), client.contact_name, ' (', client.username, IF(client.customer_no != '', CONCAT(', ', client.customer_no), ''), ')') as contactname FROM sys_group, client WHERE sys_group.client_id = client.client_id AND client.parent_client_id = ? ORDER BY client.company_name, client.contact_name, sys_group.name"; 110 | $clients = $app->db->queryAllRecords($sql, $client['client_id']); 111 | $clients = $app->functions->htmlentities($clients); 112 | $valid_group_ids = array(); 113 | if(is_array($clients)) { 114 | foreach( $clients as $client) { 115 | array_push($valid_group_ids, $client['groupid']); 116 | } 117 | } 118 | if (array_search($this->dataRecord["client_group_id"], $valid_group_ids)) { 119 | $set_client_group_id = $app->functions->intval($this->dataRecord["client_group_id"]); 120 | $app->db->query("UPDATE ddns_token SET sys_groupid = ?, sys_perm_group = 'riud' WHERE id = ?", $set_client_group_id, $this->id); 121 | } 122 | } 123 | } 124 | 125 | function onAfterUpdate() { 126 | global $app, $conf; 127 | 128 | if($_SESSION["s"]["user"]["typ"] == 'admin' && isset($this->dataRecord["client_group_id"])) { 129 | $client_group_id = $app->functions->intval($this->dataRecord["client_group_id"]); 130 | $app->db->query("UPDATE ddns_token SET sys_groupid = ?, sys_perm_group = 'riud' WHERE id = ?", $client_group_id, $this->id); 131 | } 132 | if($app->auth->has_clients($_SESSION['s']['user']['userid']) && isset($this->dataRecord["client_group_id"])) { 133 | $client_group_id = intval($_SESSION["s"]["user"]["default_group"]); 134 | $client = $app->db->queryOneRecord("SELECT client.client_id, client.contact_name, CONCAT(IF(client.company_name != '', CONCAT(client.company_name, ' :: '), ''), client.contact_name, ' (', client.username, IF(client.customer_no != '', CONCAT(', ', client.customer_no), ''), ')') as contactname, sys_group.name FROM sys_group, client WHERE sys_group.client_id = client.client_id and sys_group.groupid = ?", $client_group_id); 135 | $client = $app->functions->htmlentities($client); 136 | $sql = "SELECT sys_group.groupid, sys_group.name, CONCAT(IF(client.company_name != '', CONCAT(client.company_name, ' :: '), ''), client.contact_name, ' (', client.username, IF(client.customer_no != '', CONCAT(', ', client.customer_no), ''), ')') as contactname FROM sys_group, client WHERE sys_group.client_id = client.client_id AND client.parent_client_id = ? ORDER BY client.company_name, client.contact_name, sys_group.name"; 137 | $clients = $app->db->queryAllRecords($sql, $client['client_id']); 138 | $clients = $app->functions->htmlentities($clients); 139 | $valid_group_ids = array(); 140 | if(is_array($clients)) { 141 | foreach( $clients as $client) { 142 | array_push($valid_group_ids, $client['groupid']); 143 | } 144 | } 145 | if (array_search($this->dataRecord["client_group_id"], $valid_group_ids)) { 146 | $set_client_group_id = $app->functions->intval($this->dataRecord["client_group_id"]); 147 | $app->db->query("UPDATE ddns_token SET sys_groupid = ?, sys_perm_group = 'riud' WHERE id = ?", $set_client_group_id, $this->id); 148 | } 149 | } 150 | } 151 | 152 | } 153 | 154 | $page = new page_action(); 155 | $page->onLoad(); 156 | 157 | ?> 158 | -------------------------------------------------------------------------------- /ddns_token_list.php: -------------------------------------------------------------------------------- 1 | auth; 8 | 9 | /****************************************** 10 | * Begin Form configuration 11 | ******************************************/ 12 | 13 | $list_def_file = 'list/ddns_token.list.php'; 14 | 15 | /****************************************** 16 | * End Form configuration 17 | ******************************************/ 18 | 19 | //* Check permissions for module 20 | $auth->check_module_permissions('dns'); 21 | 22 | $app->uses('listform_actions'); 23 | 24 | // load custom config for PROXY_HOST variable 25 | $default_config = array('PROXY_HOST' => ''); 26 | if (file_exists(dirname(__FILE__) . '/update.config.local.php')) { 27 | $config_local = include dirname(__FILE__) . '/update.config.local.php'; 28 | $config = array_merge($default_config, $config_local); 29 | } else { 30 | $config = $default_config; 31 | } 32 | $app->tpl->setVar('PROXY_HOST', $config['PROXY_HOST']); 33 | 34 | // $app->listform_actions->SQLExtWhere = "dns_soa.access = 'REJECT'"; 35 | // $app->listform_actions->SQLOrderBy = 'ORDER BY dns_soa.origin'; 36 | 37 | $app->listform_actions->onLoad(); 38 | 39 | 40 | ?> 41 | -------------------------------------------------------------------------------- /form/ddns_token.tform.php: -------------------------------------------------------------------------------- 1 | 0 id must match with id of current user 40 | $form['auth_preset']['userid'] = 0; 41 | 42 | // 0 = default groupid of the user, > 0 id must match with groupid of current 43 | $form['auth_preset']['groupid'] = 0; 44 | 45 | // Permissions with the following codes: r = read, i = insert, u = update, d = delete 46 | $form['auth_preset']['perm_user'] = 'riud'; 47 | $form['auth_preset']['perm_group'] = 'riud'; 48 | $form['auth_preset']['perm_other'] = ''; 49 | 50 | // The form definition of the first tab. The name of the tab is called 'message'. We refer 51 | // to this name in the $form['tab_default'] setting above. 52 | $form['tabs']['token'] = array( 53 | 'title' => 'token_txt', // Title of the Tab 54 | 'width' => 150, // Tab width 55 | 'template' => 'templates/ddns_token_edit.htm', // Template file name 56 | 'fields' => array( 57 | //*** BEGIN Datatable columns ********************************** 58 | 'server_id' => array ( 59 | 'datatype' => 'INTEGER', 60 | 'formtype' => 'SELECT', 61 | 'default' => '', 62 | 'value' => '', 63 | 'width' => '30', 64 | 'maxlength' => '255' 65 | ), 66 | 'token' => array( 67 | 'datatype' => 'VARCHAR', 68 | 'formtype' => 'TEXT', 69 | 'default' => '', 70 | 'value' => '', 71 | 'validators' => array( 72 | 0 => array( 73 | 'type' => 'REGEX', 74 | 'regex' => '/^[0-9a-f]{48}$/', 75 | 'errmsg' => 'token_error_regex' 76 | ), 77 | ), 78 | 'width' => '48', 79 | 'maxlength' => '48' 80 | ), 81 | 'allowed_zones' => array( 82 | 'datatype' => 'VARCHAR', 83 | 'formtype' => 'MULTIPLE', 84 | 'separator' => ',', 85 | 'default' => '', 86 | 'validators' => array( 87 | 0 => array( 88 | 'type' => 'NOTEMPTY', 89 | 'errmsg' => 'allowed_zones_notempty_txt' 90 | ) 91 | ), 92 | 'datasource' => array( 93 | 'type' => 'CUSTOM', 94 | 'class' => 'ddns_custom_datasource', 95 | 'function' => 'dns_zones' 96 | ), 97 | 'value' => '', 98 | 'name' => 'zones', 99 | 'maxlength' => '500' 100 | ), 101 | 'allowed_record_types' => array( 102 | 'datatype' => 'VARCHAR', 103 | 'formtype' => 'CHECKBOXARRAY', 104 | 'separator' => ',', 105 | 'default' => 'A,AAAA', 106 | 'validators' => array( 107 | 0 => array( 108 | 'type' => 'NOTEMPTY', 109 | 'errmsg' => 'allowed_record_types_notempty_txt' 110 | ) 111 | ), 112 | 'value' => array('A' => 'A (IPv4)', 'AAAA' => 'AAAA (IPv6)', 'TXT' => 'TXT'), 113 | 'name' => 'record_types', 114 | 'maxlength' => '255' 115 | ), 116 | 'limit_records' => array( 117 | 'datatype' => 'VARCHAR', 118 | 'formtype' => 'TEXT', 119 | 'validators' => array( 120 | 0 => array( 121 | 'type' => 'REGEX', 122 | 'regex' => '/^([a-zA-Z0-9\_\.\-\*],?)*$/', 123 | 'errmsg' => 'limit_records_error_regex_txt' 124 | ) 125 | ), 126 | 'filters' => array( 127 | 0 => array('event' => 'SAVE', 'type' => 'IDNTOASCII'), 128 | 1 => array('event' => 'SHOW', 'type' => 'IDNTOUTF8'), 129 | 2 => array('event' => 'SAVE', 'type' => 'TOLOWER') 130 | ), 131 | 'default' => '', 132 | 'value' => '', 133 | 'width' => '30', 134 | 'maxlength' => '255' 135 | ), 136 | 'active' => array( 137 | 'datatype' => 'VARCHAR', 138 | 'formtype' => 'CHECKBOX', 139 | 'default' => 'Y', 140 | 'value' => array(0 => 'N', 1 => 'Y') 141 | ), 142 | 143 | //*** END Datatable columns ********************************** 144 | ) 145 | ); 146 | ?> 147 | -------------------------------------------------------------------------------- /lib/admin.conf.php: -------------------------------------------------------------------------------- 1 | 9 | 10 | -------------------------------------------------------------------------------- /lib/classes/ddns_custom_datasource.inc.php: -------------------------------------------------------------------------------- 1 | db->queryAllRecords("SELECT id,origin FROM dns_soa WHERE ".$app->tform->getAuthSQL('r')." ORDER BY origin"); 8 | $zones_new = array(); 9 | foreach($zones as $zone) { 10 | $zones_new[$zone['origin']] = $zone['origin']; 11 | } 12 | return $zones_new; 13 | } 14 | 15 | } 16 | 17 | ?> 18 | -------------------------------------------------------------------------------- /lib/ddns.menu.php: -------------------------------------------------------------------------------- 1 | "Tokens", 9 | 'target' => 'content', 10 | 'link' => 'ddns/ddns_token_list.php', 11 | 'html_id' => 'dns_wizard'); 12 | $module['nav'][] = array( 13 | 'title' => 'Dynamic DNS', 14 | 'open' => 1, 15 | 'items' => $items 16 | ); 17 | 18 | ?> 19 | 20 | -------------------------------------------------------------------------------- /lib/lang/de_ddns_token.lng: -------------------------------------------------------------------------------- 1 | _ispconfig = $ispconfig; 20 | if ($this->_ispconfig->is_under_maintenance()) { 21 | $this->_response_writer->maintenance(); 22 | exit; 23 | } 24 | if (isset($_SERVER['HTTP_X_ORIGINAL_REQUEST_URI'])) { 25 | $request_uri = parse_url($_SERVER['HTTP_X_ORIGINAL_REQUEST_URI'], PHP_URL_PATH); 26 | } else { 27 | $request_uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); 28 | } 29 | $this->_remote_ip = $this->getRequestIp($config); 30 | switch ($request_uri) { 31 | case '/nic/dyndns': 32 | case '/nic/statdns': 33 | # DynDns 1 endpoints 34 | require_once(dirname(__FILE__) . '/request/DynDnsRequest.php'); 35 | require_once(dirname(__FILE__) . '/response/DynDns1ResponseWriter.php'); 36 | $this->_response_writer = new DynDns1ResponseWriter($ispconfig); 37 | $this->_requests[] = new DynDnsRequest($_GET['host_id']); 38 | break; 39 | case '/nic/update': 40 | # DynDns 2 endpoint 41 | require_once(dirname(__FILE__) . '/request/DynDnsRequest.php'); 42 | require_once(dirname(__FILE__) . '/response/DynDns2ResponseWriter.php'); 43 | $this->_response_writer = new DynDns2ResponseWriter(); 44 | $hostnames = explode(',', $_GET['hostname']); 45 | if (sizeof($hostnames) == 0) { 46 | $this->_response_writer->missingInput(new DynDnsRequest(null)); 47 | exit; 48 | } 49 | foreach ($hostnames as $hostname) { 50 | $this->_requests[] = new DynDnsRequest($hostname); 51 | } 52 | break; 53 | default: 54 | require_once(dirname(__FILE__) . '/request/DefaultDdnsRequest.php'); 55 | require_once(dirname(__FILE__) . '/response/DefaultDdnsResponseWriter.php'); 56 | $this->_response_writer = new DefaultDdnsResponseWriter($ispconfig); 57 | $this->_requests[] = new DefaultDdnsRequest(); 58 | } 59 | $this->_token = new DdnsToken($ispconfig, $this->_remote_ip, $this->getTokenFromRequest(), $this->_response_writer); 60 | } 61 | 62 | public function getRequestIp($config): string 63 | { 64 | $remote_ip = $_SERVER['REMOTE_ADDR']; 65 | if (!isset($_SERVER["HTTP_{$config['PROXY_KEY_HEADER']}"]) || !isset($_SERVER["HTTP_{$config['PROXY_IP_HEADER']}"])) { 66 | return $remote_ip; 67 | } 68 | if ($remote_ip !== $config['TRUSTED_PROXY_IP']) { 69 | header("HTTP/1.1 500 Internal Server Error"); 70 | echo "Untrusted proxy: '$remote_ip' does not match config TRUSTED_PROXY_IP.\n"; 71 | exit; 72 | } 73 | if (empty($config['TRUSTED_PROXY_KEY']) || $_SERVER["HTTP_{$config['PROXY_KEY_HEADER']}"] !== $config['TRUSTED_PROXY_KEY']) { 74 | header("HTTP/1.1 500 Internal Server Error"); 75 | echo "Proxy key is invalid.\n"; 76 | exit; 77 | } 78 | $forwarded_ip = $_SERVER["HTTP_{$config['PROXY_IP_HEADER']}"]; 79 | if (filter_var($forwarded_ip, FILTER_VALIDATE_IP) === false) { 80 | header("HTTP/1.1 500 Internal Server Error"); 81 | echo "The proxy has forwarded an invalid IP: '$forwarded_ip'.\n"; 82 | exit; 83 | } 84 | return $forwarded_ip; 85 | } 86 | 87 | protected function getTokenFromRequest(): ?string 88 | { 89 | if (isset($_GET['token'])) { 90 | $token = $_GET['token']; 91 | } else if (isset($_SERVER['PHP_AUTH_PW'])) { 92 | $token = $_SERVER['PHP_AUTH_PW']; 93 | } else { 94 | return null; 95 | } 96 | // only hex characters allowed in token 97 | return preg_replace("/[^0-9^a-f]/", "", $token); 98 | } 99 | 100 | public function process(): void 101 | { 102 | $records = []; 103 | foreach ($this->_requests as $request) { 104 | $request->autoSetMissingInput($this->_token, $this->_remote_ip); 105 | $request->validate($this->_token, $this->_response_writer, $this->_ispconfig); 106 | $records[] = $this->loadDnsRecord($request); 107 | } 108 | $this->updateDnsRecords($records); 109 | } 110 | 111 | protected function loadDnsRecord(DdnsRequest $request): array 112 | { 113 | // try to load zone 114 | $soa = $this->_ispconfig->db->queryOneRecord( 115 | "SELECT id,server_id,sys_userid,sys_groupid,origin,ttl,serial FROM dns_soa WHERE origin=?", 116 | $request->getZone() 117 | ); 118 | if ($soa == null || $soa['id'] == null) { 119 | $this->_response_writer->dnsNotFound("zone '{$request->getZone()}'"); 120 | exit; 121 | } 122 | 123 | // try to load record (for update/delete) 124 | $rr = null; 125 | $rrResult = $this->_ispconfig->db->query( 126 | "SELECT id,data,ttl,serial FROM dns_rr WHERE type=? AND name=? AND zone=?", 127 | $request->getRecordType(), 128 | $request->getRecord(), 129 | $soa['id'] 130 | ); 131 | if ($rrResult && $rrResult->rows() > 0) { 132 | if ($request->getAction() === 'update') { 133 | // because we do not work with IDs or 'oldData' (yet), there must be exactly one existing record for update operations 134 | if ($rrResult->rows() > 1) { 135 | $rrResult->free(); 136 | $this->_response_writer->internalError("Found more than one record to update, unable to proceed"); 137 | exit; 138 | } 139 | $rr = $rrResult->get(); 140 | } else if ($request->getData() !== null) { 141 | // for add / delete, check matching record by data 142 | while($record = $rrResult->get()) { 143 | if ($record['data'] === $request->getData()) { 144 | $rr = $record; 145 | break; 146 | } 147 | } 148 | } 149 | $rrResult->free(); 150 | } 151 | if ($request->getAction() !== 'add' && $rr === null) { 152 | $this->_response_writer->dnsNotFound( 153 | "record '{$request->getRecord()}' of type '{$request->getRecordType()}' in zone '{$request->getZone()}'" 154 | ); 155 | exit; 156 | } 157 | if ($request->getAction() === 'add' && $rr !== null) { 158 | $this->_response_writer->noUpdateRequired($request); 159 | exit; 160 | } 161 | 162 | return [ 163 | 'request' => $request, 164 | 'soa' => $soa, 165 | 'rr' => $rr 166 | ]; 167 | } 168 | 169 | protected function updateDnsRecords(array $records): void 170 | { 171 | $update_performed = false; 172 | $unique_soa = []; 173 | $longest_ttl = 0; 174 | // update DNS records 175 | foreach ($records as $record) { 176 | $request = $record['request']; 177 | $soa = $record['soa']; 178 | $rr = $record['rr']; 179 | if ($request->getAction() === 'delete') { 180 | // delete record 181 | if ($rr === null) { 182 | // cannot delete non-existing record 183 | continue; 184 | } 185 | $this->_ispconfig->db->datalogDelete('dns_rr', 'id', $rr['id']); 186 | $update_performed = true; 187 | if ($longest_ttl < (int)$rr['ttl']) { 188 | $longest_ttl = (int)$rr['ttl']; 189 | } 190 | } else if ($request->getAction() === 'update') { 191 | // update record 192 | if ($rr === null) { 193 | $this->_response_writer->internalError("Record is missing for action update, unable to proceed"); 194 | exit; 195 | } 196 | // check if update is required 197 | if ($rr['data'] === $request->getData()) { 198 | continue; 199 | } 200 | // Update the RR 201 | $rr_update = array( 202 | "data" => $request->getData(), 203 | "serial" => $this->_ispconfig->validate_dns->increase_serial($rr["serial"]), 204 | "stamp" => date('Y-m-d H:i:s') 205 | ); 206 | $this->_ispconfig->db->datalogUpdate('dns_rr', $rr_update, 'id', $rr['id']); 207 | $update_performed = true; 208 | if ($longest_ttl < (int)$rr['ttl']) { 209 | $longest_ttl = (int)$rr['ttl']; 210 | } 211 | } else if ($request->getAction() === 'add') { 212 | // check record with same data was already found 213 | if ($rr !== null && $rr['data'] === $request->getData()) { 214 | continue; 215 | } 216 | // create record 217 | // Get the limits of the client 218 | $client_group_id = intval($soa["sys_groupid"]); 219 | $client = $this->_ispconfig->db->queryOneRecord( 220 | "SELECT limit_dns_record FROM sys_group, client WHERE sys_group.client_id = client.client_id and sys_group.groupid = ?", 221 | $client_group_id 222 | ); 223 | // Check if the user may add another record. 224 | if($client["limit_dns_record"] >= 0) { 225 | $tmp = $this->_ispconfig->db->queryOneRecord( 226 | "SELECT count(id) as number FROM dns_rr WHERE sys_groupid = ?", 227 | $client_group_id 228 | ); 229 | if($tmp["number"] >= $client["limit_dns_record"]) { 230 | $this->_response_writer->forbidden("new record. dns record limit reached."); 231 | exit; 232 | } 233 | } 234 | $rr_insert = array( 235 | // "id" auto-generated 236 | "server_id" => $soa["server_id"], 237 | "sys_userid" => $soa["sys_userid"], 238 | "sys_groupid" => $soa["sys_groupid"], 239 | "sys_perm_user" => 'riud', 240 | "sys_perm_group" => 'riud', 241 | "zone" => $soa['id'], 242 | "type" => $request->getRecordType(), 243 | "ttl" => '3600', 244 | "name" => $request->getRecord(), 245 | "data" => $request->getData(), 246 | "serial" => $this->_ispconfig->validate_dns->increase_serial($rr["serial"]), 247 | "active" => 'Y', 248 | "stamp" => date('Y-m-d H:i:s') 249 | ); 250 | // insert the RR 251 | $this->_ispconfig->db->datalogInsert('dns_rr', $rr_insert, 'id'); 252 | $update_performed = true; 253 | if ($longest_ttl < (int)$soa['ttl']) { 254 | $longest_ttl = (int)$soa['ttl']; 255 | } 256 | } 257 | if (!array_key_exists($soa['id'], $unique_soa)) { 258 | $unique_soa[$soa['id']] = $soa; 259 | } 260 | } 261 | 262 | if (!$update_performed) { 263 | $this->_response_writer->noUpdateRequired($records[0]['request']); 264 | exit; 265 | } 266 | 267 | // Update the serial number of the affected SOA records 268 | foreach ($unique_soa as $soa) { 269 | //* Update the serial number of the SOA record 270 | $soa_update = array( 271 | "serial" => $this->_ispconfig->validate_dns->increase_serial($soa["serial"]) 272 | ); 273 | $this->_ispconfig->db->datalogUpdate('dns_soa', $soa_update, 'id', $soa['id']); 274 | 275 | // cron runs every full minute, calculate seconds left 276 | $cron_eta = 60 - date('s'); 277 | } 278 | $this->_response_writer->successfulUpdate($records[0]['request'], $longest_ttl, $cron_eta); 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /lib/updater/request/DdnsRequest.php: -------------------------------------------------------------------------------- 1 | _zone = $zone; 20 | } 21 | 22 | public function getZone(): ?string 23 | { 24 | return $this->_zone; 25 | } 26 | 27 | public function setRecord($record): void 28 | { 29 | $this->_record = $record; 30 | } 31 | 32 | public function getRecord(): ?string 33 | { 34 | return $this->_record; 35 | } 36 | 37 | public function setRecordType($record_type): void 38 | { 39 | $this->_record_type = $record_type; 40 | } 41 | 42 | public function getRecordType(): ?string 43 | { 44 | return $this->_record_type; 45 | } 46 | 47 | public function setData($data): void 48 | { 49 | $this->_data = $data; 50 | } 51 | 52 | public function getData(): ?string 53 | { 54 | return $this->_data; 55 | } 56 | 57 | public function setAction($action): void 58 | { 59 | // ignore invalid actions... 60 | if (in_array($action, array('add', 'delete', 'update'), true)) { 61 | $this->_action = $action; 62 | } 63 | } 64 | 65 | public function getAction(): ?string 66 | { 67 | return $this->_action; 68 | } 69 | 70 | abstract public function autoSetMissingInput(DdnsToken $token, string $remote_ip): void; 71 | 72 | // match zone and record from a hostname 73 | protected function match_from_hostname(string $hostname, DdnsToken $token): void 74 | { 75 | $hostname = rtrim($hostname, '.'); 76 | 77 | // match hostname with allowed dns zones 78 | $matching_zones = []; 79 | foreach ($token->getAllowedZones() as $allowed_zone) { 80 | if(strpos($hostname, rtrim($allowed_zone, '.')) !== false) { 81 | $matching_zones[] = $allowed_zone; 82 | } 83 | } 84 | if (empty($matching_zones)) { 85 | return; 86 | } else if (sizeof($matching_zones) === 1) { 87 | $this->setZone($matching_zones[0]); 88 | } else { 89 | $closest_match = ''; 90 | foreach ($matching_zones as $matching_zone) { 91 | if(sizeof($matching_zone) > sizeof($closest_match)) { 92 | $closest_match = $matching_zone; 93 | } 94 | } 95 | $this->setZone($closest_match); 96 | } 97 | 98 | // match records with allowed records 99 | $zone_length = strlen(rtrim($this->getZone(), '.')); 100 | if ($zone_length < strlen($hostname)) { 101 | $record = substr($hostname, 0, - $zone_length - 1); 102 | $this->setRecord($record); 103 | } else if (count($token->getLimitRecords()) == 0) { 104 | $this->setRecord(''); 105 | } 106 | } 107 | 108 | public function validate(DdnsToken $token, DdnsResponseWriter $response_writer, app $app): void 109 | { 110 | // check if requested zone is allowed (allowed_zones must be set) 111 | if ($this->getZone() !== null && !in_array($this->getZone(), $token->getAllowedZones(), true)) { 112 | $response_writer->forbidden("zone {$this->getZone()}"); 113 | exit; 114 | } 115 | 116 | // check if record restriction is set and requested zone is allowed 117 | if ($this->getRecord() !== null && count($token->getLimitRecords()) !== 0 && !in_array($this->getRecord(), $token->getLimitRecords(), true)) { 118 | $response_writer->forbidden("record {$this->getRecord()}"); 119 | exit; 120 | } 121 | 122 | // check if requested type is allowed (allowed_record_types must be set) 123 | if ($this->getRecordType() !== null && !in_array($this->getRecordType(), $token->getAllowedRecordTypes(), true)) { 124 | $response_writer->forbidden("record type {$this->getRecordType()}"); 125 | exit; 126 | } 127 | 128 | // check if all required data is available 129 | if ($this->getZone() === null || $this->getRecord() === null || $this->getRecordType() === null || $this->getData() === null) { 130 | $response_writer->missingInput($this); 131 | exit; 132 | } 133 | 134 | // validate data for given type 135 | if ($this->getRecordType() === 'A') { 136 | $ip = filter_var($this->getData(), FILTER_VALIDATE_IP, FILTER_FLAG_IPV4); 137 | if (!$ip) { 138 | $response_writer->invalidIpAddress($this->getData()); 139 | exit; 140 | } 141 | // write back filtered ip 142 | $this->setData($ip); 143 | } else if ($this->getRecordType() === 'AAAA') { 144 | $ip = filter_var($this->getData(), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); 145 | if (!$ip) { 146 | $response_writer->invalidIpAddress($this->getData()); 147 | exit; 148 | } 149 | // write back filtered ip 150 | $this->setData($ip); 151 | } else if ($this->getRecordType() === 'TXT') { 152 | // IDNTOASCII and TOLOWER transformations for record name 153 | $record = $app->functions->idn_encode($this->getRecord()); 154 | $record = strtolower($record); 155 | $this->setRecord($record); 156 | 157 | // validation for data 158 | if ($this->getData() === '') { 159 | $response_writer->missingInput($this); 160 | exit; 161 | } else if (strlen($this->getData()) > 255) { 162 | $response_writer->invalidData("maximum 255 characters"); 163 | exit; 164 | } 165 | 166 | if ($this->getAction() === 'update') { 167 | $response_writer->forbidden("TXT update"); 168 | } 169 | } else { 170 | $response_writer->forbidden('record type ' . $this->getRecordType()); 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /lib/updater/request/DefaultDdnsRequest.php: -------------------------------------------------------------------------------- 1 | setZone($zone); 12 | } 13 | $this->setRecord($_GET['record'] ?? $_POST['record']); 14 | $this->setRecordType($_GET['type'] ?? $_POST['type']); 15 | $this->setData($_GET['data'] ?? $_POST['data']); 16 | $this->setAction($_GET['action'] ?? $_POST['action']); 17 | } 18 | 19 | public function autoSetMissingInput(DdnsToken $token, string $remote_ip): void 20 | { 21 | // auto-set zone based on record if possible 22 | if ($this->getZone() === null && $this->getRecord() !== null && $this->getRecord() !== '') { 23 | parent::match_from_hostname($this->getRecord(), $token); 24 | } 25 | 26 | // auto-set zone if possible 27 | if ($this->getZone() === null && count($token->getAllowedZones()) === 1) { 28 | $this->setZone($token->getAllowedZones()[0]); 29 | } 30 | // auto-set record if possible 31 | if ($this->getRecord() === null && count($token->getLimitRecords()) === 1) { 32 | $this->setRecord($token->getLimitRecords()[0]); 33 | } else if ($this->getRecord() === null && count($token->getLimitRecords()) === 0) { 34 | $this->setRecord(''); 35 | } 36 | 37 | // auto-set type if possible 38 | if ($this->getRecordType() === null && count($token->getAllowedRecordTypes()) === 1) { 39 | $this->setRecordType($token->getAllowedRecordTypes()[0]); 40 | } 41 | 42 | // auto-set data if possible 43 | if ($this->getData() === null && ($this->getRecordType() === null || $this->getRecordType() === 'A' || $this->getRecordType() === 'AAAA')) { 44 | $this->setData($remote_ip); 45 | // auto-set type based on IP 46 | if ($this->getRecordType() === null && $this->getData() !== null && filter_var($this->getData(), FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { 47 | $this->setRecordType('A'); 48 | } else if ($this->getRecordType() === null && $this->getData() !== null && filter_var($this->getData(), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { 49 | $this->setRecordType('AAAA'); 50 | } 51 | } 52 | 53 | // auto-set action if provided 54 | if ($this->getAction() === null) { 55 | switch ($_SERVER['REQUEST_METHOD']) { 56 | case 'DELETE': 57 | $this->setAction('delete'); 58 | break; 59 | case 'POST': 60 | $this->setAction('add'); 61 | break; 62 | default: 63 | // GET, PUT, PATCH, ... 64 | $this->setAction('update'); 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /lib/updater/request/DynDnsRequest.php: -------------------------------------------------------------------------------- 1 | _hostname = $hostname; 12 | $this->setData($_GET['myip']); 13 | // action is always update (for DynDNS requests) 14 | $this->setAction('update'); 15 | // zone, record and type cannot be determined from http request only 16 | } 17 | 18 | public function autoSetMissingInput(DdnsToken $token, string $remote_ip): void 19 | { 20 | if ($this->_hostname !== null) { 21 | parent::match_from_hostname($this->_hostname, $token); 22 | } 23 | 24 | if ($this->getData() === null) { 25 | $this->setData($remote_ip); 26 | } 27 | 28 | // auto-set type 29 | if ($this->getData() !== null && filter_var($this->getData(), FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { 30 | $this->setRecordType('A'); 31 | } else if ($this->getData() !== null && filter_var($this->getData(), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { 32 | $this->setRecordType('AAAA'); 33 | } 34 | } 35 | 36 | public function validate(DdnsToken $token, DdnsResponseWriter $response_writer, app $app): void 37 | { 38 | if ($this->_hostname === null) { 39 | $response_writer->missingInput($this); 40 | exit; 41 | } 42 | // check if all required data is available 43 | if ($this->getZone() === null || $this->getRecord() === null) { 44 | $response_writer->dnsNotFound($this->_hostname); 45 | exit; 46 | } else if ($this->getRecordType() !== 'A' && $this->getRecordType() !== 'AAAA') { 47 | $response_writer->invalidIpAddress($this->getData()); 48 | } 49 | parent::validate($token, $response_writer, $app); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/updater/response/DdnsResponseWriter.php: -------------------------------------------------------------------------------- 1 | _ispconfig = $ispconfig; 12 | } 13 | 14 | public function invalidOrMissingToken(): void 15 | { 16 | header("HTTP/1.1 401 Unauthorized"); 17 | echo "Missing or invalid token.\n"; 18 | exit; 19 | } 20 | 21 | public function maintenance(): void 22 | { 23 | header("HTTP/1.1 500 Internal Server Error"); 24 | echo "This ISPConfig installation is currently under maintenance. We should be back shortly. Thank you for your patience.\n"; 25 | exit; 26 | } 27 | 28 | public function tooManyLoginAttempts(): void 29 | { 30 | header("HTTP/1.1 429 Too Many Requests"); 31 | echo $this->_ispconfig->lng('error_user_too_many_logins')."\n"; 32 | exit; 33 | } 34 | 35 | public function forbidden(string $entity): void 36 | { 37 | header("HTTP/1.1 403 Forbidden"); 38 | echo "Permission denied for $entity\n"; 39 | exit; 40 | } 41 | 42 | public function missingInput(DdnsRequest $request): void 43 | { 44 | header("HTTP/1.1 400 Bad Request"); 45 | echo "Missing input data, zone={$request->getZone()}, record={$request->getRecord()}, type={$request->getRecordType()}, data={$request->getData()}.\n"; 46 | exit; 47 | } 48 | 49 | public function invalidIpAddress(?string $ip): void 50 | { 51 | header("HTTP/1.1 400 Bad Request"); 52 | echo "Invalid IP address: $ip\n"; 53 | exit; 54 | } 55 | 56 | public function invalidData(string $reason): void 57 | { 58 | header("HTTP/1.1 400 Bad Request"); 59 | echo "Invalid Data: $reason\n"; 60 | exit; 61 | } 62 | 63 | public function dnsNotFound(string $dns): void 64 | { 65 | header("HTTP/1.1 404 Not Found"); 66 | echo "Could not find $dns\n"; 67 | exit; 68 | } 69 | 70 | public function internalError(string $message): void 71 | { 72 | header("HTTP/1.1 500 Internal Server Error"); 73 | echo "$message.\n"; 74 | exit; 75 | } 76 | 77 | public function noUpdateRequired(DdnsRequest $request): void 78 | { 79 | // return normal 200, no http error code 80 | if ($request->getAction() === 'delete') { 81 | echo "ERROR: {$request->getRecord()} does not exit.\n"; 82 | } else { 83 | echo "ERROR: {$request->getData()} is already set in {$request->getRecord()}.\n"; 84 | } 85 | exit; 86 | } 87 | 88 | public function successfulUpdate(DdnsRequest $request, int $record_ttl, int $cron_eta): void 89 | { 90 | if ($request->getAction() === 'delete') { 91 | echo "Scheduled delete of record {$request->getRecord()}. Schedule runs in $cron_eta seconds. Record TTL: $record_ttl.\n"; 92 | } else { 93 | echo "Scheduled update to '{$request->getData()}' of record {$request->getRecord()}. Schedule runs in $cron_eta seconds. Record TTL: $record_ttl.\n"; 94 | } 95 | exit; 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /lib/updater/response/DynDns1ResponseWriter.php: -------------------------------------------------------------------------------- 1 | _ispconfig = $ispconfig; 12 | } 13 | private function exit(): void { 14 | // current ddclient implementation does not handle chunked encoding, setting content-length fixes this 15 | // see: https://github.com/ddclient/ddclient/issues/499#issuecomment-1447465250 16 | header('Content-Length: ' . ob_get_length()); 17 | exit; 18 | } 19 | 20 | private function dynDns1Error(string $message) { 21 | echo "$message\n"; 22 | echo "return code: ERROR\n"; 23 | echo "error code: ERROR\n"; 24 | $this->exit(); 25 | } 26 | 27 | private function dynDns1Success(string $message) { 28 | echo "$message\n"; 29 | echo "return code: NOERROR\n"; 30 | echo "error code: NOERROR\n"; 31 | $this->exit(); 32 | } 33 | 34 | public function invalidOrMissingToken(): void 35 | { 36 | $this->dynDns1Error("Missing or invalid token"); 37 | } 38 | 39 | public function maintenance(): void 40 | { 41 | $this->dynDns1Error("This ISPConfig installation is currently under maintenance. We should be back shortly. Thank you for your patience."); 42 | } 43 | 44 | public function tooManyLoginAttempts(): void 45 | { 46 | $this->dynDns1Error($this->_ispconfig->lng('error_user_too_many_logins')); 47 | } 48 | 49 | public function forbidden(string $entity): void 50 | { 51 | $this->dynDns1Error("Permission denied for $entity"); 52 | } 53 | 54 | public function missingInput(DdnsRequest $request): void 55 | { 56 | $this->dynDns1Error("Missing input data, zone={$request->getZone()}, record={$request->getRecord()}, type={$request->getRecordType()}, data={$request->getData()}"); 57 | } 58 | 59 | public function invalidIpAddress(?string $ip): void 60 | { 61 | $this->dynDns1Error("Invalid IP address: $ip\n"); 62 | } 63 | 64 | public function invalidData(string $reason): void 65 | { 66 | $this->invalidIpAddress($reason); 67 | } 68 | 69 | public function dnsNotFound(string $dns): void 70 | { 71 | $this->dynDns1Error("Could not find $dns"); 72 | } 73 | 74 | public function internalError(string $message): void 75 | { 76 | $this->dynDns1Error($message); 77 | } 78 | 79 | public function noUpdateRequired(DdnsRequest $request): void 80 | { 81 | $this->dynDns1Success("{$request->getData()} is already set"); 82 | } 83 | 84 | public function successfulUpdate(DdnsRequest $request, int $record_ttl, int $cron_eta): void 85 | { 86 | $this->dynDns1Success("Scheduled update to {$request->getData()}. Schedule runs in $cron_eta seconds. Record TTL: $record_ttl"); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /lib/updater/response/DynDns2ResponseWriter.php: -------------------------------------------------------------------------------- 1 | exit(); 17 | } 18 | 19 | public function maintenance(): void 20 | { 21 | echo "maintenance"; // not a documented dyndns2 return value 22 | $this->exit(); 23 | } 24 | 25 | public function tooManyLoginAttempts(): void 26 | { 27 | echo "abuse"; 28 | $this->exit(); 29 | } 30 | 31 | public function forbidden(string $entity): void 32 | { 33 | echo "!yours"; 34 | $this->exit(); 35 | } 36 | 37 | public function missingInput(DdnsRequest $request): void 38 | { 39 | echo "notfqdn"; 40 | $this->exit(); 41 | } 42 | 43 | public function invalidIpAddress(?string $ip): void 44 | { 45 | echo "notip"; // not a documented dyndns2 return value 46 | $this->exit(); 47 | } 48 | 49 | public function invalidData(string $reason): void 50 | { 51 | $this->invalidIpAddress($reason); 52 | } 53 | 54 | public function dnsNotFound(string $dns): void 55 | { 56 | echo "nohost"; 57 | $this->exit(); 58 | } 59 | 60 | public function internalError(string $message): void 61 | { 62 | echo "dnserr"; 63 | $this->exit(); 64 | } 65 | 66 | public function noUpdateRequired(DdnsRequest $request): void 67 | { 68 | echo "nochg"; 69 | $this->exit(); 70 | } 71 | 72 | public function successfulUpdate(DdnsRequest $request, int $record_ttl, int $cron_eta): void 73 | { 74 | echo "good {$request->getData()}"; 75 | $this->exit(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /lib/updater/token/DdnsToken.php: -------------------------------------------------------------------------------- 1 | _ispconfig = $ispconfig; 17 | if ($requestToken == null) { 18 | $response_writer->invalidOrMissingToken(); 19 | exit; 20 | } 21 | 22 | //* Check if there are already wrong logins 23 | $request_ip = md5($remote_ip); 24 | $sql = "SELECT * FROM `attempts_login` WHERE `ip`= ? AND `login_time` > (NOW() - INTERVAL 1 MINUTE) LIMIT 1"; 25 | $alreadyfailed = $this->_ispconfig->db->queryOneRecord($sql, $request_ip); 26 | if ($alreadyfailed['times'] > 5) { 27 | $response_writer->tooManyLoginAttempts(); 28 | exit; 29 | } 30 | 31 | $token = $this->_ispconfig->db->queryOneRecord("SELECT * FROM ddns_token WHERE active = 'Y' AND token=?", $requestToken); 32 | if ($token == null) { 33 | if (!$alreadyfailed['times']) { 34 | //* user login the first time wrong 35 | $sql = "INSERT INTO `attempts_login` (`ip`, `times`, `login_time`) VALUES (?, 1, NOW())"; 36 | $this->_ispconfig->db->query($sql, $request_ip); 37 | } elseif ($alreadyfailed['times'] >= 1) { 38 | //* update times wrong 39 | $sql = "UPDATE `attempts_login` SET `times`=`times`+1, `login_time`=NOW() WHERE `ip` = ? AND `login_time` < NOW() ORDER BY `login_time` DESC LIMIT 1"; 40 | $this->_ispconfig->db->query($sql, $request_ip); 41 | } 42 | 43 | $response_writer->invalidOrMissingToken(); 44 | exit; 45 | } else { 46 | // User login right, so attempts can be deleted 47 | $sql = "DELETE FROM `attempts_login` WHERE `ip`=?"; 48 | $this->_ispconfig->db->query($sql, $request_ip); 49 | // create fake user session for token owner 50 | $group_id = intval($token['sys_groupid']); 51 | $user_id = intval($token['sys_userid']); 52 | if ($group_id !== 0) { 53 | // groupid is changeable in UI (by admins), try it first 54 | $sql = "SELECT * FROM sys_group WHERE groupid = ?"; 55 | $group = $this->_ispconfig->db->queryOneRecord($sql, $group_id); 56 | $client_id = intval($group['client_id']); 57 | $sql = "SELECT * FROM sys_user WHERE client_id = ?"; 58 | $user = $this->_ispconfig->db->queryOneRecord($sql, $client_id); 59 | $this->create_user_session($user); 60 | } else if ($user_id !== 0) { 61 | $sql = "SELECT * FROM sys_user WHERE userid = ?"; 62 | $user = $this->_ispconfig->db->queryOneRecord($sql, $user_id); 63 | $this->create_user_session($user); 64 | } 65 | } 66 | $this->_allowed_zones = array_filter(explode(',', $token['allowed_zones'])); 67 | $this->_allowed_record_types = array_filter(explode(',', $token['allowed_record_types'])); 68 | $this->_limit_records = array_filter(explode(',', $token['limit_records'])); 69 | } 70 | 71 | private function create_user_session(array $user) 72 | { 73 | $user = $this->_ispconfig->db->toLower($user); 74 | // session_start() should never be called, 75 | // but make sure no session cookie is created anyway to prevent possible account takeover 76 | ini_set('session.use_cookies', '0'); 77 | $_SESSION = array(); 78 | $_SESSION['s']['user'] = $user; 79 | $_SESSION['s']['language'] = $this->_ispconfig->functions->check_language($user['language']); 80 | } 81 | 82 | public function getAllowedZones(): array 83 | { 84 | return $this->_allowed_zones; 85 | } 86 | 87 | 88 | public function getAllowedRecordTypes(): array 89 | { 90 | return $this->_allowed_record_types; 91 | } 92 | 93 | public function getLimitRecords(): array 94 | { 95 | return $this->_limit_records; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /list/ddns_token.list.php: -------------------------------------------------------------------------------- 1 | "active", 39 | 'datatype' => "VARCHAR", 40 | 'formtype' => "SELECT", 41 | 'op' => "=", 42 | 'prefix' => "", 43 | 'suffix' => "", 44 | 'width' => "", 45 | 'value' => array('Y' => $app->lng('yes_txt'), 'N' => $app->lng('no_txt')) 46 | ); 47 | if($_SESSION['s']['user']['typ'] == 'admin') { 48 | $liste["item"][] = array( 49 | 'field' => "sys_groupid", 50 | 'datatype' => "INTEGER", 51 | 'formtype' => "SELECT", 52 | 'op' => "=", 53 | 'prefix' => "", 54 | 'suffix' => "", 55 | 'datasource' => array ( 'type' => 'SQL', 56 | 'querystring' => "SELECT sys_group.groupid,CONCAT(IF(client.company_name != '', CONCAT(client.company_name, ' :: '), ''), IF(client.contact_firstname != '', CONCAT(client.contact_firstname, ' '), ''), client.contact_name, ' (', client.username, IF(client.customer_no != '', CONCAT(', ', client.customer_no), ''), ')') as name FROM sys_group, client WHERE sys_group.groupid != 1 AND sys_group.client_id = client.client_id ORDER BY client.company_name, client.contact_name", 57 | 'keyfield'=> 'groupid', 58 | 'valuefield'=> 'name' 59 | ), 60 | 'width' => "", 61 | 'value' => "" 62 | ); 63 | } 64 | 65 | $liste["item"][] = array( 66 | 'field' => "token", 67 | 'datatype' => "VARCHAR", 68 | 'filters' => array( 69 | 0 => array('event' => 'SHOW', 'type' => 'IDNTOUTF8') 70 | ), 71 | 'formtype' => "TEXT", 72 | 'op' => "like", 73 | 'prefix' => "%", 74 | 'suffix' => "%", 75 | 'width' => "", 76 | 'value' => "" 77 | ); 78 | 79 | $liste['item'][] = array( 80 | 'field' => 'allowed_zones', 81 | 'datatype' => "VARCHAR", 82 | 'filters' => array( 83 | 0 => array('event' => 'SHOW', 'type' => 'IDNTOUTF8') 84 | ), 85 | 'formtype' => "TEXT", 86 | 'op' => "like", 87 | 'prefix' => "%", 88 | 'suffix' => "%", 89 | 'width' => "", 90 | 'value' => "" 91 | ); 92 | 93 | $liste["item"][] = array( 94 | 'field' => "allowed_record_types", 95 | 'datatype' => "VARCHAR", 96 | 'formtype' => "TEXT", 97 | 'op' => "like", 98 | 'prefix' => "%", 99 | 'suffix' => "%", 100 | 'width' => "", 101 | 'value' => "" 102 | ); 103 | 104 | $liste["item"][] = array( 105 | 'field' => "limit_records", 106 | 'datatype' => "VARCHAR", 107 | 'filters' => array( 108 | 0 => array('event' => 'SHOW', 'type' => 'IDNTOUTF8') 109 | ), 110 | 'formtype' => "TEXT", 111 | 'op' => "like", 112 | 'prefix' => "%", 113 | 'suffix' => "%", 114 | 'width' => "", 115 | 'value' => "" 116 | ); 117 | 118 | ?> 119 | -------------------------------------------------------------------------------- /migration_1.4.0.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE `ddns_token` 3 | ADD COLUMN `server_id` int(11) unsigned NOT NULL DEFAULT 0 AFTER id; 4 | 5 | -- following updates fix issues in multi-server setup #13 6 | select @server_id := server_id from server 7 | where active=1 and dns_server=1 8 | order by server_id limit 1; 9 | update ddns_token set server_id = @server_id 10 | where server_id = 0; 11 | update sys_datalog set server_id = @server_id 12 | where server_id = 0 and dbtable = 'ddns_token'; 13 | -------------------------------------------------------------------------------- /nic/.htaccess: -------------------------------------------------------------------------------- 1 | # rewrite DynDNS endpoints: https://help.dyn.com/remote-access-api/perform-update/ 2 | RewriteEngine On 3 | 4 | # DynDNS v1 (legacy) 5 | RewriteRule ^dyndns$ /ddns/update.php [L] 6 | RewriteRule ^statdns /ddns/update.php [L] 7 | 8 | # DynDNS v2 9 | RewriteRule ^update$ /ddns/update.php [L] 10 | -------------------------------------------------------------------------------- /setup.sql: -------------------------------------------------------------------------------- 1 | -- DROP TABLE IF EXISTS `ddns_token`; 2 | CREATE TABLE `ddns_token` ( 3 | `id` int(10) unsigned NOT NULL AUTO_INCREMENT, 4 | `server_id` int(11) unsigned NOT NULL DEFAULT 0, 5 | `sys_userid` int(11) unsigned NOT NULL DEFAULT 0, 6 | `sys_groupid` int(11) unsigned NOT NULL DEFAULT 0, 7 | `sys_perm_user` varchar(5) NOT NULL DEFAULT '', 8 | `sys_perm_group` varchar(5) NOT NULL DEFAULT '', 9 | `sys_perm_other` varchar(5) NOT NULL DEFAULT '', 10 | `token` varchar(48) NOT NULL DEFAULT '', 11 | `allowed_zones` varchar(500) DEFAULT NULL, 12 | `allowed_record_types` varchar(255) NOT NULL DEFAULT '', 13 | `limit_records` varchar(255) DEFAULT NULL, 14 | `active` enum('N','Y') NOT NULL DEFAULT 'N', 15 | PRIMARY KEY (`id`), 16 | UNIQUE KEY `token` (`token`), 17 | KEY `active` (`active`) 18 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 19 | -------------------------------------------------------------------------------- /templates/ddns_token_edit.htm: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | 6 |
7 | 8 |
9 | 12 |
13 |
14 |
15 | 16 |
17 | 18 |
19 | 22 |
23 |
24 |
25 |
26 | 27 |
28 | 29 | 30 | 31 | 32 | 33 | 34 |
35 |
36 |
37 | 38 |
39 | 42 |
43 |
44 |
45 | 46 |
47 | {tmpl_var name='allowed_record_types'} 48 |
49 |
50 |
51 | 52 |
53 | 54 | {tmpl_var name='limit_records_hint_txt'} 55 |
56 |
57 |
58 | 59 |
60 | {tmpl_var name='active'} 61 |
62 |
63 |
64 | 65 | 66 | 67 |
68 | 69 | 70 |
71 |
72 | 73 |
74 | -------------------------------------------------------------------------------- /templates/ddns_token_list.htm: -------------------------------------------------------------------------------- 1 | 4 |

5 | 6 | 7 |
8 |
9 |
10 |
11 | {tmpl_var name="datalog_changes_txt"} 12 |
    13 | 14 |
  • {tmpl_var name="text"}: {tmpl_var name="count"}
  • 15 |
    16 |
17 | {tmpl_var name="datalog_changes_end_txt"} 18 |
19 |

20 |
21 |
22 | 23 | 24 |

{tmpl_var name="toolsarea_head_txt"}

25 | 26 | 27 | 28 | 29 |

30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 |
{tmpl_var name='search_limit'}
54 | 55 |
{tmpl_var name="active"}{tmpl_var name="sys_groupid"}{tmpl_var name="token"}{tmpl_var name="allowed_zones"}{tmpl_var name="allowed_record_types"}{tmpl_var name="limit_records"} 70 | 71 | 72 |
{tmpl_var name='globalsearch_noresults_text_txt'}{tmpl_var name='globalsearch_noresults_text_txt'}
97 |
98 | 257 | 351 | -------------------------------------------------------------------------------- /update.config.local-example.php: -------------------------------------------------------------------------------- 1 | 'ddns.company.com', 5 | // generate a long, unique key. must be set when behind reverse proxy 6 | 'TRUSTED_PROXY_KEY' => 'a-very-long-and-random-key', 7 | 'TRUSTED_PROXY_IP' => '127.0.0.1' 8 | ); 9 | -------------------------------------------------------------------------------- /update.config.php: -------------------------------------------------------------------------------- 1 | '', 4 | 'TRUSTED_PROXY_KEY' => '', 5 | 'TRUSTED_PROXY_IP' => '127.0.0.1', 6 | 'PROXY_KEY_HEADER' => 'X_DDNS_PROXY_KEY', 7 | 'PROXY_IP_HEADER' => 'X_FORWARDED_FOR' 8 | ); 9 | -------------------------------------------------------------------------------- /update.php: -------------------------------------------------------------------------------- 1 | process(); 17 | --------------------------------------------------------------------------------