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

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

import React, { useState, useEffect } from 'react';

import { Alert, AlertActionLink } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import { CodeBlockCode } from "@patternfly/react-core/dist/esm/components/CodeBlock/index.js";
import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
import { ExpandableSection } from "@patternfly/react-core/dist/esm/components/ExpandableSection/index.js";
import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js";
import {
    Modal, ModalBody, ModalFooter, ModalHeader
} from '@patternfly/react-core/dist/esm/components/Modal/index.js';
import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js";
import { CheckIcon, ExclamationCircleIcon, InProgressIcon } from "@patternfly/react-icons";

import cockpit from "cockpit";
import { Privileged } from "cockpit-components-privileged";
import { superuser } from "superuser";
import { useEvent } from "hooks.js";
import { FormHelper } from "cockpit-components-form-helper";
import { install_dialog } from "cockpit-components-install-dialog.jsx";
import { useDialogs } from "dialogs.jsx";
import { getPackageManager } from 'packagemanager';

import "./realmd.scss";

const _ = cockpit.gettext;

const MANAGER = "/org/freedesktop/realmd";
const PROVIDER = "org.freedesktop.realmd.Provider";
const KERBEROS = "org.freedesktop.realmd.Kerberos";
const KERBEROS_MEMBERSHIP = "org.freedesktop.realmd.KerberosMembership";
const REALM = "org.freedesktop.realmd.Realm";

export class RealmdClient {
    constructor() {
        this.onClose = this.onClose.bind(this);
        this.onRealmsChanged = this.onRealmsChanged.bind(this);
        this.joined = [];
        this.detected = null;
        this.error = null;
        this.install_realmd = false;
        this.callSerial = 1;

        cockpit.event_target(this);
        this.initProxy();
        superuser.addEventListener("changed", () => this.initProxy());
    }

    onClose(ev, options) {
        if (options.problem === "not-found") {
            // see if we can install it
            getPackageManager().then(() => {
                this.error = _("Joining a domain requires installation of realmd");
                this.install_realmd = true;
                this.dispatchEvent("changed");
            }).catch(exc => {
                this.error = _("Cannot join a domain because realmd is not available on this system");
                this.dispatchEvent("changed");
            });
        } else {
            this.error = cockpit.message(options);
            this.dispatchEvent("changed");
        }
        this.dbus_realmd.removeEventListener("close", this.onClose);
        this.dbus_realmd.close();
        this.dbus_realmd = null;
    }

    onRealmsChanged() {
        this.joined = [];
        for (const path in this.realms) {
            const realm = this.realms[path];
            if (realm.Configured)
                this.joined.push(realm);
        }

        this.dispatchEvent("changed");
    }

    initProxy() {
        // Ignore intermediate states of superuser.allowed to
        // avoid initializing the proxy twice during page
        // load. This is less wasteful and helps the tests avoid
        // race conditions. We are guaranteed to see a real "true"
        // or "false" value eventually.
        //
        if (superuser.allowed === null)
            return;

        if (this.dbus_realmd) {
            this.dbus_realmd.removeEventListener("close", this.onClose);
            this.dbus_realmd.close();
        }

        this.error = null;
        this.dbus_realmd = cockpit.dbus("org.freedesktop.realmd", { superuser: "try" });
        this.dbus_realmd.watch(MANAGER);
        this.dbus_realmd.addEventListener("close", this.onClose);

        this.realms = this.dbus_realmd.proxies(REALM, MANAGER);
        this.realms.addEventListener("changed", this.onRealmsChanged);
    }

    checkRealm(name) {
        return this.dbus_realmd.call(MANAGER, PROVIDER, "Discover", [name, {}])
                .then(([relevance, realms]) => {
                    if (realms.length === 0)
                        return { result: false };

                    // the first realm
                    const path = realms[0];
                    const realm = this.dbus_realmd.proxy(REALM, path);
                    const kerberos_membership = this.dbus_realmd.proxy(KERBEROS_MEMBERSHIP, path);
                    return Promise.allSettled([realm.wait(), kerberos_membership.wait()])
                            .then(() => { return { result: true, realm, kerberos_membership } });
                });
    }

    join(realm, kerberosMembership, user, password) {
        const id = "cockpit-" + this.callSerial;
        this.callSerial += 1;
        let diagnostics = "";

        const options = { operation: cockpit.variant('s', id) };
        const diagnostics_sub = this.dbus_realmd.subscribe({ member: "Diagnostics" }, (path, iface, signal, args) => {
            if (args[1] === id) {
                diagnostics += args[0];
            }
        });

        if (kerberosMembership.valid) {
            const credentials = ["password", "administrator", cockpit.variant('(ss)', [user, password])];
            return kerberosMembership.call("Join", [credentials, options])
                    .then(() => this.installWSCredentials(realm, user, password))
                    .catch(ex => {
                        if (ex.name === "org.freedesktop.realmd.Error.Cancelled")
                            return Promise.resolve();
                        ex.diagnostics = diagnostics;
                        return Promise.reject(ex);
                    })
                    .finally(() => diagnostics_sub.remove());
        } else {
            return Promise.reject(new Error(_("Joining this domain is not supported")));
        }
    }

    leave(realm) {
        return this.cleanupWSCredentials(realm)
                .then(() => realm.Deconfigure({ operation: cockpit.variant('s', "cockpit-leave-domain") }));
    }

    installWSCredentials(realm, user, password) {
        // skip this on remote ssh hosts, only set up ws hosts
        if (cockpit.transport.host !== "localhost")
            return true;

        const server_sw = find_detail(realm, "server-software");
        if (server_sw !== "ipa") {
            console.log("installing ws credentials not supported for server software", server_sw);
            return true;
        }

        const kerberos = this.dbus_realmd.proxy(KERBEROS, realm.path);
        return kerberos.wait()
                .then(() => {
                    const helper = cockpit.manifests.system.libexecdir + "/cockpit-certificate-helper";
                    const proc = cockpit.spawn([helper, "ipa", "request", kerberos.RealmName, user],
                                               { superuser: "require", err: "message" });
                    proc.input(password);
                    proc.catch(ex => console.warn("Failed to run", helper, "ipa request:", ex.toString()));
                    return proc;
                })
                .catch(() => true); // no Kerberos domain? nevermind then
    }

    cleanupWSCredentials(realm) {
        // skip this on remote ssh hosts, only set up ws hosts
        if (cockpit.transport.host !== "localhost")
            return Promise.resolve();

        const server_sw = find_detail(realm, "server-software");
        if (server_sw !== "ipa") {
            console.log("cleaning up ws credentials not supported for server software", server_sw);
            return Promise.resolve();
        }

        const kerberos = this.dbus_realmd.proxy(KERBEROS, realm.path);
        return kerberos.wait()
                .then(() => {
                    const helper = cockpit.manifests.system.libexecdir + "/cockpit-certificate-helper";
                    return cockpit.spawn([helper, "ipa", "cleanup", kerberos.RealmName],
                                         { superuser: "require", err: "message" })
                            .catch(ex => {
                                console.log("Failed to clean up SPN from /etc/cockpit/krb5.keytab:", JSON.stringify(ex));
                                return true;
                            });
                })
                .catch(() => true); // no Kerberos domain? nevermind then
    }

    allowHostnameChange() {
        return this.joined.length === 0;
    }

    installPackage() {
        if (this.install_realmd) {
            return install_dialog("realmd")
                    .then(() => {
                        this.install_realmd = false;
                        this.initProxy();
                        return true;
                    })
                    .catch(() => false); // dialog cancelled
        }

        return null;
    }
}

/*
 * The realmd dbus interface has an a(ss) Details
 * property. Lookup the right value for the given
 * field key.
 */
function find_detail(realm, field) {
    let result = null;
    if (realm && realm.Details) {
        realm.Details.forEach(value => {
            if (value[0] === field)
                result = value[1];
        });
    }
    return result;
}

const LeaveDialog = ({ realmd_client }) => {
    const Dialogs = useDialogs();
    const [expanded, setExpanded] = useState(false);
    const [pending, setPending] = useState(false);
    const [error, setError] = useState(null);
    const realm = realmd_client.joined[0];

    const onLeave = () => {
        setPending(true);
        realmd_client.leave(realm)
                .then(Dialogs.close)
                .catch(err => {
                    console.warn("Failed to leave domain:", err.toString());
                    setPending(false);
                    setError(err);
                });
    };

    return (
        <Modal id="realms-leave-dialog" isOpen position="top" variant="medium"
            onClose={Dialogs.close}
        >
            <ModalHeader title={ _("dialog-title", "Domain") } />
            <ModalBody>
                <DescriptionList isHorizontal>
                    <DescriptionListGroup>
                        <DescriptionListTerm>{ _("Domain") }</DescriptionListTerm>
                        <DescriptionListDescription id="realms-op-info-domain">
                            { realm && realm.Name }
                        </DescriptionListDescription>
                    </DescriptionListGroup>
                    <DescriptionListGroup>
                        <DescriptionListTerm>{ _("Login format") }</DescriptionListTerm>
                        <DescriptionListDescription id="realms-op-info-login-format">{
                            realm && realm.LoginFormats && realm.LoginFormats.length > 0
                                ? realm.LoginFormats[0].replace("%U", "username")
                                : null
                        }</DescriptionListDescription>
                    </DescriptionListGroup>
                    <DescriptionListGroup>
                        <DescriptionListTerm>{ _("Server software") }</DescriptionListTerm>
                        <DescriptionListDescription id="realms-op-info-server-sw">
                            { find_detail(realm, "server-software") }
                        </DescriptionListDescription>
                    </DescriptionListGroup>
                    <DescriptionListGroup>
                        <DescriptionListTerm>{ _("Client software") }</DescriptionListTerm>
                        <DescriptionListDescription id="realms-op-info-client-sw">
                            { find_detail(realm, "client-software") }
                        </DescriptionListDescription>
                    </DescriptionListGroup>
                </DescriptionList>

                <ExpandableSection toggleText={ _("Leave domain") } isExpanded={expanded}
                                    onToggle={ e => setExpanded(e) }>
                    <Alert variant="warning" isInline
                            title={ realm && realm.Name ? cockpit.format(_("Leave $0"), realm.Name) : _("Leave domain") }
                            actionLinks={
                                <Button variant="danger" id="realms-op-leave" isDisabled={pending} onClick={onLeave}>{ _("Leave domain") }</Button>
                            }>
                        { _("After leaving the domain, only users with local credentials will be able to log into this machine. This may also affect other services as DNS resolution settings and the list of trusted CAs may change.") }
                    </Alert>
                </ExpandableSection>
            </ModalBody>
            <ModalFooter>
                { error && <Alert variant="danger" isInline className="realms-op-error" title={error.toString()} /> }
                <Button variant="secondary" isDisabled={pending} onClick={Dialogs.close}>{ _("Close") }</Button>
            </ModalFooter>
        </Modal>);
};

let domainValidateTimeout;

const JoinDialog = ({ realmd_client }) => {
    const Dialogs = useDialogs();
    const [pending, setPending] = useState(false);
    const [address, setAddress] = useState("");
    const [addressValid, setAddressValid] = useState(null); // success, error, unsupported, default (for pending check)
    const [admin, setAdmin] = useState("");
    const [adminPassword, setAdminPassword] = useState("");
    const [realm, setRealm] = useState(null);
    const [kerberosMembership, setKerberosMembership] = useState(null);
    const [error, setError] = useState(null);
    const [diagnosticsExpanded, setDiagnosticsExpanded] = useState(false);

    const checkAddress = name => {
        setAddressValid("default");

        realmd_client.checkRealm(name)
                .then(reply => {
                    if (reply.result) {
                        setRealm(reply.realm);

                        // handle initial auto-detection
                        if (name === "") {
                            if (!address)
                                setAddress(reply.realm.Name);
                        }

                        if (reply.kerberos_membership && reply.kerberos_membership.valid) {
                            setAddressValid("success");
                            if (!admin && reply.kerberos_membership.SuggestedAdministrator)
                                setAdmin(reply.kerberos_membership.SuggestedAdministrator);
                            setKerberosMembership(reply.kerberos_membership);
                        } else {
                            setAddressValid("unsupported");
                        }
                    } else {
                        // error_detect will not show the validation error, but trigger data-discover=done
                        setAddressValid(name ? "error" : "error_detect");
                    }
                })
                .catch(err => console.error("checkRealm failed", JSON.stringify(err)));
    };

    const validateAddress = value => {
        setAddress(value);
        setAddressValid(null);
        window.clearTimeout(domainValidateTimeout);
        if (value)
            domainValidateTimeout = window.setTimeout(() => checkAddress(value), 1000);
    };

    const onJoin = () => {
        setError(null);
        setDiagnosticsExpanded(null);
        setPending(true);
        realmd_client.join(realm, kerberosMembership, admin, adminPassword)
                .then(Dialogs.close)
                .catch(err => {
                    setPending(false);
                    setError(err);
                });
    };

    // initial auto-detection of domain name
    useEffect(() => checkAddress(""), []); // eslint-disable-line react-hooks/exhaustive-deps

    const join_disabled = pending || addressValid !== "success" || !admin || !kerberosMembership;

    const DOMAIN_VALID_HELPER_TEXT = {
        default: _("Validating address"),
        success: _("Contacted domain"),
        error: _("Domain could not be contacted"),
        unsupported: _("Domain is not supported"),
    };

    const DOMAIN_VALID_HELPER_ICON = {
        default: <InProgressIcon />,
        success: <CheckIcon />,
        error: <ExclamationCircleIcon />,
        unsupported: <ExclamationCircleIcon />,
    };

    const domainHelperText = DOMAIN_VALID_HELPER_TEXT[addressValid];
    const domainHelperIcon = DOMAIN_VALID_HELPER_ICON[addressValid];

    let errorAlert;
    if (error) {
        const details = (error.diagnostics && diagnosticsExpanded)
            ? <CodeBlockCode className="realms-op-diagnostics">{error.diagnostics}</CodeBlockCode>
            : null;
        const actionLink = (error.diagnostics && !diagnosticsExpanded)
            ? <AlertActionLink onClick={ () => setDiagnosticsExpanded(true) }>{ _("Details") }</AlertActionLink>
            : null;
        errorAlert = <Alert variant="danger" isInline className="realms-op-error" actionLinks={actionLink} title={error.toString()}>{details}</Alert>;
    }

    return (
        <Modal id="realms-join-dialog" isOpen position="top" variant="medium"
            onClose={Dialogs.close}
        >
            <ModalHeader title={ _("dialog-title", "Join a domain") } />
            <ModalBody>
                <Form isHorizontal onSubmit={onJoin}>
                    <FormGroup label={ _("Domain address") } fieldId="realms-op-address" validated={addressValid}>
                        <TextInput id="realms-op-address" placeholder="domain.example.com"
                                   data-discover={ (!addressValid || addressValid === "default") ? null : "done" }
                                   autoComplete="url"
                                   value={address} onChange={(_event, value) => validateAddress(value)} isDisabled={pending} />
                    </FormGroup>

                    <FormGroup label={ _("Domain administrator name") } fieldId="realms-op-admin">
                        <TextInput id="realms-op-admin" placeholder="admin" value={admin} autoComplete="username" onChange={(_event, value) => setAdmin(value)} isDisabled={pending} />
                    </FormGroup>
                    <FormGroup label={ _("Domain administrator password") } fieldId="realms-op-admin-password">
                        <TextInput id="realms-op-admin-password" type="password" value={adminPassword} autoComplete="current-password" onChange={(_event, value) => setAdminPassword(value)} isDisabled={pending} />
                    </FormGroup>
                    <FormHelper fieldId="realms-op-address" helperText={domainHelperText} helperTextInvalid={addressValid === "error" && domainHelperText} icon={domainHelperIcon} />
                </Form>
            </ModalBody>
            <ModalFooter>
                { errorAlert }
                <Button variant="primary"
                        isDisabled={join_disabled}
                        onClick={onJoin}
                        isLoading={pending}
                        spinnerAriaValueText={ pending ? _("Joining") : null }>
                    { _("Join") }
                </Button>
                <Button variant="link" isDisabled={pending} onClick={Dialogs.close}>{ _("Cancel") }</Button>
                { pending && <span className="realms-op-wait-message">{ _("This may take a while") }</span> }
            </ModalFooter>
        </Modal>);
};

export const RealmButton = ({ realmd_client }) => {
    const Dialogs = useDialogs();
    useEvent(realmd_client, "changed");
    useEvent(superuser, "changed");

    const buttonTooltip = superuser.allowed ? realmd_client.error : _("Not permitted to configure realms");
    const buttonText = !realmd_client.install_realmd ? (realmd_client.joined.length ? realmd_client.joined.map(r => r.Name).join(", ") : _("Join domain")) : _("Install realmd support");
    const buttonDisabled = !superuser.allowed || (realmd_client.error && !realmd_client.install_realmd);

    const onClicked = () => {
        // handle on-demand realmd package install
        const install_promise = realmd_client.installPackage();
        if (install_promise) {
            // after installation, proceed to join dialog
            install_promise.then(success => success && onClicked());
            return null;
        }
        realmd_client.joined.length > 0
            ? Dialogs.show(<LeaveDialog realmd_client={realmd_client} />)
            : Dialogs.show(<JoinDialog realmd_client={realmd_client} />);
    };

    return (
        <Privileged allowed={ superuser.allowed && !realmd_client.error }
                    tooltipId="system_information_domain_tooltip"
                    excuse={ buttonTooltip }>
            <Button id="system_information_domain_button" variant="link"
                    onClick={onClicked}
                    isInline isDisabled={buttonDisabled} aria-label={buttonText}>
                { buttonText }
            </Button>
        </Privileged>);
};