From e02164049e9482ed92561fce794e0711f2a5d737 Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Sun, 22 May 2022 20:18:18 +0200 Subject: [PATCH] add update cli-command; add update-channel setting (stable|testing) Signed-off-by: Michael Kaufmann --- actions/admin/settings/120.system.php | 13 ++ admin_autoupdate.php | 204 ++++++-------------- bin/froxlor-cli | 2 + install/froxlor.sql.php | 1 + install/updates/froxlor/update_0.11.inc.php | 1 + lib/Froxlor/Cli/UpdateCommand.php | 176 +++++++++++++++++ lib/Froxlor/Install/AutoUpdate.php | 173 +++++++++++++++++ lng/de.lng.php | 8 +- lng/en.lng.php | 10 +- 9 files changed, 438 insertions(+), 150 deletions(-) create mode 100644 lib/Froxlor/Cli/UpdateCommand.php create mode 100644 lib/Froxlor/Install/AutoUpdate.php diff --git a/actions/admin/settings/120.system.php b/actions/admin/settings/120.system.php index ba3dd152..23c60083 100644 --- a/actions/admin/settings/120.system.php +++ b/actions/admin/settings/120.system.php @@ -108,6 +108,19 @@ return [ 'default' => false, 'save_method' => 'storeSettingField' ], + 'update_channel' => [ + 'label' => lng('serversettings.update_channel'), + 'settinggroup' => 'system', + 'varname' => 'update_channel', + 'type' => 'select', + 'default' => 'stable', + 'select_var' => [ + 'stable' => lng('serversettings.uc_stable'), + 'testing' => lng('serversettings.uc_testing') + ], + 'save_method' => 'storeSettingField', + 'advanced_mode' => true + ], 'system_validatedomain' => [ 'label' => lng('serversettings.validate_domain'), 'settinggroup' => 'system', diff --git a/admin_autoupdate.php b/admin_autoupdate.php index 341e28b2..827031ae 100644 --- a/admin_autoupdate.php +++ b/admin_autoupdate.php @@ -29,32 +29,12 @@ require __DIR__ . '/lib/init.php'; use Froxlor\Froxlor; use Froxlor\FroxlorLogger; use Froxlor\Http\HttpClient; +use Froxlor\Install\AutoUpdate; use Froxlor\Settings; use Froxlor\UI\Panel\UI; use Froxlor\UI\Response; -// define update-uri -define('UPDATE_URI', "https://version.froxlor.org/Froxlor/api/" . Froxlor::VERSION); -define('RELEASE_URI', "https://autoupdate.froxlor.org/froxlor-{version}.zip"); -define('CHECKSUM_URI', "https://autoupdate.froxlor.org/froxlor-{version}.zip.sha256"); - if ($page != 'error') { - // check for archive-stuff - if (!extension_loaded('zip')) { - Response::redirectTo($filename, [ - 'page' => 'error', - 'errno' => 2 - ]); - } - - // 0.11.x requires 7.4 at least - if (version_compare("7.4.0", PHP_VERSION, ">=")) { - Response::redirectTo($filename, [ - 'page' => 'error', - 'errno' => 10 - ]); - } - // check for webupdate to be enabled if (Settings::Config('enable_webupdate') != true) { Response::redirectTo($filename, [ @@ -71,170 +51,98 @@ if ($page == 'overview') { // check for new version try { - $latestversion = HttpClient::urlGet(UPDATE_URI, true, 3); + $result = AutoUpdate::checkVersion(); } catch (Exception $e) { - Response::dynamicError("Version-check currently unavailable, please try again later"); + Response::dynamicError($e->getMessage()); } - $latestversion = explode('|', $latestversion); - if (is_array($latestversion) && count($latestversion) >= 1) { - $_version = $latestversion[0]; - $_message = isset($latestversion[1]) ? $latestversion[1] : ''; - $_link = isset($latestversion[2]) ? $latestversion[2] : htmlspecialchars($filename . '?page=' . urlencode($page) . '&lookfornewversion=yes'); - - // add the branding so debian guys are not gettings confused - // about their version-number - $version_label = $_version . Froxlor::BRANDING; - $version_link = $_link; - $message_addinfo = $_message; - - // not numeric -> error-message - if (!preg_match('/^((\d+\\.)(\d+\\.)(\d+\\.)?(\d+)?(\-(svn|dev|rc)(\d+))?)$/', $_version)) { - // check for customized version to not output - // "There is a newer version of froxlor" besides the error-message - Response::redirectTo($filename, [ - 'page' => 'error', - 'errno' => 3 - ]); - } elseif (Froxlor::versionCompare2(Froxlor::VERSION, $_version) == -1) { - // there is a newer version - yay - $isnewerversion = 1; - } else { - // nothing new - $isnewerversion = 0; - } + if ($result == 1) { // anzeige über version-status mit ggfls. formular // zum update schritt #1 -> download - if ($isnewerversion == 1) { - $text = 'There is a newer version available. Update to version ' . $_version . ' now?
(Your current version is: ' . Froxlor::VERSION . ')'; + $text = lng('admin.newerversionavailable') . ' ' . lng('admin.newerversiondetails', [AutoUpdate::getFromResult('version'), Froxlor::VERSION]); - $upd_formfield = [ - 'updates' => [ - 'title' => lng('update.update'), - 'image' => 'fa-solid fa-download', - 'sections' => [ - 'section_autoupd' => [ - 'fields' => [ - 'newversion' => ['type' => 'hidden', 'value' => $_version] - ] - ] - ], - 'buttons' => [ - [ - 'class' => 'btn-outline-secondary', - 'label' => lng('panel.cancel'), - 'type' => 'reset' - ], - [ - 'label' => lng('update.proceed') + $upd_formfield = [ + 'updates' => [ + 'title' => lng('update.update'), + 'image' => 'fa-solid fa-download', + 'sections' => [ + 'section_autoupd' => [ + 'fields' => [ + 'newversion' => ['type' => 'hidden', 'value' => AutoUpdate::getFromResult('version')] ] ] + ], + 'buttons' => [ + [ + 'class' => 'btn-outline-secondary', + 'label' => lng('panel.cancel'), + 'type' => 'reset' + ], + [ + 'label' => lng('update.proceed') + ] ] - ]; + ] + ]; - UI::view('user/form-note.html.twig', [ - 'formaction' => $linker->getLink(['section' => 'autoupdate', 'page' => 'getdownload']), - 'formdata' => $upd_formfield['updates'], - // alert - 'type' => 'warning', - 'alert_msg' => $text - ]); - } elseif ($isnewerversion == 0) { - // all good - Response::standardSuccess('noupdatesavail'); + UI::view('user/form-note.html.twig', [ + 'formaction' => $linker->getLink(['section' => 'autoupdate', 'page' => 'getdownload']), + 'formdata' => $upd_formfield['updates'], + // alert + 'type' => 'warning', + 'alert_msg' => $text + ]); + } else if ($result < 0 || $result > 1) { + // remote errors + if ($result < 0) { + Response::dynamicError(AutoUpdate::getLastError()); } else { - Response::standardError('customized_version'); + Response::redirectTo($filename, [ + 'page' => 'error', + 'errno' => $result + ]); } + } else { + // no new version + Response::standardSuccess('update.noupdatesavail'); } } // download the new archive elseif ($page == 'getdownload') { // retrieve the new version from the form $newversion = isset($_POST['newversion']) ? $_POST['newversion'] : null; + $result = 6; // valid? if ($newversion !== null) { - // define files to get - $toLoad = str_replace('{version}', $newversion, RELEASE_URI); - $toCheck = str_replace('{version}', $newversion, CHECKSUM_URI); - - // check for local destination folder - if (!is_dir(Froxlor::getInstallDir() . '/updates/')) { - mkdir(Froxlor::getInstallDir() . '/updates/'); - } - - // name archive - $localArchive = Froxlor::getInstallDir() . '/updates/' . basename($toLoad); - - $log->logAction(FroxlorLogger::ADM_ACTION, LOG_NOTICE, "Downloading " . $toLoad . " to " . $localArchive); - - // remove old archive - if (file_exists($localArchive)) { - @unlink($localArchive); - } - - // get archive data - try { - HttpClient::fileGet($toLoad, $localArchive); - } catch (Exception $e) { + $result = AutoUpdate::downloadZip($newversion); + if (!is_numeric($result)) { + // to the next step Response::redirectTo($filename, [ - 'page' => 'error', - 'errno' => 4 + 'page' => 'extract', + 'archive' => $result ]); } - - // validate the integrity of the downloaded file - $_shouldsum = HttpClient::urlGet($toCheck); - if (!empty($_shouldsum)) { - $_t = explode(" ", $_shouldsum); - $shouldsum = $_t[0]; - } else { - $shouldsum = null; - } - $filesum = hash_file('sha256', $localArchive); - - if ($filesum != $shouldsum) { - Response::redirectTo($filename, [ - 'page' => 'error', - 'errno' => 9 - ]); - } - - // to the next step - Response::redirectTo($filename, [ - 'page' => 'extract', - 'archive' => basename($localArchive) - ]); } Response::redirectTo($filename, [ 'page' => 'error', - 'errno' => 6 + 'errno' => $result ]); } // extract and install new version elseif ($page == 'extract') { if (isset($_POST['send']) && $_POST['send'] == 'send') { $toExtract = isset($_POST['archive']) ? $_POST['archive'] : null; $localArchive = Froxlor::getInstallDir() . '/updates/' . $toExtract; - // decompress from zip - $zip = new ZipArchive(); - $res = $zip->open($localArchive); - if ($res === true) { - $log->logAction(FroxlorLogger::ADM_ACTION, LOG_NOTICE, "Extracting " . $localArchive . " to " . Froxlor::getInstallDir()); - $zip->extractTo(Froxlor::getInstallDir()); - $zip->close(); - // success - remove unused archive - @unlink($localArchive); - // wait a bit before we redirect to be sure - sleep(2); - } else { + $log->logAction(FroxlorLogger::ADM_ACTION, LOG_NOTICE, "Extracting " . $localArchive . " to " . Froxlor::getInstallDir()); + $result = AutoUpdate::extractZip($localArchive); + if ($result > 0) { // error Response::redirectTo($filename, [ 'page' => 'error', - 'errno' => 8 + 'errno' => $result ]); } - - // redirect to update-page? + // redirect to update-page Response::redirectTo('admin_updates.php'); } else { $toExtract = isset($_GET['archive']) ? $_GET['archive'] : null; @@ -248,7 +156,7 @@ elseif ($page == 'extract') { ]); } - $text = 'Extract downloaded archive "' . $toExtract . '"?'; + $text = lng('admin.extractdownloadedzip', [$toExtract]); $upd_formfield = [ 'updates' => [ diff --git a/bin/froxlor-cli b/bin/froxlor-cli index 664f6be8..80b62ebc 100755 --- a/bin/froxlor-cli +++ b/bin/froxlor-cli @@ -31,6 +31,7 @@ use Froxlor\Cli\RunApiCommand; use Froxlor\Cli\ConfigServices; use Froxlor\Cli\PhpSessionclean; use Froxlor\Cli\SwitchServerIp; +use Froxlor\Cli\UpdateCommand; use Froxlor\Froxlor; // validate correct php version @@ -51,4 +52,5 @@ $application->add(new RunApiCommand()); $application->add(new ConfigServices()); $application->add(new PhpSessionclean()); $application->add(new SwitchServerIp()); +$application->add(new UpdateCommand()); $application->run(); diff --git a/install/froxlor.sql.php b/install/froxlor.sql.php index 711e821d..e043f861 100644 --- a/install/froxlor.sql.php +++ b/install/froxlor.sql.php @@ -699,6 +699,7 @@ opcache.validate_timestamps'), ('system', 'froxlorusergroup_gid', ''), ('system', 'acmeshpath', '/root/.acme.sh/acme.sh'), ('system', 'distribution', ''), + ('system', 'update_channel', 'stable'), ('api', 'enabled', '0'), ('2fa', 'enabled', '1'), ('panel', 'decimal_places', '4'), diff --git a/install/updates/froxlor/update_0.11.inc.php b/install/updates/froxlor/update_0.11.inc.php index d968e7d2..a4396f3b 100644 --- a/install/updates/froxlor/update_0.11.inc.php +++ b/install/updates/froxlor/update_0.11.inc.php @@ -133,6 +133,7 @@ if (Froxlor::isFroxlorVersion('0.10.99')) { Settings::AddNew("panel.settings_mode", $panel_settings_mode); $system_distribution = isset($_POST['system_distribution']) ? $_POST['system_distribution'] : ''; Settings::AddNew("system.distribution", $system_distribution); + Settings::AddNew("system.update_channel", 'stable'); Update::lastStepStatus(0); Update::showUpdateStep("Adjusting existing settings"); diff --git a/lib/Froxlor/Cli/UpdateCommand.php b/lib/Froxlor/Cli/UpdateCommand.php new file mode 100644 index 00000000..3cf01421 --- /dev/null +++ b/lib/Froxlor/Cli/UpdateCommand.php @@ -0,0 +1,176 @@ + + * @license https://files.froxlor.org/misc/COPYING.txt GPLv2 + */ + +namespace Froxlor\Cli; + +use Exception; +use Froxlor\Froxlor; +use Froxlor\Install\AutoUpdate; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; + +final class UpdateCommand extends CliCommand +{ + + protected function configure() + { + $this->setName('froxlor:update'); + $this->setDescription('Check for newer version and update froxlor'); + $this->addOption('check-only', 'c', InputOption::VALUE_NONE, 'Only check for newer version and exit') + ->addOption('yes-to-all', 'A', InputOption::VALUE_NONE, 'Do not ask for download, extract and database-update, just do it (if not --check-only is set)') + ->addOption('integer-return', 'i', InputOption::VALUE_NONE, 'Return integer whether a new version is available or not (implies --check-only). Useful for programmatic use.'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $result = self::SUCCESS; + + $result = $this->validateRequirements($input, $output); + + require Froxlor::getInstallDir() . '/lib/functions.php'; + + // version check + $newversionavail = false; + if ($result == self::SUCCESS) { + try { + $aucheck = AutoUpdate::checkVersion(); + + if ($aucheck == 1) { + if ($input->getOption('integer-return')) { + $output->write(1); + return self::SUCCESS; + } + // there is a new version + $text = lng('admin.newerversionavailable') . ' ' . lng('admin.newerversiondetails', [AutoUpdate::getFromResult('version'), Froxlor::VERSION]); + $text = str_replace("
", " ", $text); + $text = str_replace("", "", $text); + $text = str_replace("", "", $text); + $newversionavail = true; + $output->writeln('' . $text . ''); + $result = self::SUCCESS; + } else if ($aucheck < 0 || $aucheck > 1) { + if ($input->getOption('integer-return')) { + $output->write(-1); + return self::INVALID; + } + // errors + if ($aucheck < 0) { + $output->writeln('' . AutoUpdate::getLastError() . ''); + } else { + $errmsg = 'error.autoupdate_' . $aucheck; + if ($aucheck == 3) { + $errmsg = 'error.customized_version'; + } + $output->writeln('' . lng($errmsg) . ''); + } + $result = self::INVALID; + } else { + if ($input->getOption('integer-return')) { + $output->write(0); + return self::SUCCESS; + } + // no new version + $output->writeln('' . lng('update.noupdatesavail') . ''); + $result = self::SUCCESS; + } + } catch (Exception $e) { + if ($input->getOption('integer-return')) { + $output->write(-1); + return self::FAILURE; + } + $output->writeln('' . $e->getMessage() . ''); + $result = self::FAILURE; + } + } + + // if there's a newer version, proceed + if ($result == self::SUCCESS && $newversionavail) { + + // check whether we only wanted to check + if ($input->getOption('check-only')) { + //$output->writeln('Not proceeding as "check-only" is specified'); + return $result; + } else { + $yestoall = $input->getOption('yes-to-all') !== false; + + $helper = $this->getHelper('question'); + + // ask download + $question = new ConfirmationQuestion('Download newer version? [no] ', false, '/^(y|j)/i'); + if ($yestoall || $helper->ask($input, $output, $question)) { + // do download + $output->writeln('Downloading...'); + $audl = AutoUpdate::downloadZip(AutoUpdate::getFromResult('version')); + if (!is_numeric($audl)) { + // ask extract + $question = new ConfirmationQuestion('Extract downloaded archive? [no] ', false, '/^(y|j)/i'); + if ($yestoall || $helper->ask($input, $output, $question)) { + // do extract + $output->writeln('Extracting...'); + $auex = AutoUpdate::extractZip($audl); + if ($auex == 0) { + $output->writeln("Froxlor files updated successfully."); + $result = self::SUCCCESS; + $question = new ConfirmationQuestion('Update database? [no] ', false, '/^(y|j)/i'); + if ($yestoall || $helper->ask($input, $output, $question)) { + $result = $this->updateDatabase(); + } + } else { + $errmsg = 'error.autoupdate_' . $auex; + $output->writeln('' . lng($errmsg) . ''); + $result = self::FAILURE; + } + } + } else { + $errmsg = 'error.autoupdate_' . $audl; + $output->writeln('' . lng($errmsg) . ''); + $result = self::FAILURE; + } + } + } + } + return $result; + } + + private function updateDatabase() + { + include_once Froxlor::getInstallDir() . '/lib/tables.inc.php'; + define('_CRON_UPDATE', 1); + ob_start([ + 'this', + 'cleanUpdateOutput' + ]); + include_once Froxlor::getInstallDir() . '/install/updatesql.php'; + ob_end_flush(); + return self::SUCCCESS; + } + + private function cleanUpdateOutput($buffer) + { + return strip_tags(preg_replace("//", "\n", $buffer)); + } +} diff --git a/lib/Froxlor/Install/AutoUpdate.php b/lib/Froxlor/Install/AutoUpdate.php new file mode 100644 index 00000000..eccca1d3 --- /dev/null +++ b/lib/Froxlor/Install/AutoUpdate.php @@ -0,0 +1,173 @@ + + * @license https://files.froxlor.org/misc/COPYING.txt GPLv2 + */ + +namespace Froxlor\Install; + +use Exception; +use Froxlor\Froxlor; +use Froxlor\Settings; +use Froxlor\Http\HttpClient; + +class AutoUpdate +{ + // define update-uri + const UPDATE_URI = "https://version.froxlor.org/froxlor/api2/"; + const RELEASE_URI = "https://autoupdate.froxlor.org/froxlor-{version}.zip"; + const CHECKSUM_URI = "https://autoupdate.froxlor.org/froxlor-{version}.zip.sha256"; + + const ERR_NOZIPEXT = 2; + const ERR_COULDNOTSTORE = 4; + const ERR_ZIPNOTFOUND = 7; + const ERR_COULDNOTEXTRACT = 8; + const ERR_CHKSUM_MISMATCH = 9; + const ERR_MINPHP = 10; + + private static $latestversion = ""; + + private static $lasterror = ""; + + /** + * returns status about whether there is a newer version + * + * 0 = no new version available + * 1 = new version available + * -1 = remote error message + * >1 = local error message + * + * @return int + */ + public static function checkVersion(): int + { + $result = self::checkPrerequisites(); + + if ($result == 0) { + try { + $channel = ''; + if (Settings::Get('system.update_channel') == 'testing') { + $channel = '/testing'; + } + $latestversion = HttpClient::urlGet(self::UPDATE_URI . Froxlor::VERSION . $channel, true, 3); + } catch (Exception $e) { + throw new Exception("Version-check currently unavailable, please try again later", 504); + } + + self::$latestversion = json_decode($latestversion, true); + + if (self::$latestversion) { + if (!empty(self::$latestversion['error']) && self::$latestversion['error']) { + $result = -1; + self::$lasterror = self::$latestversion['message']; + } else if (isset(self::$latestversion['has_latest']) && self::$latestversion['has_latest'] == false) { + $result = 1; + } + } + } + return $result; + } + + public static function downloadZip(string $newversion) + { + // define files to get + $toLoad = str_replace('{version}', $newversion, self::RELEASE_URI); + $toCheck = str_replace('{version}', $newversion, self::CHECKSUM_URI); + + // check for local destination folder + if (!is_dir(Froxlor::getInstallDir() . '/updates/')) { + mkdir(Froxlor::getInstallDir() . '/updates/'); + } + + // name archive + $localArchive = Froxlor::getInstallDir() . '/updates/' . basename($toLoad); + + // remove old archive + if (file_exists($localArchive)) { + @unlink($localArchive); + } + + // get archive data + try { + HttpClient::fileGet($toLoad, $localArchive); + } catch (Exception $e) { + return self::ERR_COULDNOTSTORE; + } + + // validate the integrity of the downloaded file + $_shouldsum = HttpClient::urlGet($toCheck); + if (!empty($_shouldsum)) { + $_t = explode(" ", $_shouldsum); + $shouldsum = $_t[0]; + } else { + $shouldsum = null; + } + $filesum = hash_file('sha256', $localArchive); + + if ($filesum != $shouldsum) { + return self::ERR_CHKSUM_MISMATCH; + } + + return basename($localArchive); + } + + public static function extractZip(string $localArchive): int + { + if (!file_exists($localArchive)) { + return self::ERR_ZIPNOTFOUND; + } + // decompress from zip + $zip = new ZipArchive(); + $res = $zip->open($localArchive); + if ($res === true) { + $zip->extractTo(Froxlor::getInstallDir()); + $zip->close(); + // success - remove unused archive + @unlink($localArchive); + // wait a bit before we redirect to be sure + sleep(3); + return 0; + } + return self::ERR_COULDNOTEXTRACT; + } + + private static function checkPrerequisites(): int + { + if (!extension_loaded('zip')) { + return self::ERR_NOZIPEXT; + } + if (version_compare("7.4.0", PHP_VERSION, ">=")) { + return self::ERR_MINPHP; + } + return 0; + } + + public static function getLastError(): string + { + return self::$lasterror ?? ""; + } + + public static function getFromResult(string $index) + { + return self::$latestversion[$index] ?? ""; + } +} diff --git a/lng/de.lng.php b/lng/de.lng.php index 60c3f736..7e871f2a 100644 --- a/lng/de.lng.php +++ b/lng/de.lng.php @@ -329,7 +329,9 @@ return [ 'accountdata' => 'Benutzerdaten', 'contactdata' => 'Kontaktdaten', 'servicedata' => 'Dienstleistungsdaten', - 'newerversionavailable' => 'Eine neuere Version von Froxlor wurde veröffentlicht', + 'newerversionavailable' => 'Eine neuere Version von Froxlor wurde veröffentlicht.', + 'newerversiondetails' => 'Jetzt auf Version %s aktualisieren?
(Aktuelle Version ist: %s)', + 'extractdownloadedzip' => 'Heruntergeladenes Archiv "%s" entpacken?', 'cron' => [ 'cronsettings' => 'Cronjob-Einstellungen', 'add' => 'Cronjob hinzufügen', @@ -2005,6 +2007,10 @@ Vielen Dank, Ihr Administrator', 'title' => 'Pfad zu acme.sh', 'description' => 'Installationspfad zu acme.sh, inklusive acme.sh Script
Standard ist /root/.acme.sh/acme.sh', ], + 'update_channel' => [ + 'title' => 'froxlor Update Kanal', + 'description' => 'Wähle den bevorzugten Update Kanal. Standard ist "stable"', + ], ], 'spf' => [ 'use_spf' => 'Aktiviere SPF für Domains?', diff --git a/lng/en.lng.php b/lng/en.lng.php index 3eaba955..a860aebe 100644 --- a/lng/en.lng.php +++ b/lng/en.lng.php @@ -331,7 +331,9 @@ return [ 'accountdata' => 'Account Data', 'contactdata' => 'Contact Data', 'servicedata' => 'Service Data', - 'newerversionavailable' => 'There is a newer version of Froxlor available', + 'newerversionavailable' => 'There is a newer version of Froxlor available.', + 'newerversiondetails' => 'Update to version %s now?
(Your current version is: %s)', + 'extractdownloadedzip' => 'Extract downloaded archive "%s"?', 'cron' => [ 'cronsettings' => 'Cronjob settings', 'add' => 'Add cronjob', @@ -2377,6 +2379,12 @@ Yours sincerely, your administrator', 'title' => 'Path to acme.sh', 'description' => 'Set this to where acme.sh is installed to, including the acme.sh script
Default is /root/.acme.sh/acme.sh', ], + 'update_channel' => [ + 'title' => 'froxlor update-channel', + 'description' => 'Select the update channel of froxlor. Default is "stable"', + ], + 'uc_stable' => 'stable', + 'uc_testing' => 'testing', ], 'spf' => [ 'use_spf' => 'Activate SPF for domains?',