/*
 * Copyright (C) 2018 Red Hat, Inc.
 * SPDX-License-Identifier: LGPL-2.1-or-later
 */

import cockpit from 'cockpit';
import * as service from 'service';
import { debounce } from 'throttle-debounce';
import * as utils from './utils.js';

const firewall = {
    owner: undefined,
    installed: true,
    enabled: false,
    readonly: true,
    services: {},
    enabledServices: new Set(),
    /* Dictionary where zone ID is the key and zone information, as fetched by
     * fetchZoneInfos, the value */
    zones: {},
    activeZones: new Set(),
    /* Zones predefined by firewalld, from untrusted to trusted */
    predefinedZones: ['drop', 'block', 'public', 'external',
        'dmz', 'work', 'home', 'internal', 'trusted'],
    defaultZone: null,
    availableInterfaces: [],
    ready: false
};

cockpit.event_target(firewall);

utils.list_interfaces().then(interfaces => {
    firewall.availableInterfaces = interfaces;
});

const firewalld_service = service.proxy('firewalld');
let firewalld_dbus = null;

function debug() {
    if (window.debugging == "all" || window.debugging?.includes("firewalld")) // not-covered: debugging
        console.debug("firewalld:", ...arguments); // not-covered: debugging
}

firewall.debouncedGetZones = debounce(300, () => {
    if (firewalld_dbus)
        getZones()
                .then(() => getServices())
                .then(() => firewall.debouncedEvent('changed'))
                .catch(error => console.warn(error));
});

/* As certain dbus signal callbacks might change the firewall frequently
 * in a short period of time, prevent rapid succession of renders by
 * debouncing the ('changed') event */
firewall.debouncedEvent = debounce(300, event => firewall.dispatchEvent(event));

/* As a service might be removed from multiple zones at the same time,
 * prevent rapid succession of GetServices call */
firewall.debouncedGetServices = debounce(300, () => {
    getServices().then(() => firewall.debouncedEvent('changed'));
});

function initFirewalldDbus(owner) {
    debug("initializing D-Bus connection", owner);
    firewall.owner = owner;
    firewall.enabled = !!owner;

    firewall.zones = {};
    firewall.activeZones = new Set();
    firewall.services = {};
    firewall.enabledServices = new Set();

    if (!firewall.enabled) {
        firewall.ready = true;
        firewall.dispatchEvent('changed');
        return;
    }

    firewalld_dbus = cockpit.dbus(owner, { superuser: "try", track: true });

    firewalld_dbus.subscribe({
        interface: 'org.fedoraproject.FirewallD1.zone',
        path: '/org/fedoraproject/FirewallD1',
        member: 'ServiceAdded'
    }, (path, iface, signal, args) => {
        const zone = args[0];
        const service = args[1];
        fetchZoneInfos([zone])
                .then(() => fetchServiceInfos([service]))
                .then(info => firewall.enabledServices.add(info[0].id))
                .then(() => firewall.debouncedEvent('changed'))
                .catch(error => console.warn(error));
    });

    firewalld_dbus.subscribe({
        interface: 'org.fedoraproject.FirewallD1.zone',
        path: '/org/fedoraproject/FirewallD1',
        member: 'ServiceRemoved'
    }, (path, iface, signal, args) => {
        const zone = args[0];
        const service = args[1];

        firewall.zones[zone].services = firewall.zones[zone].services.filter(s => s !== service);
        firewall.enabledServices.delete(service);
        firewall.debouncedGetServices();
    });

    firewalld_dbus.subscribe({
        interface: 'org.fedoraproject.FirewallD1.zone',
        path: '/org/fedoraproject/FirewallD1',
        member: 'PortAdded'
    }, (path, iface, signal, args) => {
        const zone = args[0];
        const port = args[1];
        const protocol = args[2];
        if (!firewall.zones[zone].ports.some(p => p.port === port && p.protocol === protocol)) {
            firewall.zones[zone].ports.push({ port, protocol });
            firewall.debouncedEvent('changed');
        }
    });

    firewalld_dbus.subscribe({
        interface: 'org.fedoraproject.FirewallD1.zone',
        path: '/org/fedoraproject/FirewallD1',
        member: 'PortRemoved'
    }, (path, iface, signal, args) => {
        const zone = args[0];
        const port = args[1];
        const protocol = args[2];
        firewall.zones[zone].ports = firewall.zones[zone].ports
                .filter(p => p.port !== port || p.protocol !== protocol);
        firewall.debouncedEvent('changed');
    });

    firewalld_dbus.subscribe({
        interface: 'org.fedoraproject.FirewallD1',
        path: '/org/fedoraproject/FirewallD1',
        member: 'Reloaded'
    }, () => firewall.debouncedGetZones());

    /* There are two APIs available, changeZoneOf(Interface|Source) and
     * add(Interface|Source). Listen to both of them for any background changes
     * to zones. */
    firewalld_dbus.subscribe({
        interface: 'org.fedoraproject.FirewallD1.zone',
        path: '/org/fedoraproject/FirewallD1',
        member: 'ZoneOfInterfaceChanged'
    }, () => firewall.debouncedGetZones());
    firewalld_dbus.subscribe({
        interface: 'org.fedoraproject.FirewallD1.zone',
        path: '/org/fedoraproject/FirewallD1',
        member: 'ZoneOfSourceChanged'
    }, () => firewall.debouncedGetZones());

    firewalld_dbus.subscribe({
        interface: 'org.fedoraproject.FirewallD1.zone',
        path: '/org/fedoraproject/FirewallD1',
        member: 'InterfaceAdded'
    }, () => firewall.debouncedGetZones());
    firewalld_dbus.subscribe({
        interface: 'org.fedoraproject.FirewallD1.zone',
        path: '/org/fedoraproject/FirewallD1',
        member: 'SourceAdded'
    }, () => firewall.debouncedGetZones());
    firewalld_dbus.subscribe({
        interface: 'org.fedoraproject.FirewallD1.zone',
        path: '/org/fedoraproject/FirewallD1',
        member: 'InterfaceRemoved'
    }, () => firewall.debouncedGetZones());
    firewalld_dbus.subscribe({
        interface: 'org.fedoraproject.FirewallD1.zone',
        path: '/org/fedoraproject/FirewallD1',
        member: 'SourceRemoved'
    }, () => firewall.debouncedGetZones());

    getZones()
            .then(() => getServices())
            .catch(error => console.warn(error))
            .then(() => { firewall.ready = true })
            .then(() => firewall.debouncedEvent('changed'));
}

firewalld_service.addEventListener('changed', () => {
    const installed = !!firewalld_service.exists;
    const is_running = firewalld_service.state === 'running';

    // we get lots of these events, for internal property changes; filter interesting changes
    if (is_running === firewalld_service.prev_running && firewall.installed === installed)
        return;
    firewall.installed = installed;
    firewalld_service.prev_running = is_running;

    debug("systemd service changed: exists", firewalld_service.exists, "state", firewalld_service.state,
          "firewall.enabled:", JSON.stringify(firewall.enabled));

    firewall.dispatchEvent('changed');
});

function getZones() {
    return firewalld_dbus.call('/org/fedoraproject/FirewallD1',
                               'org.fedoraproject.FirewallD1.zone',
                               'getActiveZones', [])
            .then(reply => fetchZoneInfos(Object.keys(reply[0])))
            .then(zones => {
                firewall.activeZones = new Set(zones.map(z => z.id));
                debug("getActiveZones succeeded:", JSON.stringify([...firewall.activeZones]));
            })
            .then(() => firewalld_dbus.call('/org/fedoraproject/FirewallD1',
                                            'org.fedoraproject.FirewallD1',
                                            'getDefaultZone', []))
            .then(reply => {
                firewall.defaultZone = reply[0];
            })
            .then(() => firewalld_dbus.call('/org/fedoraproject/FirewallD1',
                                            'org.fedoraproject.FirewallD1.zone',
                                            'getZones', []))
            .then(reply => fetchZoneInfos(reply[0]));
}

function getServices() {
    if (firewall.readonly)
        return Promise.resolve();
    firewall.enabledServices = new Set();
    return Promise.all([...firewall.activeZones].map(z => {
        return firewalld_dbus.call('/org/fedoraproject/FirewallD1',
                                   'org.fedoraproject.FirewallD1.zone',
                                   'getServices', [z])
                .then(reply => fetchServiceInfos(reply[0]))
                .then(services => {
                    const promises = [];
                    for (const s of services) {
                        firewall.enabledServices.add(s.id);
                        if (s.includes.length)
                            promises.push(fetchServiceInfos(s.includes));
                    }
                    return Promise.all(promises);
                });
    }));
}

function fetchServiceInfos(services) {
    return Promise.all(services.map(service => {
        if (firewall.services[service])
            return firewall.services[service];

        let info;
        return firewalld_dbus.call('/org/fedoraproject/FirewallD1',
                                   'org.fedoraproject.FirewallD1',
                                   'getServiceSettings2', [service])
                .then(([{ short, description, ports }]) => {
                    short = short ? short.v : "";
                    description = description ? description.v : "";
                    ports = ports ? ports.v : [];
                    info = {
                        id: service,
                        name: short,
                        description,
                        ports: ports.map(p => ({ port: p[0], protocol: p[1] })),
                        includes: [],
                    };

                    firewall.services[service] = info;
                    return firewalld_dbus.call('/org/fedoraproject/FirewallD1/config',
                                               'org.fedoraproject.FirewallD1.config',
                                               'getServiceByName', [service]);
                })
                .then(path => firewalld_dbus.call(path[0],
                                                  'org.fedoraproject.FirewallD1.config.service',
                                                  'getSettings2', []))
                .then(reply => {
                    if (reply[0].includes) {
                        info.includes = reply[0].includes.v;
                        firewall.services[service] = info;
                    }
                    return info;
                })
                .catch(error => {
                    if (error.name === 'org.freedesktop.DBus.Error.UnknownMethod')
                        return info;
                    return Promise.reject(error);
                });
    }));
}

function fetchZoneInfos(zones) {
    return Promise.all(zones.map(zone => {
        if (firewall.readonly) {
            const info = {
                id: zone,
                name: zone,
                description: null,
                target: null,
                services: [],
                ports: [],
                interfaces: [],
                source: [],
            };
            firewall.zones[zone] = info;
            return info;
        }
        return firewalld_dbus.call('/org/fedoraproject/FirewallD1',
                                   'org.fedoraproject.FirewallD1.zone',
                                   'getZoneSettings2', [zone])
                .then(([zoneInfo]) => {
                    const info = {
                        id: zone,
                        name: (zoneInfo.short || {}).v,
                        description: (zoneInfo.description || {}).v,
                        target: (zoneInfo.target || {}).v,
                        services: ((zoneInfo.services || {}).v || []),
                        ports: ((zoneInfo.ports || {}).v || []).map(p => ({ port: p[0], protocol: p[1] })),
                        interfaces: ((zoneInfo.interfaces || {}).v || []),
                        source: ((zoneInfo.sources || {}).v || []),
                    };
                    firewall.zones[zone] = info;
                    return info;
                });
    }));
}

/* We watch the name owner explicitly.

   The org.fedoraproject.FirewallD service might appear on D-Bus only
   some time after the firewalld.service is active and thus we want to
   only try connecting to it once we know that it is definitely there.
   However, while cockpit.dbus() does work with services that
   disappear and then reappear on the bus while the channel is open,
   it doesn't work with names that are not present when the channel is
   initially opened (see
   https://github.com/cockpit-project/cockpit/pull/9125).

   Thus, we track the name explicitly via the org.freedesktop.DBus
   API.
 */

function dbus_watch_name_owner(name, owner_callback) {
    const bus = cockpit.dbus('org.freedesktop.DBus', { superuser: "try" });
    let owner = null;

    function owner_changed(new_owner) {
        if (new_owner != owner) {
            owner = new_owner;
            owner_callback(owner);
        }
    }

    bus.subscribe({
        path: "/org/freedesktop/DBus",
        interface: "org.freedesktop.DBus",
        member: "NameOwnerChanged",
        arg0: name,
    }, (_path, _iface, _member, [_name, _old_owner, new_owner]) => {
        owner_changed(new_owner);
    });

    bus.call(
        "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetNameOwner",
        [name]
    )
            .then(([owner]) => {
                owner_changed(owner);
            })
            .catch(ex => {
                if (ex.name == "org.freedesktop.DBus.Error.NameHasNoOwner")
                    owner_changed("");
                else
                    throw ex;
            });
}

dbus_watch_name_owner('org.fedoraproject.FirewallD1', initFirewalldDbus);

cockpit.spawn(['sh', '-c', 'pkcheck --action-id org.fedoraproject.FirewallD1.all --process $$ --allow-user-interaction 2>&1'], { superuser: "try" })
        .then(() => {
            firewall.readonly = false;
            firewall.debouncedEvent('changed');
            firewall.debouncedGetZones();
        })
        .catch(error => {
            console.log("pkcheck failed", error);

            // Fall back to cockpit.permissions, "pkcheck" might not be available,
            // always allow edits by admins
            const permission = cockpit.permission({ admin: true });
            const update_permissions = () => {
                firewall.readonly = !permission.allowed;
                firewall.debouncedEvent('changed');
                firewall.debouncedGetZones();
            };
            permission.addEventListener("changed", update_permissions);
        });

firewall.enable = () => firewalld_service.enable().then(() => firewalld_service.start());
firewall.disable = () => firewalld_service.disable().then(() => firewalld_service.stop());

firewall.getAvailableServices = () => {
    return firewalld_dbus.call('/org/fedoraproject/FirewallD1',
                               'org.fedoraproject.FirewallD1',
                               'listServices', [])
            .then(reply => fetchServiceInfos(reply[0]))
            .catch(error => console.warn(error));
};

/*
 * Only call this after defining a new service, as it will remove existing
 * non-permanent configurations.
 */
firewall.reload = () => {
    return firewalld_dbus.call('/org/fedoraproject/FirewallD1',
                               'org.fedoraproject.FirewallD1',
                               'reload', [])
            .catch(error => console.warn(error));
};

/*
 * Remove a service from the specified zone (i.e., close its ports).
 *
 * Returns a promise that resolves when the service is removed.
 */
firewall.removeService = (zone, service) => {
    return firewalld_dbus.call('/org/fedoraproject/FirewallD1',
                               'org.fedoraproject.FirewallD1.zone',
                               'removeService', [zone, service])
            .then(reply => firewalld_dbus.call('/org/fedoraproject/FirewallD1/config',
                                               'org.fedoraproject.FirewallD1.config',
                                               'getZoneByName', [zone]))
            .then(path => firewalld_dbus.call(path[0], 'org.fedoraproject.FirewallD1.config.zone',
                                              'removeService', [service]));
};

firewall.removePort = (zone, port, protocol) => {
    return firewalld_dbus.call('/org/fedoraproject/FirewallD1',
                               'org.fedoraproject.FirewallD1.zone',
                               'removePort', [zone, port, protocol])
            .then(reply => firewalld_dbus.call('/org/fedoraproject/FirewallD1/config',
                                               'org.fedoraproject.FirewallD1.config',
                                               'getZoneByName', [zone]))
            .then(path => firewalld_dbus.call(path[0], 'org.fedoraproject.FirewallD1.config.zone',
                                              'removePort', [port, protocol]));
};

/*
 * Create new firewalld service.
 *
 * Returns a promise that resolves when the service is created.
 * It will also reload firewalld and enable the new service.
 */
firewall.createService = (service, ports, zones, desc = "") => {
    const subscription = firewalld_dbus.subscribe({
        interface: 'org.fedoraproject.FirewallD1',
        path: '/org/fedoraproject/FirewallD1',
        member: 'Reloaded'
    }, () => {
        firewall.addServices(zones, [service]);
        subscription.remove();
    });
    return firewalld_dbus.call('/org/fedoraproject/FirewallD1/config',
                               'org.fedoraproject.FirewallD1.config',
                               'addService2', [service, { description: { t: 's', v: desc }, ports: { t: 'a(ss)', v: ports } }])
            .then(() => firewall.reload());
};

/*
 * Edit firewalld service.
 *
 * Returns a promise that resolves when the service is edited.
 */
firewall.editService = (service, ports, desc = "") => {
    return firewalld_dbus.call('/org/fedoraproject/FirewallD1/config',
                               'org.fedoraproject.FirewallD1.config',
                               'getServiceByName', [service])
            .then(path => firewalld_dbus.call(path[0],
                                              'org.fedoraproject.FirewallD1.config.service',
                                              'update2', [{ description: { t: 's', v: desc }, ports: { t: 'a(ss)', v: ports } }])
                    .then(() => {
                        // No signal for updated service so we need to manually update it
                        firewall.services[service].description = desc;
                        firewall.services[service].ports = ports.map(port => ({ port: port[0], protocol: port[1] }));
                        firewall.debouncedEvent('changed');
                        return firewall.reload();
                    })
            );
};

/*
 * Add a predefined firewalld service to the specified zone (i.e., open its
 * ports).
 *
 * Returns a promise that resolves when the service is added.
 */
firewall.addService = (zone, service) => {
    return firewalld_dbus.call('/org/fedoraproject/FirewallD1',
                               'org.fedoraproject.FirewallD1.zone',
                               'addService', [zone, service, 0])
            .then(reply => firewalld_dbus.call('/org/fedoraproject/FirewallD1/config',
                                               'org.fedoraproject.FirewallD1.config',
                                               'getZoneByName', [zone]))
            .then(path => firewalld_dbus.call(path[0], 'org.fedoraproject.FirewallD1.config.zone',
                                              'addService', [service]));
};

/*
 * Like addService(), but adds multiple predefined firewalld services at once
 * to the specified zones.
 *
 * Returns a promise that resolves when all services are added.
 */
firewall.addServices = (zone, services) =>
    Promise.all(services.map(s => firewall.addService(zone, s)));

firewall.removeServiceFromZones = (zones, service) =>
    Promise.all(zones.map(z => firewall.removeService(z, service)));

firewall.activateZone = (zone, interfaces, sources) => {
    let promises = interfaces.map(i => firewalld_dbus.call('/org/fedoraproject/FirewallD1',
                                                           'org.fedoraproject.FirewallD1.zone',
                                                           'addInterface', [zone, i]));

    promises = promises.concat(sources.map(s => firewalld_dbus.call('/org/fedoraproject/FirewallD1',
                                                                    'org.fedoraproject.FirewallD1.zone',
                                                                    'addSource', [zone, s])));
    let p = Promise.all(promises).then(() => firewalld_dbus.call('/org/fedoraproject/FirewallD1/config',
                                                                 'org.fedoraproject.FirewallD1.config',
                                                                 'getZoneByName', [zone]));
    p = p.then(path => {
        /* Once this signal is received, it's safe to actually emit the changed
         * signal and thus update the UI */
        const subscription = firewalld_dbus.subscribe({
            interface: 'org.fedoraproject.FirewallD1.config.zone',
            path: path[0],
            member: 'Updated'
        }, (path, iface, signal, args) => {
            getZones().then(() => getServices());
            subscription.remove();
        });

        return firewalld_dbus.call(path[0],
                                   'org.fedoraproject.FirewallD1.config.zone',
                                   'update2', [{ interfaces: { t: 'as', v: interfaces }, sources: { t: 'as', v: sources } }]);
    });
    return p;
};

/*
 * A zone is considered deactivated when it has no interfaces or sources.
 */
firewall.deactiveateZone = (zone) => {
    const zoneObject = firewall.zones[zone];
    let promises = zoneObject.interfaces.map(i => firewalld_dbus.call('/org/fedoraproject/FirewallD1',
                                                                      'org.fedoraproject.FirewallD1.zone',
                                                                      'removeInterface', [zone, i]));
    promises = promises.concat(zoneObject.source.map(s => firewalld_dbus.call('/org/fedoraproject/FirewallD1',
                                                                              'org.fedoraproject.FirewallD1.zone',
                                                                              'removeSource', [zone, s])));
    let p = Promise.all(promises).then(() => firewalld_dbus.call('/org/fedoraproject/FirewallD1/config',
                                                                 'org.fedoraproject.FirewallD1.config',
                                                                 'getZoneByName', [zone]));
    p = p.then(path => {
        /* Once this signal is received, it's safe to actually emit the changed
         * signal and thus update the UI */
        const subscription = firewalld_dbus.subscribe({
            interface: 'org.fedoraproject.FirewallD1.config.zone',
            path: path[0],
            member: 'Updated'
        }, (path, iface, signal, args) => {
            firewall.activeZones.delete(args[0]);
            getZones().then(() => getServices());
            subscription.remove();
        });

        return firewalld_dbus.call(path[0],
                                   'org.fedoraproject.FirewallD1.config.zone',
                                   'update2', [{ interfaces: { t: 'as', v: [] }, sources: { t: 'as', v: [] } }]);
    });

    return p.catch(error => console.warn(error));
};

export default firewall;
