array( // RSAMD5 should never be used, though it could be valid for dnssec-keygen. // RSASHA1 should never be used, thought it could be valid. 'NSEC3RSASHA1' => array( 'min' => 512, 'max' => 2048, ), 'NSEC3DSA' => array( 'min' => 512, 'max' => 1024, 'other' => '%64', // Exact multiple of 64. ), 'RSASHA256' => array( 'min' => 512, 'max' => 2048, ), 'RSASHA512' => array( 'min' => 512, 'max' => 2048, ), 'ECDSAP256SHA256' => array( // Keysize parameter is ignored. 'min' => 0, 'max' => 10000, ), 'ECDSAP384SHA384' => array( // Keysize parameter is ignored. 'min' => 0, 'max' => 10000, ), ), 'TSIG/TKEY' => array( // No supported algorithms presently. ), ); /** * Return the part of the conf we got from the database * * @global m_mysql $db * @param string $domain * @return array $this->cache_conf_db */ function conf_from_db($domain=false) { global $db; // Use cache, fill cache if empty if (empty($this->cache_conf_db)) { $db->query(" select sd.domaine, replace(replace(dt.entry,'%TARGET%',sd.valeur), '%SUB%', if(length(sd.sub)>0,sd.sub,'@')) as entry from sub_domaines sd, domaines_type dt where sd.type=dt.name and sd.enable in ('ENABLE', 'ENABLED') order by entry ;"); $t=array(); while ($db->next_record()) { $t[$db->f('domaine')][] = $db->f('entry'); } $this->cache_conf_db = $t; } if ($domain) { if (isset($this->cache_conf_db[$domain])) { return $this->cache_conf_db[$domain]; } else { return array(); } } // if domain return $this->cache_conf_db; } /** * Return full path of the zone configuration file * * @param string $domain * * @param boolean $signed * Whether to return the signed or unsigned zone file path. Ignored * if force is FALSE. * * @param boolean $force * If FALSE, the zone file path returned will be in accordance with the * configuration for the domain in the database. If forced, the domain * configuratoin will be ignored and the path returned according to the * signed parameter. * * @return string */ function get_zone_file_uri($domain, $signed = FALSE, $force = FALSE) { if (!$force) { $d = $this->get_domain_summary($domain); $signed = $d['dnssec']; } if (!$signed) { return $this->zone_file_directory.$domain; } else { return "{$this->zone_file_directory}{$domain}.signed"; } } /** * * @param string $domain * @return string zone file path */ function get_zone_file($domain) { // Use cache, fill cache if empty if (!isset($this->cache_zone_file[$domain]) ) { if (file_exists($this->get_zone_file_uri($domain))) { $this->cache_zone_file[$domain] = @file_get_contents($this->get_zone_file_uri($domain)); } else { $this->cache_zone_file[$domain] = false; } } return $this->cache_zone_file[$domain] ; } /** * * @param string $domain * @return string */ function get_serial($domain) { // Return the next serial the domain must have. // Choose between a generated and an incremented. // Calculated : $calc = date('Ymd').'00'."\n"; // Old one : $old=$calc; // default value $file = $this->get_zone_file($domain); preg_match_all("/\s*(\d{10})\s+\;\sserial\s?/", $file, $output_array); if (isset($output_array[1][0]) && !empty($output_array[1][0])) { $old = $output_array[1][0]; } // Return max between newly calculated, and old one incremented return max(array($calc,$old)) + 1 ; } /** * Return lines that are after ;;; END ALTERNC AUTOGENERATE CONFIGURATION * * @param string $domain * @return string */ function get_persistent($domain) { if ( ! isset($this->cache_get_persistent[$domain] )) { preg_match_all('/\;\s*END\sALTERNC\sAUTOGENERATE\sCONFIGURATION(.*)/s', $this->get_zone_file($domain), $output_array); if (isset($output_array[1][0]) && !empty($output_array[1][0])) { $this->cache_get_persistent[$domain] = $output_array[1][0]; } else { $this->cache_get_persistent[$domain] = false; } } // isset return $this->cache_get_persistent[$domain]; } /** * * @return string */ function get_zone_header() { return file_get_contents($this->ZONE_TEMPLATE); } /** * * @global m_dom $dom * @param string $domain * @return array Retourne un tableau */ function get_domain_summary($domain=false) { global $dom; // Use cache if is filled, if not, fill it if (empty($this->cache_domain_summary)) { $this->cache_domain_summary = $dom->get_domain_all_summary(); } if ($domain) return $this->cache_domain_summary[$domain]; else return $this->cache_domain_summary; } /** * * @param string $domain * @return boolean */ function dkim_delete($domain) { $target_dir = "/etc/opendkim/keys/$domain"; if (file_exists($target_dir)) { @unlink("$target_dir/alternc_private"); @unlink("$target_dir/alternc.txt"); @rmdir($target_dir); } return true; } /** * Generate the domain DKIM key * * @param string $domain * @return null|boolean */ function dkim_generate_key($domain) { // Stop here if we do not manage the mail $domainInfo = $this->get_domain_summary($domain); if ( ! $domainInfo['gesmx'] ) return; $target_dir = "/etc/opendkim/keys/$domain"; if (file_exists($target_dir.'/alternc.txt')) return; // Do not generate if exist if (! is_dir($target_dir)) mkdir($target_dir); // create dir // Generate the key $old_dir=getcwd(); chdir($target_dir); exec('opendkim-genkey -r -d '.escapeshellarg($domain).' -s "alternc" '); chdir($old_dir); // opendkim must be owner of the key chown("$target_dir/alternc.private", 'opendkim'); chgrp("$target_dir/alternc.private", 'opendkim'); return true; // FIXME handle error } /** * Refresh DKIM configuration: be sure to list the domain having a private key (and only them) */ function dkim_refresh_list() { // so ugly... but there is only 1 pass, not 3. Still ugly. $trusted_host_new = "# WARNING: this file is auto generated by AlternC.\n# Add your changes after the last line\n"; $keytable_new = "# WARNING: this file is auto generated by AlternC.\n# Add your changes after the last line\n"; $signingtable_new = "# WARNING: this file is auto generated by AlternC.\n# Add your changes after the last line\n"; # Generate automatic entry foreach ($this->get_domain_summary() as $domain => $ds ) { // Skip if delete in progress, or if we do not manage dns or mail if ( ! $ds['gesdns'] || ! $ds['gesmx'] || strtoupper($ds['dns_action']) == 'DELETE' ) continue; // Skip if there is no key generated if (! file_exists("/etc/opendkim/keys/$domain/alternc.txt")) continue; // Modif the files. $trusted_host_new.="$domain\n"; $keytable_new .="alternc._domainkey.$domain $domain:alternc:/etc/opendkim/keys/$domain/alternc.private\n"; $signingtable_new.="$domain alternc._domainkey.$domain\n"; } $trusted_host_new.="# END AUTOMATIC FILE. ADD YOUR CHANGES AFTER THIS LINE\n"; $keytable_new .="# END AUTOMATIC FILE. ADD YOUR CHANGES AFTER THIS LINE\n"; $signingtable_new.="# END AUTOMATIC FILE. ADD YOUR CHANGES AFTER THIS LINE\n"; # Get old files $trusted_host_old=@file_get_contents($this->dkim_trusted_host_file); $keytable_old =@file_get_contents($this->dkim_keytable_file); $signingtable_old=@file_get_contents($this->dkim_signingtable_file); # Keep manuel entry preg_match_all('/\#\s*END\ AUTOMATIC\ FILE\.\ ADD\ YOUR\ CHANGES\ AFTER\ THIS\ LINE(.*)/s', $trusted_host_old, $output_array); if (isset($output_array[1][0]) && !empty($output_array[1][0])) { $trusted_host_new.=$output_array[1][0]; } preg_match_all('/\#\s*END\ AUTOMATIC\ FILE\.\ ADD\ YOUR\ CHANGES\ AFTER\ THIS\ LINE(.*)/s', $keytable_old, $output_array); if (isset($output_array[1][0]) && !empty($output_array[1][0])) { $keytable_new.=$output_array[1][0]; } preg_match_all('/\#\s*END\ AUTOMATIC\ FILE\.\ ADD\ YOUR\ CHANGES\ AFTER\ THIS\ LINE(.*)/s', $signingtable_old, $output_array); if (isset($output_array[1][0]) && !empty($output_array[1][0])) { $signingtable_new.=$output_array[1][0]; } // Save if there are some diff if ( $trusted_host_new != $trusted_host_old ) { file_put_contents($this->dkim_trusted_host_file, $trusted_host_new); } if ( $keytable_new != $keytable_old ) { file_put_contents($this->dkim_keytable_file, $keytable_new); } if ( $signingtable_new != $signingtable_old ) { file_put_contents($this->dkim_signingtable_file, $signingtable_new); } } /** * Outputs the include statements for a zone's DnsSec keys if enabled. * * @param string $domain * @returns string * Include statements for all the keys found or an empty ztring. */ function dnssec_entry($domain) { $includes = ''; $key_directory = system_bind::$DNSSEC_KEY_BASEDIR . "/{$domain}"; $dnssec_enabled = $this->dnssec_is_enabled($domain); if ($dnssec_enabled && is_dir($key_directory)) { foreach (glob("{$key_directory}/K{$domain}*.key") as $f) { if (!is_file($f)) { continue; } $includes .= "\$INCLUDE {$f}\n"; } } return $includes; } /** * Checks if dnssec is enabled for a domain. * * @param $domain * A string for which domain to check. * * @returns boolean * True/False if DnsSec is enabled for the domain. */ function dnssec_is_enabled(String $domain) { $d = $this->get_domain_summary($domain); $enabled = false; if ($d && $d['dnssec']) { $enabled = true; } return $enabled; } /** * Signs a zone. */ function dnssec_sign_zone(String $domain) { global $msg; if (!$domain) { return; } // Keys should already exist. $key_dir = system_bind::$DNSSEC_KEY_BASEDIR . "/{$domain}"; $setfile_dir = system_bind::$DNSSEC_SETFILE_BASEDIR . "/{$domain}"; if (!is_dir($setfile_dir)) { mkdir($setfile_dir, '0750', TRUE); } $return_code = -1; $output = array(); $salt = _salt16hex(); if (!$salt) { $salt = '-'; } // The unsigned_zone_file should be an absolute path to the zone file. // The $INCLUDE statements in the the zone file _MUST_ use absolute // paths, otherwise, the CWD of the dnssec-signzone command must be // changed. $unsigned_zone_file = $this->get_zone_file_uri($domain, FALSE, TRUE); // @TODO Should '-Q' and '-R' be used to allow for key roll overs? $command = sprintf('/usr/sbin/dnssec-signzone -u -d %s -K %s -3 %s -A -N INCREMENT -o %s %s', escapeshellarg($setfile_dir), escapeshellarg($key_dir), escapeshellarg($salt), escapeshellarg($domain), escapeshellarg($unsigned_zone_file)); $last_line = exec(escapeshellcmd($command), $output, $return_code); $msg->log('system_bind', 'dnssec_sign_zone', sprintf('Signed zone %s using command "%s". Return code %d; Last line of output: %s', $domain, $command, $return_code, $last_line) ); return $return_code; } /** * * @param string $domain * @return string */ function dkim_entry($domain) { $keyfile="/etc/opendkim/keys/$domain/alternc.txt"; $domainInfo = $this->get_domain_summary($domain); if (! file_exists($keyfile) && $domainInfo['gesmx'] ) { $this->dkim_generate_key($domain); } return @file_get_contents($keyfile); } /** * Conditionnal generation autoconfig entry for outlook / thunderbird * If entry with the same name allready exist, skip it. * * @param string $domain * @return string */ function mail_autoconfig_entry($domain) { $zone= implode("\n",$this->conf_from_db($domain))."\n".$this->get_persistent($domain); $entry=''; $domainInfo = $this->get_domain_summary($domain); if ( $domainInfo['gesmx'] ) { // If we manage the mail // Check if there is no the same entry (defined or manual) // can be toto IN A or toto.fqdn.tld. IN A if (! preg_match("/autoconfig(\s|\.".str_replace('.','\.',$domain)."\.)/", $zone )) { $entry.="autoconfig IN CNAME %%fqdn%%.\n"; } if (! preg_match("/autodiscover(\s|\.".str_replace('.','\.',$domain)."\.)/", $zone )) { $entry.="autodiscover IN CNAME %%fqdn%%.\n"; } } // if gesmx return $entry; } /** * * Return a fully generated zone * * @global string $L_FQDN * @global string $L_NS1_HOSTNAME * @global string $L_NS2_HOSTNAME * @global string $L_DEFAULT_MX * @global string $L_DEFAULT_SECONDARY_MX * @global string $L_PUBLIC_IP * @param string $domain * @return string */ function get_zone($domain) { global $L_FQDN, $L_NS1_HOSTNAME, $L_NS2_HOSTNAME, $L_DEFAULT_MX, $L_DEFAULT_SECONDARY_MX, $L_PUBLIC_IP; $zone =$this->get_zone_header(); $zone.=implode("\n",$this->conf_from_db($domain)); $zone.="\n;;;HOOKED ENTRY\n"; $zone.= $this->dkim_entry($domain); $zone.= $this->mail_autoconfig_entry($domain); $zone .= $this->dnssec_entry($domain); $zone.="\n;;; END ALTERNC AUTOGENERATE CONFIGURATION\n"; $zone.=$this->get_persistent($domain); $domainInfo = $this->get_domain_summary($domain); // FIXME check those vars $zone = strtr($zone, array( "%%fqdn%%"=>"$L_FQDN", "%%ns1%%"=>"$L_NS1_HOSTNAME", "%%ns2%%"=>"$L_NS2_HOSTNAME", "%%DEFAULT_MX%%"=>"$L_DEFAULT_MX", "%%DEFAULT_SECONDARY_MX%%"=>"$L_DEFAULT_SECONDARY_MX", "@@fqdn@@"=>"$L_FQDN", "@@ns1@@"=>"$L_NS1_HOSTNAME", "@@ns2@@"=>"$L_NS2_HOSTNAME", "@@DEFAULT_MX@@"=>"$L_DEFAULT_MX", "@@DEFAULT_SECONDARY_MX@@"=>"$L_DEFAULT_SECONDARY_MX", "@@DOMAINE@@"=>"$domain", "@@SERIAL@@"=>$this->get_serial($domain), "@@PUBLIC_IP@@"=>"$L_PUBLIC_IP", "@@ZONETTL@@"=> $domainInfo['zonettl'], )); return $zone; } /** * * @param string $domain */ function reload_zone($domain) { exec($this->RNDC." reload ".escapeshellarg($domain), $output, $return_value); if ($return_value != 0 ) { echo "ERROR: Reload zone failed for zone $domain\n"; } } /** * return true if zone is locked * * @param string $domain * @return boolean */ function is_locked($domain) { preg_match_all("/(\;\s*LOCKED:YES)/i", $this->get_zone_file($domain), $output_array); if (isset($output_array[1][0]) && !empty($output_array[1][0])) { return true; } return false; } /** * * @global m_mysql $db * @global m_dom $dom * @param string $domain * @return boolean */ function save_zone($domain) { global $db, $dom; // Do not save if the zone is LOCKED if ( $this->is_locked($domain)) { $dom->set_dns_result($domain, "The zone file of this domain is locked. Contact your administrator."); // If edit, change dummy_for_translation $dom->set_dns_action($domain, 'OK'); return false; } // Save file, and apply chmod/chown // Always want to save the unsigned version. $file=$this->get_zone_file_uri($domain, FALSE, TRUE); file_put_contents($file, $this->get_zone($domain)); chown($file, 'bind'); chmod($file, 0640); $d = $this->get_domain_summary($domain); if ($d['dnssec']) { $this->dnssec_sign_zone($domain); } // @TODO: What should happen if zone signing fails? $dom->set_dns_action($domain, 'OK'); return true; // fixme add tests } /** * Delete the zone configuration file * * @param string $domain * @return boolean */ function delete_zone($domain) { // get_zone_file_uri() is used since it's result won't be cached. $files = array( $this->get_zone_file_uri($domain, FALSE, TRUE), $this->get_zone_file_uri($domain, TRUE, TRUE), ); foreach ($files as $file) { if (file_exists($file)) { unlink($file); } } $dnssec_directories = array( system_bind::$DNSSEC_KEY_BASEDIR . "/{$domain}", system_bind::$DNSSEC_SETFILE_BASEDIR . "/{$domain}", ); foreach ($dnssec_directories as $dir) { if ($domain && is_dir($dir)) { rmdir($dir); } } $this->dkim_delete($domain); return true; } /** * * @global m_hooks $hooks * @return boolean */ function reload_named() { global $hooks; // Generate the new conf file $new_named_conf="// DO NOT EDIT\n// This file is generated by Alternc.\n// Every changes you'll make will be overwrited.\n"; $tpl=file_get_contents($this->NAMED_TEMPLATE); foreach ($this->get_domain_summary() as $domain => $ds ) { if ( ! $ds['gesdns'] || strtoupper($ds['dns_action']) == 'DELETE' ) continue; $new_named_conf.=strtr($tpl, array("@@DOMAINE@@"=>$domain, "@@ZONE_FILE@@"=>$this->get_zone_file_uri($domain))); } // Get the actual conf file $old_named_conf = @file_get_contents($this->NAMED_CONF); // Apply new configuration only if there are some differences if ($old_named_conf != $new_named_conf ) { file_put_contents($this->NAMED_CONF,$new_named_conf); chown($this->NAMED_CONF, 'bind'); chmod($this->NAMED_CONF, 0640); exec($this->RNDC." reconfig"); $hooks->invoke_scripts("/usr/lib/alternc/reload.d", array('dns_reconfig') ); } return true; } /** * Regenerate bind configuration and load it * * @global m_hooks $hooks * @param boolean $all * @return boolean */ function regenerate_conf($all=false) { global $hooks; foreach ($this->get_domain_summary() as $domain => $ds ) { if ( ! $ds['gesdns'] && strtoupper($ds['dns_action']) == 'OK' ) continue; // Skip if we do not manage DNS and is up-to-date for this domain if ( (strtoupper($ds['dns_action']) == 'DELETE' ) || (strtoupper($ds['dns_action']) == 'UPDATE' && $ds['gesdns']==false ) // in case we update the zone to disable DNS management ) { $this->delete_zone($domain); continue; } if ( ( $all || strtoupper($ds['dns_action']) == 'UPDATE' ) && $ds['gesdns'] ) { $this->save_zone($domain); $this->reload_zone($domain); $hooks->invoke_scripts("/usr/lib/alternc/reload.d", array('dns_reload_zone', $domain) ); } } // end foreach domain $this->dkim_refresh_list(); $this->reload_named(); return true; } /** * */ private function dummy_for_translation() { _("The zone file of this domain is locked. Contact your administrator."); } /** * Returns the current default configuration for dnssec. * * @returqns array * An array with two indexes 'ksk' (keysigning key), 'zsk' (zonesigning key). * Each key type is also an array with the properities 'algorithm', and 'size'. * Algorithm should match the named parameters for dnssec-keygen, and size should * be an integer. */ public static function default_dnssec_configuration() { $data = array( 'ksk' => array( 'algorithm' => variable_get('ksk_algorithm', 'RSASHA512', ''), 'keysize' => variable_get('ksk_keysize', 2048, ''), ), 'zsk' => array( 'algorithm' => variable_get('zsk_algorithm', 'RSASHA512', ''), 'keysize' => variable_get('zsk_keysize', 2048, ''), ), ); return $data; } /** * Returns the DS entries for a domain. * * @returns string * The output of dnssec-dsfromkey. */ public static function dnssec_ds_entries($domain) { return shell_exec("/usr/sbin/dnssec-dsfromkey -A -f {$this->zone_file_directory}/{$domain}"); } /** * Creates new key for a domain. * * @param $domain string * The domain to create the key for. * * @param $key_type string * One of 'ksk' or 'zsk' for key-sigining keys or zone-sigining keys. * * @param $algorithm string * The name of algorithm as recognized by dnssec-genkey. * * @param $length int * The length of the key in bytes. Note: for elliptic curve keys this * parameter is ignored. * * @returns string|bool * Returns the full path to the keyfile created on success, otherwise FALSE * is returned on a failure. */ public static function dnssec_create_key($domain, $key_type, $algorithm, $length) { global $msg; $valid = system_bind::validate_key_parameters($key_type, $algorithm, $length); if (!$valid) { $msg->raise('ERROR', 'system_bind', _("Key generation parameters for {$domain} are invalid. Please check the domain and server default configuration. Type: {$key_type}; Algorithm: {$algorithm}; Key size: {$length}")); return FALSE; } $ksk = ''; if ($key_type == 'ksk') { $ksk = ' -f KSK '; } $output = array(); $return_code = -1; $key_dir = system_bind::$DNSSEC_KEY_BASEDIR . '/' . $domain; // @TODO: This fails because the keys directory is owned and only readable by root. // @TODO: Furthermore, this should probably be called during update, when the scripts // aren't running as the alterncpanel user. // @TODO: Similar problems probably exist for the set files. if (!is_dir($key_dir)) { if (!mkdir($key_dir, 0750, TRUE)) { $msg->raise('ERROR', 'system_bind', _('Unable to create key storage directory')); $msg->log('system_bind', 'dnssec_create_key', "Unable to create directory '{$key_dir}'"); return FALSE; } } $command = sprintf("/usr/sbin/dnssec-keygen -q{$ksk} -a %s -b %s -n ZONE -K %s %s", escapeshellarg($algorithm), escapeshellarg($length), escapeshellarg($key_dir), escapeshellarg($domain)); $file_name = exec(escapeshellcmd($command), $output, $return_code); $msg->log('system_bind', 'dnssec_create_key', sprintf('Executed command "%s". Return code %d ; Last line of output: %s', $command, $return_code, $file_name) ); if ($return_code == 0) { return "{$key_dir}/{$file_name}.key"; } else { $output = implode("\n", $output); $msg->log('system_bind', 'dnssec_create_key', "Full output: {$output}"); } return FALSE; } public static function validate_key_parameters($key_type, $algorithm, $length) { global $msg; if (in_array($key_type, array('ksk', 'zsk'))) { if (!in_array($algorithm,array_keys(system_bind::$dnssec_key_limits['KSK/ZSK']))) { // Unsupported algorithm. $msg->debug('system_bind', 'validate_key_parameters', "Key validation failed: unknown algorithm for ksk/zsk {$algorithm}"); return FALSE; } $min = system_bind::$dnssec_key_limits['KSK/ZSK'][$algorithm]['min']; $max = system_bind::$dnssec_key_limits['KSK/ZSK'][$algorithm]['max']; if ($length >= $min && $length <= $max) { if (isset(system_bind::$dnssec_key_limits['KSK/ZSK'][$algorithm]['other'])) { // Only have one "other" check for the moment. Could do more // flexible parsing here. $other = system_bind::$dnssec_key_limits['KSK/ZSK'][$algorithm]['other']; if ($other == '%64') { return $length % 64 == 0; } $msg->debug('system_bind', 'validate_key_parameters', "Key validation failed: unknown other condition '{$other}'"); } else { return TRUE; } } else { $msg->debug('system_bind', 'validate_key_parameters', "Key validation failed: keysize ({$length} outside of bounds {$min} and {$max}"); } } else { $msg->debug('system_bind', 'validate_key_parameters', "Key validation failed: unknown key type '{$key_type}'"); } return FALSE; } /** * Lists all the existing keys for a given domain. * * @param $domain * The domain name * * @returns array * An array, possibly empty, of all existing keys for the domain. * The array elements are full paths to the keys. */ public static function list_keys($domain) { $r = array(); if (!$domain) { return $r; } $key_dir = system_bind::$DNSSEC_KEY_BASEDIR . '/' . $domain; if (!is_dir($key_dir)) { return $r; } $r = glob("{$key_dir}/K{$domain}*.key"); return $r; } /** * Gets the timing metadata for a given key. * * @param $key_file * Full path to the key file. * * @returns array * An array the metadata properties indexed by name: Created, Publish * Activate, Revoke, Inactive, Delete with the data being NULL (for unset) * or a time in seconds since the UNIX epoch. */ public static function get_key_metadata($key_file) { global $msg; $data = array(); if (!is_file($key_file)) { return $data; } $output = shell_exec(escapeshellcmd("LANG=C /usr/sbin/dnssec-settime -u -p all {$key_file}")); $lines = explode(PHP_EOL, $output); foreach ($lines as $line) { $r = explode(": ", $line); if ($r && length($r) == 2) { $data[$r[0]] = $r[1]; } else { $msg->log('system_bind', 'get_key_metadata', "Warning: output line for dnssec-settime for key {$key_file} does not have expected form: \"{$line}\""); } } return $data; } } /* Class system_bind */