🏡 index : quine-global-labs/mirror-cockpit-project-cockpit.git

/*
 * Copyright (C) 2019 Red Hat, Inc.
 * SPDX-License-Identifier: LGPL-2.1-or-later
 */
import cockpit from "cockpit";
import React from "react";
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import { DatePicker } from "@patternfly/react-core/dist/esm/components/DatePicker/index.js";
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js";
import { Popover } from "@patternfly/react-core/dist/esm/components/Popover/index.js";
import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js";
import { TimePicker } from "@patternfly/react-core/dist/esm/components/TimePicker/index.js";
import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js";
import { Icon } from "@patternfly/react-core/dist/esm/components/Icon/index.js";
import { CloseIcon, ExclamationCircleIcon, InfoCircleIcon, PlusIcon } from "@patternfly/react-icons";
import { show_modal_dialog } from "cockpit-components-dialog.jsx";
import { SimpleSelect } from "cockpit-components-simple-select";
import { TypeaheadSelect } from "cockpit-components-typeahead-select";
import { useObject, useEvent } from "hooks.js";

import * as service from "service.js";
import * as timeformat from "timeformat";
import * as python from "python.js";
import get_timesync_backend_py from "./get-timesync-backend.py";

import { superuser } from "superuser.js";

import "serverTime.scss";

const _ = cockpit.gettext;

export function ServerTime() {
    const self = this;
    cockpit.event_target(self);

    function emit_changed() {
        self.dispatchEvent("changed");
    }

    let time_offset = null;
    let remote_offset = null;

    let client = null;
    let timedate = null;

    function connect() {
        if (client) {
            timedate.removeEventListener("changed", emit_changed);
            client.close();
        }
        client = cockpit.dbus('org.freedesktop.timedate1', { superuser: "try" });
        timedate = client.proxy();
        timedate.addEventListener("changed", emit_changed);
        client.subscribe({
            interface: "org.freedesktop.DBus.Properties",
            member: "PropertiesChanged"
        }, ntp_updated);
    }

    const timedate1_service = service.proxy("dbus-org.freedesktop.timedate1.service");
    const timesyncd_service = service.proxy("systemd-timesyncd.service");
    const chronyd_service = service.proxy("chronyd.service");

    timesyncd_service.addEventListener("changed", emit_changed);
    chronyd_service.addEventListener("changed", emit_changed);

    /*
     * The time we return from here as its UTC time set to the
     * server time. This is the only way to get predictable
     * behavior.
     */
    Object.defineProperty(self, 'utc_fake_now', {
        enumerable: true,
        get: function get() {
            const offset = time_offset + remote_offset;
            return new Date(offset + (new Date()).valueOf());
        }
    });

    Object.defineProperty(self, 'now', {
        enumerable: true,
        get: function get() {
            return new Date(time_offset + (new Date()).valueOf());
        }
    });

    const updateInterval = window.setInterval(emit_changed, 30000);

    self.wait = function wait() {
        if (remote_offset === null)
            return self.update();
        return Promise.resolve();
    };

    self.update = function update() {
        return cockpit.spawn(["date", "+%s:%z"], { err: "message" })
                .then(data => {
                    const parts = data.trim().split(":");
                    const timems = parseInt(parts[0], 10) * 1000;
                    let tzmin = parseInt(parts[1].slice(-2), 10);
                    const tzhour = parseInt(parts[1].slice(0, -2));
                    if (tzhour < 0)
                        tzmin = -tzmin;
                    const offsetms = (tzhour * 3600000) + tzmin * 60000;
                    const now = new Date();
                    time_offset = (timems - now.valueOf());
                    remote_offset = offsetms;
                    emit_changed();
                })
                .catch(ex => console.log("Couldn't calculate server time offset: " + cockpit.message(ex)));
    };

    /* There is no way to make sense of this date without a round trip to the
     * server, as the timezone is really server specific. */
    self.change_time = (datestr, timestr) => cockpit.spawn(["date", "--date=" + datestr + " " + timestr, "+%s"])
            .then(data => {
                const seconds = parseInt(data.trim(), 10);
                return timedate.call('SetTime', [seconds * 1000 * 1000, false, true])
                        .then(self.update);
            });

    self.bump_time = function (millis) {
        return timedate.call('SetTime', [millis, true, true]);
    };

    self.get_time_zone = function () {
        return timedate.Timezone;
    };

    self.set_time_zone = function (tz) {
        return timedate.call('SetTimezone', [tz, true]);
    };

    self.poll_ntp_synchronized = () => client.call(
        timedate.path, "org.freedesktop.DBus.Properties", "Get", ["org.freedesktop.timedate1", "NTPSynchronized"])
            .then(result => {
                const ifaces = { "org.freedesktop.timedate1": { NTPSynchronized: result[0].v } };
                const data = { };
                data[timedate.path] = ifaces;
                client.notify(data);
            })
            .catch(error => {
                if (error.name !== "org.freedesktop.DBus.Error.UnknownProperty" &&
                        error.problem !== "not-found")
                    console.log("can't get NTPSynchronized property", error);
            });

    let ntp_waiting_value = null;
    let ntp_waiting_resolve = null;

    function ntp_updated(path, iface, member, args) {
        if (!ntp_waiting_resolve || !args[1].NTP)
            return;
        if (ntp_waiting_value !== args[1].NTP.v)
            console.warn("Unexpected value of NTP");
        ntp_waiting_resolve();
        ntp_waiting_resolve = null;
    }

    self.set_ntp = function set_ntp(val) {
        const promise = new Promise((resolve, reject) => {
            ntp_waiting_resolve = resolve;
        });
        ntp_waiting_value = val;
        client.call(timedate.path,
                    "org.freedesktop.DBus.Properties", "Get", ["org.freedesktop.timedate1", "NTP"])
                .then(result => {
                // Check if don't want to enable enabled or disable disabled
                    if (result[0].v === val) {
                        ntp_waiting_resolve();
                        ntp_waiting_resolve = null;
                        return;
                    }
                    timedate.call('SetNTP', [val, true])
                            .catch(e => {
                                ntp_waiting_resolve();
                                ntp_waiting_resolve = null;
                                console.error("Failed to call SetNTP:", e.message); // not-covered: OS error
                            });
                });
        return promise;
    };

    self.get_ntp_active = function () {
        return timedate.NTP;
    };

    self.get_ntp_supported = function () {
        return timedate.CanNTP;
    };

    self.get_ntp_status = function () {
        const status = {
            initialized: false,
            active: false,
            synch: false,
            service: null,
            server: null,
            sub_status: null
        };

        // flag for tests that timedated/timesyncd proxies got initialized
        if (timedate.CanNTP !== undefined &&
            timedate1_service.unit && timedate1_service.unit.Id &&
            timesyncd_service.exists !== null &&
            chronyd_service.exists !== null)
            status.initialized = true;

        status.active = timedate.NTP;
        status.synch = timedate.NTPSynchronized;

        const timesyncd_server_regex = /.*time server (.*)\./i;

        const timesyncd_status = (timesyncd_service.state === "running" &&
                                timesyncd_service.service?.StatusText);

        if (timesyncd_service.state === "running")
            status.service = "systemd-timesyncd.service";
        else if (chronyd_service.state === "running")
            status.service = "chronyd.service";

        if (timesyncd_status) {
            const match = timesyncd_status.match(timesyncd_server_regex);
            if (match)
                status.server = match[1];
            else if (timesyncd_status !== "Idle." && timesyncd_status !== "")
                status.sub_status = timesyncd_status;
        }

        return status;
    };

    function get_timesync_backend() {
        return python.spawn(get_timesync_backend_py, [], { superuser: "try", err: "message" })
                .then(data => {
                    const unit = data.trim();
                    if (unit === "systemd-timesyncd.service")
                        return "timesyncd";
                    else if (unit === "chrony.service" || unit === "chronyd.service")
                        return "chronyd";
                    else
                        return null;
                });
    }

    function get_custom_ntp_timesyncd() {
        const custom_ntp_config_file = cockpit.file("/etc/systemd/timesyncd.conf.d/50-cockpit.conf",
                                                    { superuser: "try" });

        const result = {
            backend: "timesyncd",
            enabled: false,
            servers: []
        };

        return custom_ntp_config_file.read()
                .then(function(text) {
                    let ntp_line = "";
                    if (text) {
                        result.enabled = true;
                        text.split("\n").forEach(function(line) {
                            if (line.indexOf("NTP=") === 0) {
                                ntp_line = line.slice(4);
                                result.enabled = true;
                            } else if (line.indexOf("#NTP=") === 0) {
                                ntp_line = line.slice(5);
                                result.enabled = false;
                            }
                        });

                        result.servers = ntp_line.split(" ").filter(function(val) {
                            return val !== "";
                        });
                        if (result.servers.length === 0)
                            result.enabled = false;
                    }
                    return result;
                })
                .catch(function(error) {
                    console.warn("failed to load time servers", error);
                    return result;
                });
    }

    async function set_custom_ntp_timesyncd(config) {
        const conf_path = "/etc/systemd/timesyncd.conf.d/50-cockpit.conf";
        const custom_ntp_config_file = cockpit.file(conf_path, { superuser: "require" });

        const text = `# This file is automatically generated by Cockpit\n\n[Time]\n${config.enabled ? "" : "#"}NTP=${config.servers.join(" ")}\n`;

        // this must be readable with tight umask, timesyncd runs as unprivileged user
        await cockpit.spawn(["mkdir", "-p", "-m755", "/etc/systemd/timesyncd.conf.d"], { superuser: "require" });
        await custom_ntp_config_file.replace(text);
        await cockpit.spawn(["chmod", "644", conf_path], { superuser: "require" });
    }

    const chronyd_sourcedir = "/etc/chrony/sources.d";
    const chronyd_sources_enabled = chronyd_sourcedir + "/cockpit.sources";
    const chronyd_sources_disabled = chronyd_sourcedir + "/cockpit.disabled";

    function get_custom_ntp_chronyd() {
        const enabled_file = cockpit.file(chronyd_sources_enabled, { superuser: "try" });
        const disabled_file = cockpit.file(chronyd_sources_disabled, { superuser: "try" });

        function parse_servers(data) {
            if (!data)
                return [];
            const servers = [];
            data.split("\n").forEach(function(line) {
                const parts = line.split(" ");
                if (parts[0] === "server")
                    servers.push(parts[1]);
            });
            return servers;
        }

        return enabled_file.read()
                .then(data => {
                    if (data) {
                        return {
                            backend: "chronyd",
                            enabled: true,
                            servers: parse_servers(data)
                        };
                    } else {
                        return disabled_file.read()
                                .then(data => {
                                    return {
                                        backend: "chronyd",
                                        enabled: false,
                                        servers: parse_servers(data)
                                    };
                                });
                    }
                });
    }

    async function set_custom_ntp_chronyd(config) {
        const enabled_file = cockpit.file(chronyd_sources_enabled, { superuser: "require" });
        const disabled_file = cockpit.file(chronyd_sources_disabled, { superuser: "require" });

        const text = "# This file is automatically generated by Cockpit\n\n" + config.servers.map(s => `server ${s}\n`).join("");

        // HACK - https://bugzilla.redhat.com/show_bug.cgi?id=2168863
        function ensure_sourcedir() {
            function add_sourcedir(data) {
                const line = "sourcedir " + chronyd_sourcedir;
                if (data && data.indexOf(line) === -1)
                    data += "\n# Added by Cockpit\n" + line + "\n";
                return data;
            }
            return cockpit.file("/etc/chrony.conf", { superuser: "require" }).modify(add_sourcedir);
        }

        // chronyd runs as unprivileged chrony user; must be readable with tight umask
        await cockpit.spawn(["mkdir", "-p", "-m755", chronyd_sourcedir], { superuser: "require" });
        // mkdir -p -m only works for the last directory, parents are not changed so need to set that manually
        await cockpit.spawn(["chmod", "755", "/etc/chrony", chronyd_sourcedir], { superuser: "require" });

        if (config.enabled) {
            await enabled_file.replace(text);
            await disabled_file.replace(null);
            await cockpit.spawn(["chmod", "644", chronyd_sources_enabled], { superuser: "require" });
            await ensure_sourcedir();
        } else {
            await disabled_file.replace(text);
            await enabled_file.replace(null);
            await cockpit.spawn(["chmod", "644", chronyd_sources_disabled], { superuser: "require" });
        }
    }

    self.get_custom_ntp = function () {
        return get_timesync_backend().then(backend => {
            if (backend === "timesyncd") {
                return get_custom_ntp_timesyncd();
            } else if (backend === "chronyd") {
                return get_custom_ntp_chronyd();
            } else {
                return Promise.resolve({ backend: null, servers: [], enabled: false });
            }
        });
    };

    self.set_custom_ntp = function (config) {
        if (config.backend === "timesyncd") {
            return set_custom_ntp_timesyncd(config);
        } else if (config.backend === "chronyd") {
            return set_custom_ntp_chronyd(config);
        } else {
            return Promise.resolve();
        }
    };

    self.get_timezones = function() {
        return cockpit.spawn(["/usr/bin/timedatectl", "list-timezones"])
                .then(content => content.split('\n').filter(tz => tz !== ""));
    };

    /* NTPSynchronized needs to be polled so we just do that
     * always.
     */

    const ntp_poll_interval = window.setInterval(function() {
        self.poll_ntp_synchronized();
    }, 5000);

    self.close = function close() {
        window.clearInterval(updateInterval);
        window.clearInterval(ntp_poll_interval);
        client.close();
    };

    connect();
    superuser.addEventListener("reconnect", connect);
    self.update();
}

export function ServerTimeConfig() {
    const server_time = useObject(() => new ServerTime(),
                                  st => st.close(),
                                  []);
    useEvent(server_time, "changed");

    const ntp = server_time.get_ntp_status();

    const tz = server_time.get_time_zone();
    const systime_button = (
        <Button variant="link" id="system_information_systime_button"
                onClick={ () => change_systime_dialog(server_time, tz) }
                data-timedated-initialized={ntp?.initialized}
                isInline isDisabled={!superuser.allowed || !tz}>
            { timeformat.dateTimeUTC(server_time.utc_fake_now) }
        </Button>);

    let ntp_status = null;
    if (ntp?.active) {
        let icon; let header; let body = ""; let footer = null;
        if (ntp.synch) {
            icon = <Icon status="info"><InfoCircleIcon className="ct-info-circle" /></Icon>;
            header = _("Synchronized");
            if (ntp.server)
                body = <div>{cockpit.format(_("Synchronized with $0"), ntp.server)}</div>;
        } else {
            if (ntp.server) {
                icon = <Spinner size="md" />;
                header = _("Synchronizing");
                body = <div>{cockpit.format(_("Trying to synchronize with $0"), ntp.server)}</div>;
            } else {
                icon = <Icon status="danger"><ExclamationCircleIcon className="ct-exclamation-circle" /></Icon>;
                header = _("Not synchronized");
                if (ntp.service) {
                    footer = (
                        <Button variant="link"
                                onClick={() => cockpit.jump("/system/services#/" +
                                                            encodeURIComponent(ntp.service))}>
                            {_("Log messages")}
                        </Button>);
                }
            }
        }

        if (ntp.sub_status) {
            body = <>{body}<div>{ntp.sub_status}</div></>;
        }

        ntp_status = (
            <Popover headerContent={header} bodyContent={body} footerContent={footer}>
                {icon}
            </Popover>);
    }

    return (
        <Flex spaceItems={{ default: 'spaceItemsSm' }} alignItems={{ default: 'alignItemsCenter' }}>
            {systime_button}
            {ntp_status}
        </Flex>
    );
}

function Validated({ errors, error_key, children }) {
    const error = errors?.[error_key];
    // We need to always render the <div> for the has-error
    // class so that the input field keeps the focus when
    // errors are cleared.  Otherwise the DOM changes enough
    // for the Browser to remove focus.
    return (
        <div className={error ? "ct-validation-wrapper has-error" : "ct-validation-wrapper"}>
            { children }
            { error ? <span className="help-block dialog-error">{error}</span> : null }
        </div>
    );
}

function ValidatedInput({ errors, error_key, children }) {
    const error = errors?.[error_key];
    return (
        <span className={error ? "ct-validation-wrapper has-error" : "ct-validation-wrapper"}>
            { children }
        </span>
    );
}

function ChangeSystimeBody({ state, errors, change }) {
    const {
        time_zone, time_zones,
        mode,
        manual_date, manual_time,
        ntp_supported, custom_ntp
    } = state;

    function add_server(event, index) {
        custom_ntp.servers.splice(index + 1, 0, "");
        change("custom_ntp", custom_ntp);
        event.stopPropagation();
        event.preventDefault();
        return false;
    }

    function remove_server(event, index) {
        custom_ntp.servers.splice(index, 1);
        change("custom_ntp", custom_ntp);
        event.stopPropagation();
        event.preventDefault();
        return false;
    }

    function change_server(event, index, value) {
        custom_ntp.servers[index] = value;
        change("custom_ntp", custom_ntp);
        event.stopPropagation();
        event.preventDefault();
        return false;
    }

    const ntp_servers = (
        custom_ntp.servers.map((s, i) => (
            <Flex className="ntp-server-input-group" spaceItems={{ default: 'spaceItemsSm' }} key={i}>
                <FlexItem grow={{ default: 'grow' }}>
                    <TextInput value={s} placeholder={_("NTP server")} aria-label={_("NTP server")}
                               onChange={(event, value) => change_server(event, i, value)} />
                </FlexItem>
                <Button variant="secondary" onClick={event => add_server(event, i)}
                        icon={ <PlusIcon /> } />
                <Button variant="secondary" onClick={event => remove_server(event, i)}
                        icon={ <CloseIcon /> } isDisabled={i === custom_ntp.servers.length - 1} />
            </Flex>
        ))
    );

    const mode_options = [
        { value: "manual_time", content: _("Manually") },
        { value: "ntp_time", content: _("Automatically using NTP"), isDisabled: !ntp_supported },
    ];

    if (custom_ntp.backend)
        mode_options.push(
            {
                value: "ntp_time_custom",
                isDisabled: !ntp_supported,
                content: (custom_ntp.backend === "chronyd")
                    ? _("Automatically using additional NTP servers")
                    : _("Automatically using specific NTP servers")
            });

    return (
        <Form isHorizontal>
            <FormGroup fieldId="systime-timezones" label={_("Time zone")}>
                <Validated errors={errors} error_key="time_zone">
                    <TypeaheadSelect toggleProps={ { id: "systime-timezones" } }
                                     isScrollable
                                     selected={time_zone}
                                     onSelect={(event, value) => { change("time_zone", value) }}
                                     selectOptions={time_zones.map(tz => (
                                         { value: tz, content: tz.replaceAll("_", " ") }
                                     ))} />
                </Validated>
            </FormGroup>
            <FormGroup fieldId="change_systime_btn" label={_("Set time")} isStack>
                <SimpleSelect
                    id="change_systime"
                    options={mode_options}
                    selected={mode}
                    toggleProps={{ id: "change_systime_btn" }}
                    onSelect={value => change("mode", value)} />
                { mode === "manual_time" &&
                    <Flex spaceItems={{ default: 'spaceItemsSm' }} id="systime-manual-row">
                        <ValidatedInput errors={errors} error_key="manual_date">
                            <DatePicker id="systime-date-input"
                                        aria-label={_("Pick date")}
                                        buttonAriaLabel={_("Toggle date picker")}
                                        invalidFormatText=""
                                        locale={timeformat.dateFormatLang()}
                                        weekStart={timeformat.firstDayOfWeek()}
                                        onChange={(_, d) => change("manual_date", d)}
                                        value={manual_date}
                                        appendTo={() => document.body} />
                        </ValidatedInput>
                        <ValidatedInput errors={errors} error_key="manual_time">
                            <TimePicker id="systime-time-input"
                                        className="ct-serverTime-time-picker"
                                        time={manual_time}
                                        is24Hour
                                        menuAppendTo={() => document.body}
                                        invalidFormatErrorMessage=""
                                        onChange={(e, time, h, m, s, valid) => change("manual_time", time, valid) } />
                        </ValidatedInput>
                        <Validated errors={errors} error_key="manual_date" />
                        <Validated errors={errors} error_key="manual_time" />
                    </Flex>
                }
                { mode === "ntp_time_custom" &&
                    <Validated errors={errors} error_key="ntp_servers">
                        <div id="systime-ntp-servers">
                            { ntp_servers }
                        </div>
                    </Validated>
                }
            </FormGroup>
        </Form>
    );
}

function has_errors(errors) {
    for (const field in errors) {
        if (errors[field])
            return true;
    }
    return false;
}

function change_systime_dialog(server_time, timezone) {
    let dlg = null;
    const state = {
        time_zone: timezone,
        time_zones: null,
        mode: null,
        ntp_supported: server_time.get_ntp_supported(),
        custom_ntp: null,
        manual_time_valid: true,
    };
    let errors = { };

    function get_current_time() {
        state.manual_date = server_time.utc_fake_now.toISOString().split("T")[0];

        const minutes = server_time.utc_fake_now.getUTCMinutes();
        // normalize to two digits
        const minutes_str = (minutes < 10) ? "0" + minutes.toString() : minutes.toString();
        state.manual_time = `${server_time.utc_fake_now.getUTCHours()}:${minutes_str}`;
    }

    function change(field, value, isValid) {
        state[field] = value;
        errors = { };

        if (field === "mode" && value === "manual_time")
            get_current_time();

        if (field === "manual_time")
            state.manual_time_valid = value && isValid;

        update();
    }

    function validate() {
        errors = { };

        if (state.time_zone === "")
            errors.time_zone = _("Invalid timezone");

        if (state.mode === "manual_time") {
            const new_date = new Date(state.manual_date);
            if (isNaN(new_date.getTime()) || new_date.getTime() < 0)
                errors.manual_date = _("Invalid date format");

            if (!state.manual_time_valid)
                errors.manual_time = _("Invalid time format");
        }

        if (state.mode === "ntp_time_custom") {
            if (state.custom_ntp.servers.filter(s => !!s).length === 0)
                errors.ntp_servers = _("Need at least one NTP server");
        }

        return !has_errors(errors);
    }

    function apply() {
        return server_time.set_time_zone(state.time_zone)
                .then(() => {
                    if (state.mode === "manual_time") {
                        return server_time.set_ntp(false)
                                .then(() => server_time.change_time(state.manual_date,
                                                                    state.manual_time));
                    } else {
                        // Switch off NTP, write the config file, and switch NTP back on
                        state.custom_ntp.enabled = (state.mode === "ntp_time_custom");
                        state.custom_ntp.servers = state.custom_ntp.servers.filter(s => !!s);
                        return server_time.set_ntp(false)
                                .then(() => server_time.set_custom_ntp(state.custom_ntp))
                                .then(() => server_time.set_ntp(true));
                    }
                });
    }

    function update() {
        const props = {
            id: "system_information_change_systime",
            title: _("Change system time"),
            body: <ChangeSystimeBody state={state} errors={errors} change={change} />
        };

        const footer = {
            actions: [
                {
                    caption: _("Change"),
                    style: "primary",
                    clicked: () => {
                        if (validate()) {
                            return apply();
                        } else {
                            update();
                            return Promise.reject();
                        }
                    }
                }
            ]
        };

        if (!dlg)
            dlg = show_modal_dialog(props, footer);
        else {
            dlg.setProps(props);
            dlg.setFooterProps(footer);
        }
    }

    Promise.all([server_time.get_custom_ntp(), server_time.get_timezones()])
            .then(([custom_ntp, time_zones]) => {
                if (custom_ntp.servers.length === 0)
                    custom_ntp.servers = [""];
                state.custom_ntp = custom_ntp;
                state.time_zones = time_zones;
                if (server_time.get_ntp_active()) {
                    if (custom_ntp.enabled)
                        state.mode = "ntp_time_custom";
                    else
                        state.mode = "ntp_time";
                } else {
                    state.mode = "manual_time";
                    get_current_time();
                }
                update();
            });
}