<?php

/*
 * Copyright (C) 2018 Fabian Franz
 * Copyright (C) 2015-2024 Franco Fichtner <franco@opnsense.org>
 * Copyright (C) 2015 Manuel Faux <mfaux@conf.at>
 * Copyright (C) 2014 Warren Baker <warren@decoy.co.za>
 * Copyright (C) 2004-2007 Scott Ullrich <sullrich@gmail.com>
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * 1. Redistributions of source code must retain the above copyright notice,
 *    this list of conditions and the following disclaimer.
 *
 * 2. Redistributions in binary form must reproduce the above copyright
 *    notice, this list of conditions and the following disclaimer in the
 *    documentation and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
 * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
 * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
 * AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
 * OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 * POSSIBILITY OF SUCH DAMAGE.
 */

function unbound_enabled()
{
    $mdl = new \OPNsense\Unbound\Unbound();
    return !empty((string)$mdl->general->enabled);
}

function unbound_configure()
{
    return [
        'bootup' => ['unbound_configure_do'],
        'dns' => ['unbound_configure_do'],
        'early' => ['unbound_cache_flush'],
        'local' => ['unbound_configure_do'],
        'newwanip' => ['unbound_configure_do:2'],
        'unbound_start' => ['unbound_configure_do'],
        'unbound_stop' => ['unbound_service_stop'],
    ];
}

function unbound_services()
{
    $services = [];

    if (!unbound_enabled()) {
        return $services;
    }

    $mdl = new \OPNsense\Unbound\Unbound();

    $pconfig = [];
    $pconfig['name'] = 'unbound';
    $pconfig['dns_ports'] = [(string)$mdl->general->port];
    $pconfig['description'] = gettext('Unbound DNS');
    $pconfig['php']['restart'] = ['unbound_configure_do'];
    $pconfig['php']['start'] = ['unbound_configure_do'];
    $pconfig['php']['stop'] = ['unbound_service_stop'];
    $pconfig['pidfile'] = '/var/run/unbound.pid';
    $services[] = $pconfig;

    return $services;
}

function unbound_xmlrpc_sync()
{
    $result = [];

    $result[] = [
        'section' => 'unbound,OPNsense.unboundplus',
        'description' => gettext('Unbound DNS'),
        'services' => ['unbound'],
        'id' => 'dnsresolver',
    ];

    return $result;
}

function unbound_optimization()
{
    $optimization = [];

    $numprocs = intval(get_single_sysctl('kern.smp.cpus'));
    $numprocs = $numprocs <= 0 ? 1 : $numprocs;
    $numslabs = pow(2, floor(log($numprocs, 2)) + 1);

    $optimization['number_threads'] = "num-threads: {$numprocs}";
    $optimization['msg_cache_slabs'] = "msg-cache-slabs: {$numslabs}";
    $optimization['rrset_cache_slabs'] = "rrset-cache-slabs: {$numslabs}";
    $optimization['infra_cache_slabs'] = "infra-cache-slabs: {$numslabs}";
    $optimization['key_cache_slabs'] = "key-cache-slabs: {$numslabs}";

    return $optimization;
}

function unbound_service_stop()
{
    $mdl = new \OPNsense\Unbound\Unbound();

    mwexec('/usr/local/bin/flock -E 0 -o /tmp/unbound_start.lock true');

    if (empty((string)$mdl->general->cacheflush)) {
        if (isvalidpid('/var/run/unbound.pid')) {
            configd_run('unbound cache dump');
        }
    } else {
        unbound_cache_flush();
    }

    killbypid('/var/run/unbound_logger.pid');
    killbypid('/var/run/unbound_dhcpd.pid');
    killbypid('/var/run/unbound.pid');

    mwexecf('/sbin/umount %s', '/var/unbound/dev', true);
    mwexecf('/sbin/umount %s', '/var/unbound/usr/local/lib/' . readlink('/usr/local/bin/python3'), true);
    mwexecf('/sbin/umount %s', '/var/unbound/lib', true);
}

function unbound_generate_config()
{
    global $config;
    $general = config_read_array('OPNsense', 'unboundplus', 'general');

    $pythonv = readlink('/usr/local/bin/python3');
    $python_dir = "/usr/local/lib/{$pythonv}";
    $chroot_python_dir = "/var/unbound{$python_dir}";

    $dirs = ['/data', '/dev', '/etc', '/lib', '/run', '/usr', '/var/db', '/var/run', $python_dir];

    foreach ($dirs as $dir) {
        mwexecf('/bin/mkdir -p %s', "/var/unbound{$dir}");
    }

    mwexecf('/sbin/mount -t devfs devfs %s', '/var/unbound/dev');
    mwexecf('/sbin/mount -r -t nullfs %s %s', [$python_dir, $chroot_python_dir]);
    mwexecf('/sbin/mount -r -t nullfs %s %s', ['/lib', '/var/unbound/lib']);

    $optimization = unbound_optimization();

    $module_config = 'python ';
    $anchor_file = '';
    $dns64_config = '';

    if (!empty($general['dns64'])) {
        if (!empty($general['dns64prefix'])) {
            $dns64_config .= "\ndns64-prefix: {$general['dns64prefix']}";
        }
        if (!empty($general['noarecords'])) {
            $module_config .= 'respip ';
            $dns64_config .= "\nresponse-ip: 0.0.0.0/0 redirect";
        }
        $module_config .= 'dns64 ';
    }
    if (!empty($general['dnssec'])) {
        $module_config .= 'validator iterator';
        $anchor_file = 'auto-trust-anchor-file: /var/unbound/root.key';
    } else {
        $module_config .= 'iterator';
    }

    $private_addr = "";
    if (!isset($config['system']['webgui']['nodnsrebindcheck'])) {
        $advanced = config_read_array('OPNsense', 'unboundplus', 'advanced');
        if (!empty($advanced) && !empty($advanced['privateaddress'])) {
            foreach (explode(',', $advanced['privateaddress']) as $address) {
                $private_addr .= "private-address: {$address}\n";
            }
        }
    }

    $bindints = '';
    if (!empty($general['active_interface'])) {
        $active_interfaces = explode(',', $general['active_interface']);
        array_unshift($active_interfaces, 'lo0');

        $addresses = [];

        foreach (interfaces_addresses($active_interfaces) as $tmpaddr => $info) {
            if ($info['name'] == 'lo0' && $info['family'] == 'inet' && $tmpaddr != '127.0.0.1') {
                /* allow other DNS services to bind to loopback aliases */
                continue;
            }

            if (!$info['bind']) {
                continue;
            }

            $addresses[] = $tmpaddr;
        }

        foreach ($addresses as $address) {
            $bindints .= "interface: $address\n";
        }
    } else {
        $bindints .= "interface: 0.0.0.0\n";
        $bindints .= "interface: ::\n";
        $bindints .= "interface-automatic: yes\n";
    }

    $outgoingints = '';
    $ifconfig_details = legacy_interfaces_details();
    if (!empty($general['outgoing_interface'])) {
        $outgoingints = "# Outgoing interfaces to be used\n";
        $outgoing_interfaces = explode(",", $general['outgoing_interface']);
        foreach ($outgoing_interfaces as $outif) {
            $outip = get_interface_ip($outif, $ifconfig_details);
            if (!empty($outip)) {
                $outgoingints .= "outgoing-interface: $outip\n";
            }
            $outip = get_interface_ipv6($outif, $ifconfig_details);
            if (!empty($outip)) {
                $outgoingints .= "outgoing-interface: $outip\n";
            }
        }
    }

    unbound_add_host_entries($ifconfig_details);

    $port = $general['port'];

    /* do not touch prefer-ip6 as it is defaulting to 'no' anyway */
    $do_ip6 = isset($config['system']['ipv6allow']) ? 'yes' : 'no';

    if (!empty($general['regdhcp'])) {
        $include_dhcpleases = 'include: /var/unbound/dhcpleases.conf';
        @touch('/var/unbound/dhcpleases.conf');
    } else {
        $include_dhcpleases = '';
    }

    $unbound_mdl = new \OPNsense\Unbound\Unbound();
    $unbound_enabled = (string)$unbound_mdl->forwarding->enabled;

    $forward_conf = '';
    $forward_local = '';
    $resolv_conf_root = '';
    if ($unbound_enabled) {
        $dnsservers = get_nameservers();

        if (!empty($dnsservers)) {
            $forward_conf .= <<<EOD
# Forwarding
forward-zone:
    name: "."

EOD;
            foreach ($dnsservers as $dnsserver) {
                if (strpos($dnsserver, '127.') === 0 || $dnsserver == '::1') {
                    $forward_local = "do-not-query-localhost: no\n";
                } else {
                    /* Generate a custom resolv.conf file for use by unbound-anchor.
                     * These servers all use port 53 so exclude localhost from being queried for bootstrapping
                     * in our custom resolv.conf file as Unbound doesn't exist yet.
                     */
                    $resolv_conf_root .= "nameserver $dnsserver\n";
                }
                $forward_conf .= "\tforward-addr: $dnsserver\n";
            }
        }
    }

    file_put_contents("/var/unbound/resolv.conf.root", $resolv_conf_root, LOCK_EX);

    $so_reuseport = empty(system_sysctl_get()['net.inet.rss.enabled']) ? 'yes' : 'no';

    $unboundconf = <<<EOD
##########################
# Unbound Configuration
##########################

##
# Server configuration
##
server:
chroot: /var/unbound
username: unbound
directory: /var/unbound
pidfile: /var/run/unbound.pid
root-hints: /var/unbound/root.hints
use-syslog: yes
port: {$port}
include: /var/unbound/advanced.conf
harden-referral-path: no
do-ip4: yes
do-ip6: {$do_ip6}
do-udp: yes
do-tcp: yes
do-daemonize: yes
so-reuseport: {$so_reuseport}
module-config: "{$module_config}"
{$optimization['number_threads']}
{$optimization['msg_cache_slabs']}
{$optimization['rrset_cache_slabs']}
{$optimization['infra_cache_slabs']}
{$optimization['key_cache_slabs']}
{$anchor_file}
{$forward_local}
{$dns64_config}

# Interface IP(s) to bind to
{$bindints}
{$outgoingints}

# Private networks for DNS Rebinding prevention (when enabled)
{$private_addr}

# Private domains (DNS Rebinding)
include: /var/unbound/private_domains.conf

# Static host entries
include: /var/unbound/host_entries.conf

# DHCP leases (if configured)
{$include_dhcpleases}

# Custom includes
include: /var/unbound/etc/*.conf

{$forward_conf}

python:
python-script: dnsbl_module.py

remote-control:
    control-enable: yes
    control-interface: 127.0.0.1
    control-port: 953
    server-key-file: /var/unbound/unbound_server.key
    server-cert-file: /var/unbound/unbound_server.pem
    control-key-file: /var/unbound/unbound_control.key
    control-cert-file: /var/unbound/unbound_control.pem

EOD;

    file_put_contents('/var/unbound/unbound.conf', $unboundconf);

    /* Unbound load tripping over this file is very strange so make it a very safe rewrite */
    $root_hints_tmp = tempnam('/var/unbound', 'root.hints.');
    copy('/usr/local/opnsense/data/unbound/root.min.hints', $root_hints_tmp);
    chmod($root_hints_tmp, 0644);
    rename($root_hints_tmp, '/var/unbound/root.hints');

    /* XXX: cleanup, should have been run, but interface changes during boot or wizard are messing things up */
    configd_run('template reload OPNsense/Unbound/*');
}

function unbound_cache_flush()
{
    configd_run('unbound cache flush');
}

function unbound_match_interface($interface_map)
{
    $general = config_read_array('OPNsense', 'unboundplus', 'general');

    if (empty($interface_map)) {
        /* emulate non-interface reload */
        return true;
    }

    if (!empty($general['active_interface'])) {
        foreach (explode(',', $general['active_interface']) as $used) {
            if (in_array($used, $interface_map)) {
                return true;
            }
        }
    }

    if (!empty($general['outgoing_interface'])) {
        foreach (explode(',', $general['outgoing_interface']) as $used) {
            if (in_array($used, $interface_map)) {
                return true;
            }
        }
    }

    /*
     * We can ignore this request as we don't listen here
     * or always listen on :: / 0.0.0.0 so that a reload
     * is not necessary.
     */
    return false;
}

function unbound_configure_do($verbose = false, $interface_map = null)
{
    global $config;

    if (!plugins_argument_map($interface_map)) {
        return;
    }

    $mdl = new \OPNsense\Unbound\Unbound();

    /* try to avoid restarting, but make sure to let it start if it is not running */
    if (!unbound_match_interface($interface_map) && isvalidpid('/var/run/unbound.pid')) {
        return;
    }

    unbound_service_stop();

    if (!unbound_enabled()) {
        return;
    }

    service_log('Starting Unbound DNS...', $verbose);

    unbound_generate_config();

    $domain = '';
    if (!empty((string)$mdl->general->regdhcp)) {
        $domain = $config['system']['domain'];
        if (!empty((string)$mdl->general->regdhcpdomain)) {
            $domain = (string)$mdl->general->regdhcpdomain;
        }
    }

    if (!empty((string)$mdl->general->stats)) {
        @touch('/var/unbound/data/stats');
    } else {
        @unlink('/var/unbound/data/stats');
    }

    mwexecf_bg('/usr/local/bin/flock -n -E 0 -o /tmp/unbound_start.lock /usr/local/opnsense/scripts/unbound/start.sh %s', [$domain]);

    waitforpid('/var/run/unbound.pid', 10);

    service_log("done.\n", $verbose);
}

function unbound_add_host_entries($ifconfig_details)
{
    global $config;

    $general = config_read_array('OPNsense', 'unboundplus', 'general');

    $ptr_records = ['127.0.0.1', '::1'];

    openlog('unbound', LOG_DAEMON, LOG_LOCAL4);

    $local_zone_type = $general['local_zone_type'];

    $unbound_entries = "local-zone: \"{$config['system']['domain']}\" {$local_zone_type}\n";

    $unbound_entries .= "local-data-ptr: \"127.0.0.1 localhost\"\n";
    $unbound_entries .= "local-data: \"localhost A 127.0.0.1\"\n";
    $unbound_entries .= "local-data: \"localhost.{$config['system']['domain']} A 127.0.0.1\"\n";

    $unbound_entries .= "local-data-ptr: \"::1 localhost\"\n";
    $unbound_entries .= "local-data: \"localhost AAAA ::1\"\n";
    $unbound_entries .= "local-data: \"localhost.{$config['system']['domain']} AAAA ::1\"\n";

    if (!empty($general['active_interface'])) {
        $interfaces = explode(",", $general['active_interface']);
    } else {
        $interfaces = array_keys(get_configured_interface_with_descr());
    }

    if (empty($general['noregrecords'])) {
        foreach ($interfaces as $interface) {
            if ($interface == 'lo0' || substr($interface, 0, 4) == 'ovpn') {
                continue;
            }

            list ($laddr) = interfaces_primary_address($interface, $ifconfig_details);
            list ($laddr6) = interfaces_routed_address6($interface, $ifconfig_details);

            foreach (['4' => $laddr, '6' => $laddr6] as $ip_version => $addr) {
                if (empty($addr)) {
                    continue;
                }

                $domain = $config['system']['domain'];
                $dhcpd = $ip_version == '4' ? 'dhcpd' : 'dhcpd6';
                $record = $ip_version == '4' ? 'A' : 'AAAA';
                if (isset($config[$dhcpd][$interface]['enable']) && !empty($config[$dhcpd][$interface]['domain'])) {
                    $domain = $config[$dhcpd][$interface]['domain'];
                }
                if ($interface === get_primary_interface_from_list($interfaces)) {
                    $unbound_entries .= "local-data-ptr: \"{$addr} {$config['system']['hostname']}.{$domain}\"\n";
                    $ptr_records[] = $addr;
                }
                $unbound_entries .= "local-data: \"{$config['system']['hostname']}.{$domain} {$record} {$addr}\"\n";
                $unbound_entries .= "local-data: \"{$config['system']['hostname']} {$record} {$addr}\"\n";
            }

            if (empty($general['noreglladdr6'])) {
                if (!empty($lladdr6)) {
                    /* cannot embed scope */
                    $lladdr6 = explode('%', $lladdr6)[0];
                    $domain = $config['system']['domain'];
                    if (isset($config['dhcpdv6'][$interface]['enable']) && !empty($config['dhcpdv6'][$interface]['domain'])) {
                        $domain = $config['dhcpdv6'][$interface]['domain'];
                    }
                    $unbound_entries .= "local-data: \"{$config['system']['hostname']}.{$domain} AAAA {$lladdr6}\"\n";
                    $unbound_entries .= "local-data: \"{$config['system']['hostname']} AAAA {$lladdr6}\"\n";
                }
            }
        }
    }

    if (!empty($general['enable_wpad'])) {
        $webui_protocol = !empty($config['system']['webgui']['protocol']) ? $config['system']['webgui']['protocol'] : 'https';
        $webui_port = !empty($config['system']['webgui']['port']) ? $config['system']['webgui']['port'] : 443;
        // default domain
        $system_host_fqdn = $config['system']['hostname'];
        if (isset($config['system']['domain'])) {
            $system_host_fqdn .= '.' . $config['system']['domain'];
        }
        $unbound_entries .= "local-data: \"wpad.{$domain} IN CNAME {$system_host_fqdn}\"\n";
        $unbound_entries .= "local-data: \"wpad IN CNAME {$system_host_fqdn}\"\n";
        $unbound_entries .= "local-data: '{$domain} IN TXT \"service: wpad:{$webui_protocol}://{$system_host_fqdn}:{$webui_port}/wpad.dat\"'\n";
        // DHCP domains
        $tmp_known_domains = array($domain);
        foreach ($config['dhcpd'] as $dhcp_interface) {
            if (isset($dhcp_interface['domain']) && !empty($dhcp_interface['domain']) && !in_array($dhcp_interface['domain'], $tmp_known_domains)) {
                $unbound_entries .= "local-data: \"wpad.{$dhcp_interface['domain']} IN CNAME {$system_host_fqdn}\"\n";
                $unbound_entries .= "local-data: '{$dhcp_interface['domain']} IN TXT \"service: wpad:{$webui_protocol}://{$system_host_fqdn}:{$webui_port}/wpad.dat\"'\n";
                $tmp_known_domains[] = $dhcp_interface['domain'];
            }
        }
        unset($tmp_known_domains); // remove temporary variable
    }

    $unbound_mdl = new \OPNsense\Unbound\Unbound();
    $hosts = iterator_to_array($unbound_mdl->hosts->host->iterateItems());
    $aliases = iterator_to_array($unbound_mdl->aliases->alias->iterateItems());

    if (!empty($hosts)) {
        foreach ($hosts as $host) {
            if ($host->enabled == '1') {
                $tmp_aliases = array(array(
                    'domain' => (string)$host->domain,
                    'description' => (string)$host->description,
                    'hostname' => (string)$host->hostname
                ));

                if (!empty($aliases)) {
                    foreach ($aliases as $alias) {
                        if ($alias->enabled == '1' && $alias->host == $host->getAttribute('uuid')) {
                            $tmp_aliases[] = array(
                                'domain' => !empty((string)$alias->domain) ? $alias->domain : $tmp_aliases[0]['domain'],
                                'description' => !empty((string)$alias->description) ? $alias->description : $tmp_aliases[0]['description'],
                                'hostname' => !empty((string)$alias->hostname) ? $alias->hostname : $tmp_aliases[0]['hostname']
                            );
                        }
                    }
                }

                foreach ($tmp_aliases as $alias) {
                    $override_is_main = $alias === $tmp_aliases[0];

                    if ($alias['hostname'] != '') {
                        $alias['hostname'] .= '.';
                    }

                    switch ($host->rr) {
                        case 'A':
                        case 'AAAA':
                            /* Handle wildcard entries which have "*" as a hostname. Since we added a . above, we match on "*.". */
                            if ($alias['hostname'] == '*.') {
                                $unbound_entries .= "local-zone: \"{$alias['domain']}\" redirect\n";
                                $unbound_entries .= "local-data: \"{$alias['domain']} {$host->ttl} IN {$host->rr} {$host->server}\"\n";
                            } else {
                                if (($override_is_main || $tmp_aliases[0]['hostname'] === '*') && !in_array($host->server, $ptr_records, true)) {
                                    /* Only generate a PTR record for the non-alias override and only if the IP is not already associated with a PTR.
                                     * The exception to this is an alias whose parent uses a wildcard and as such does not specify a PTR record.
                                     */
                                    $unbound_entries .= "local-data-ptr: \"{$host->server} {$alias['hostname']}{$alias['domain']}\"\n";
                                    $ptr_records[] = $host->server;
                                } else {
                                    syslog(LOG_WARNING, 'PTR record already exists for ' . $alias['hostname'] . $alias['domain'] . '(' . $host->server . ')');
                                }
                                $unbound_entries .= "local-data: \"{$alias['hostname']}{$alias['domain']} {$host->ttl} IN {$host->rr} {$host->server}\"\n";
                            }
                            break;
                        case 'MX':
                            $unbound_entries .= "local-data: \"{$alias['hostname']}{$alias['domain']} {$host->ttl} IN MX {$host->mxprio} {$host->mx}\"\n";
                            break;
                    }

                    if (!empty($alias['description']) && !empty($general['txtsupport'])) {
                        $unbound_entries .= "local-data: '{$alias['hostname']}{$alias['domain']} TXT \"" . addslashes($alias['description']) . "\"'\n";
                    }
                }
            }
        }
    }

    if (!empty($general['regdhcpstatic'])) {
        foreach (plugins_run('static_mapping', [null, true, $ifconfig_details]) as $map) {
            foreach ($map as $host) {
                if (empty($host['hostname'])) {
                    /* cannot register without a hostname */
                    continue;
                }
                if (empty($host['domain'])) {
                    $host['domain'] = $config['system']['domain'];
                }
                if (isset($host['ipaddr'])) {
                    $unbound_entries .= "local-data-ptr: \"{$host['ipaddr']} {$host['hostname']}.{$host['domain']}\"\n";
                    $unbound_entries .= "local-data: \"{$host['hostname']}.{$host['domain']} IN A {$host['ipaddr']}\"\n";
                } else {
                    $unbound_entries .= "local-data-ptr: \"{$host['ipaddrv6']} {$host['hostname']}.{$host['domain']}\"\n";
                    $unbound_entries .= "local-data: \"{$host['hostname']}.{$host['domain']} IN AAAA {$host['ipaddrv6']}\"\n";
                }
                if (!empty($host['descr']) && !empty($general['txtsupport'])) {
                    $unbound_entries .= "local-data: '{$host['hostname']}.{$host['domain']} TXT \"" . addslashes($host['descr']) . "\"'\n";
                }
            }
        }
    }

    file_put_contents('/var/unbound/host_entries.conf', $unbound_entries);

    reopenlog();
}
