diff --git a/bin/froxlor-cli b/bin/froxlor-cli
index eb5e214e..6301a426 100755
--- a/bin/froxlor-cli
+++ b/bin/froxlor-cli
@@ -26,6 +26,7 @@
declare(strict_types=1);
+use Froxlor\Cli\ConfigDiff;
use Symfony\Component\Console\Application;
use Froxlor\Cli\RunApiCommand;
use Froxlor\Cli\ConfigServices;
@@ -61,4 +62,5 @@ $application->add(new InstallCommand());
$application->add(new MasterCron());
$application->add(new UserCommand());
$application->add(new ValidateAcmeWebroot());
+$application->add(new ConfigDiff());
$application->run();
diff --git a/lib/Froxlor/Cli/ConfigDiff.php b/lib/Froxlor/Cli/ConfigDiff.php
new file mode 100644
index 00000000..590c7faf
--- /dev/null
+++ b/lib/Froxlor/Cli/ConfigDiff.php
@@ -0,0 +1,178 @@
+
+ * @license https://files.froxlor.org/misc/COPYING.txt GPLv2
+ */
+
+namespace Froxlor\Cli;
+
+use Froxlor\Config\ConfigParser;
+use Froxlor\FileDir;
+use Froxlor\Froxlor;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+final class ConfigDiff extends CliCommand
+{
+ protected function configure(): void
+ {
+ $this->setName('froxlor:config-diff')
+ ->setDescription('Shows differences in config templates between OS versions')
+ ->addArgument('from', InputArgument::OPTIONAL, 'OS version to compare against')
+ ->addArgument('to', InputArgument::OPTIONAL, 'OS version to compare from')
+ ->addOption('list', 'l', InputOption::VALUE_NONE, 'List all possible OS versions')
+ ->addOption('diff-params', 'p', InputOption::VALUE_REQUIRED, 'Additional parameters for `diff`');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ require Froxlor::getInstallDir() . '/lib/functions.php';
+
+ $parsers = $versions = [];
+ foreach (glob(Froxlor::getInstallDir() . '/lib/configfiles/*.xml') as $config) {
+ $name = str_replace(".xml", "", strtolower(basename($config)));
+ $parser = new ConfigParser($config);
+ $versions[$name] = $parser->getCompleteDistroName();
+ $parsers[$name] = $parser;
+ }
+ asort($versions);
+
+ if ($input->getOption('list') === true) {
+ $output->writeln('The following OS version templates are available:');
+ foreach ($versions as $k => $v) {
+ $output->writeln(str_pad($k, 20) . $v);
+ }
+ return self::SUCCESS;
+ }
+
+ if (!$input->hasArgument('from') || !array_key_exists($input->getArgument('from'), $versions)) {
+ $output->writeln('Missing or invalid "from" argument.');
+ $output->writeln('Available versions: ' . implode(', ', array_keys($versions)));
+ return self::INVALID;
+ }
+
+ if (!$input->hasArgument('to') || !array_key_exists($input->getArgument('to'), $versions)) {
+ $output->writeln('Missing or invalid "to" argument.');
+ $output->writeln('Available versions: ' . implode(', ', array_keys($versions)));
+ return self::INVALID;
+ }
+
+ // Make sure diff is installed
+ $check_diff_installed = FileDir::safe_exec('which diff');
+ if (count($check_diff_installed) === 0) {
+ $output->writeln('Unable to find "diff" installation on your system.');
+ return self::INVALID;
+ }
+
+ $parser_from = $parsers[$input->getArgument('from')];
+ $parser_to = $parsers[$input->getArgument('to')];
+ $tmp_from = tempnam(sys_get_temp_dir(), 'froxlor_config_diff_from');
+ $tmp_to = tempnam(sys_get_temp_dir(), 'froxlor_config_diff_to');
+ $files = [];
+ $titles_by_key = [];
+
+ // Aggregate content for each config file
+ foreach ([[$parser_from, 'from'], [$parser_to, 'to']] as $todo) {
+ foreach ($todo[0]->getServices() as $service_type => $service) {
+ foreach ($service->getDaemons() as $daemon_name => $daemon) {
+ foreach ($daemon->getConfig() as $instruction) {
+ if ($instruction['type'] !== 'file') {
+ continue;
+ }
+
+ if (isset($instruction['subcommands'])) {
+ foreach ($instruction['subcommands'] as $subinstruction) {
+ if ($subinstruction['type'] !== 'file') {
+ continue;
+ }
+
+ $content = $subinstruction['content'];
+ }
+ } else {
+ $content = $instruction['content'];
+ }
+
+ if (!isset($content)) {
+ throw new \Exception("Cannot find content for {$instruction['name']}");
+ }
+
+ $key = "{$service_type}_{$daemon_name}_{$instruction['name']}";
+ $titles_by_key[$key] = "{$service->title} : {$daemon->title} : {$instruction['name']}";
+ if (!isset($files[$key])) {
+ $files[$key] = ['from' => '', 'to' => ''];
+ }
+ $files[$key][$todo[1]] = $this->filterContent($content);
+ }
+ }
+ }
+ }
+ ksort($files);
+
+ $diff_params = '';
+ if ($input->hasOption('diff-params') && trim($input->getOption('diff-params')) !== '') {
+ $diff_params = trim($input->getOption('diff-params'));
+ }
+
+ // Run diff on each file and output, if anything changed
+ foreach ($files as $file_key => $content) {
+ file_put_contents($tmp_from, $content['from']);
+ file_put_contents($tmp_to, $content['to']);
+ $diff_output = FileDir::safe_exec("{$check_diff_installed[0]} {$diff_params} {$tmp_from} {$tmp_to}");
+
+ if (count($diff_output) === 0) {
+ continue;
+ }
+
+ $output->writeln('# ' . $titles_by_key[$file_key] . '');
+ $output->writeln(implode("\n", $diff_output) . "\n");
+ unset($diff_output);
+ }
+
+ // Remove tmp files again
+ unlink($tmp_from);
+ unlink($tmp_to);
+
+ return self::SUCCESS;
+ }
+
+ private function filterContent(string $content): string
+ {
+ $new_content = '';
+
+ foreach (explode("\n", $content) as $n) {
+ $n = trim($n);
+ if (!$n) {
+ continue;
+ }
+
+ if (str_starts_with($n, '#')) {
+ continue;
+ }
+
+ $new_content .= $n . "\n";
+ }
+
+ return $new_content;
+ }
+}