Line data Source code
1 : /*
2 : * Copyright (C) 2021 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 92 : import React, { useState, useEffect } from 'react';
7 :
8 : import { Alert, AlertActionLink } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
9 : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
10 : import { CodeBlockCode } from "@patternfly/react-core/dist/esm/components/CodeBlock/index.js";
11 : import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
12 : import { ExpandableSection } from "@patternfly/react-core/dist/esm/components/ExpandableSection/index.js";
13 : import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js";
14 : import {
15 : Modal, ModalBody, ModalFooter, ModalHeader
16 : } from '@patternfly/react-core/dist/esm/components/Modal/index.js';
17 : import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js";
18 : import { CheckIcon, ExclamationCircleIcon, InProgressIcon } from "@patternfly/react-icons";
19 :
20 92 : import cockpit from "cockpit";
21 : import { Privileged } from "cockpit-components-privileged";
22 : import { superuser } from "superuser";
23 : import { useEvent } from "hooks.js";
24 : import { FormHelper } from "cockpit-components-form-helper";
25 : import { install_dialog } from "cockpit-components-install-dialog.jsx";
26 : import { useDialogs } from "dialogs.jsx";
27 : import { getPackageManager } from 'packagemanager';
28 :
29 : import "./realmd.scss";
30 :
31 92 : const _ = cockpit.gettext;
32 :
33 92 : const MANAGER = "/org/freedesktop/realmd";
34 92 : const PROVIDER = "org.freedesktop.realmd.Provider";
35 92 : const KERBEROS = "org.freedesktop.realmd.Kerberos";
36 92 : const KERBEROS_MEMBERSHIP = "org.freedesktop.realmd.KerberosMembership";
37 92 : const REALM = "org.freedesktop.realmd.Realm";
38 :
39 92 : export class RealmdClient {
40 92 : constructor() {
41 92 : this.onClose = this.onClose.bind(this);
42 92 : this.onRealmsChanged = this.onRealmsChanged.bind(this);
43 92 : this.joined = [];
44 92 : this.detected = null;
45 92 : this.error = null;
46 92 : this.install_realmd = false;
47 92 : this.callSerial = 1;
48 :
49 92 : cockpit.event_target(this);
50 92 : this.initProxy();
51 90 : superuser.addEventListener("changed", () => this.initProxy());
52 92 : }
53 :
54 15 : onClose(ev, options) {
55 5 : if (options.problem === "not-found") {
56 : // see if we can install it
57 2 : getPackageManager().then(() => {
58 2 : this.error = _("Joining a domain requires installation of realmd");
59 2 : this.install_realmd = true;
60 2 : this.dispatchEvent("changed");
61 0 : }).catch(exc => {
62 0 : this.error = _("Cannot join a domain because realmd is not available on this system");
63 0 : this.dispatchEvent("changed");
64 0 : });
65 3 : } else {
66 13 : this.error = cockpit.message(options);
67 13 : this.dispatchEvent("changed");
68 13 : }
69 15 : this.dbus_realmd.removeEventListener("close", this.onClose);
70 15 : this.dbus_realmd.close();
71 15 : this.dbus_realmd = null;
72 15 : }
73 :
74 3 : onRealmsChanged() {
75 3 : this.joined = [];
76 3 : for (const path in this.realms) {
77 3 : const realm = this.realms[path];
78 3 : if (realm.Configured)
79 2 : this.joined.push(realm);
80 3 : }
81 :
82 3 : this.dispatchEvent("changed");
83 3 : }
84 :
85 92 : initProxy() {
86 : // Ignore intermediate states of superuser.allowed to
87 : // avoid initializing the proxy twice during page
88 : // load. This is less wasteful and helps the tests avoid
89 : // race conditions. We are guaranteed to see a real "true"
90 : // or "false" value eventually.
91 : //
92 92 : if (superuser.allowed === null)
93 92 : return;
94 :
95 24 : if (this.dbus_realmd) {
96 24 : this.dbus_realmd.removeEventListener("close", this.onClose);
97 24 : this.dbus_realmd.close();
98 24 : }
99 :
100 90 : this.error = null;
101 90 : this.dbus_realmd = cockpit.dbus("org.freedesktop.realmd", { superuser: "try" });
102 90 : this.dbus_realmd.watch(MANAGER);
103 90 : this.dbus_realmd.addEventListener("close", this.onClose);
104 :
105 90 : this.realms = this.dbus_realmd.proxies(REALM, MANAGER);
106 90 : this.realms.addEventListener("changed", this.onRealmsChanged);
107 92 : }
108 :
109 5 : checkRealm(name) {
110 5 : return this.dbus_realmd.call(MANAGER, PROVIDER, "Discover", [name, {}])
111 4 : .then(([relevance, realms]) => {
112 4 : if (realms.length == 0)
113 3 : return { result: false };
114 :
115 : // the first realm
116 3 : const path = realms[0];
117 3 : const realm = this.dbus_realmd.proxy(REALM, path);
118 3 : const kerberos_membership = this.dbus_realmd.proxy(KERBEROS_MEMBERSHIP, path);
119 3 : return Promise.allSettled([realm.wait(), kerberos_membership.wait()])
120 3 : .then(() => { return { result: true, realm, kerberos_membership } });
121 4 : });
122 5 : }
123 :
124 2 : join(realm, kerberosMembership, user, password) {
125 2 : const id = "cockpit-" + this.callSerial;
126 2 : this.callSerial += 1;
127 2 : let diagnostics = "";
128 :
129 2 : const options = { operation: cockpit.variant('s', id) };
130 2 : const diagnostics_sub = this.dbus_realmd.subscribe({ member: "Diagnostics" }, (path, iface, signal, args) => {
131 2 : if (args[1] === id) {
132 2 : diagnostics += args[0];
133 2 : }
134 2 : });
135 :
136 2 : if (kerberosMembership.valid) {
137 2 : const credentials = ["password", "administrator", cockpit.variant('(ss)', [user, password])];
138 2 : return kerberosMembership.call("Join", [credentials, options])
139 2 : .then(() => this.installWSCredentials(realm, user, password))
140 2 : .catch(ex => {
141 2 : if (ex.name == "org.freedesktop.realmd.Error.Cancelled")
142 0 : return Promise.resolve();
143 2 : ex.diagnostics = diagnostics;
144 2 : return Promise.reject(ex);
145 2 : })
146 2 : .finally(() => diagnostics_sub.remove());
147 0 : } else {
148 0 : return Promise.reject(new Error(_("Joining this domain is not supported")));
149 0 : }
150 2 : }
151 :
152 2 : leave(realm) {
153 2 : return this.cleanupWSCredentials(realm)
154 2 : .then(() => realm.Deconfigure({ operation: cockpit.variant('s', "cockpit-leave-domain") }));
155 2 : }
156 :
157 2 : installWSCredentials(realm, user, password) {
158 : // skip this on remote ssh hosts, only set up ws hosts
159 2 : if (cockpit.transport.host !== "localhost")
160 0 : return true;
161 :
162 2 : const server_sw = find_detail(realm, "server-software");
163 1 : if (server_sw !== "ipa") {
164 1 : console.log("installing ws credentials not supported for server software", server_sw);
165 1 : return true;
166 1 : }
167 :
168 1 : const kerberos = this.dbus_realmd.proxy(KERBEROS, realm.path);
169 1 : return kerberos.wait()
170 1 : .then(() => {
171 1 : const helper = cockpit.manifests.system.libexecdir + "/cockpit-certificate-helper";
172 1 : const proc = cockpit.spawn([helper, "ipa", "request", kerberos.RealmName, user],
173 1 : { superuser: "require", err: "message" });
174 1 : proc.input(password);
175 0 : proc.catch(ex => console.warn("Failed to run", helper, "ipa request:", ex.toString()));
176 1 : return proc;
177 1 : })
178 0 : .catch(() => true); // no Kerberos domain? nevermind then
179 2 : }
180 :
181 2 : cleanupWSCredentials(realm) {
182 : // skip this on remote ssh hosts, only set up ws hosts
183 2 : if (cockpit.transport.host !== "localhost")
184 0 : return Promise.resolve();
185 :
186 2 : const server_sw = find_detail(realm, "server-software");
187 1 : if (server_sw !== "ipa") {
188 1 : console.log("cleaning up ws credentials not supported for server software", server_sw);
189 1 : return Promise.resolve();
190 1 : }
191 :
192 1 : const kerberos = this.dbus_realmd.proxy(KERBEROS, realm.path);
193 1 : return kerberos.wait()
194 1 : .then(() => {
195 1 : const helper = cockpit.manifests.system.libexecdir + "/cockpit-certificate-helper";
196 1 : return cockpit.spawn([helper, "ipa", "cleanup", kerberos.RealmName],
197 1 : { superuser: "require", err: "message" })
198 0 : .catch(ex => {
199 0 : console.log("Failed to clean up SPN from /etc/cockpit/krb5.keytab:", JSON.stringify(ex));
200 0 : return true;
201 0 : });
202 1 : })
203 0 : .catch(() => true); // no Kerberos domain? nevermind then
204 2 : }
205 :
206 60 : allowHostnameChange() {
207 60 : return this.joined.length === 0;
208 60 : }
209 :
210 5 : installPackage() {
211 2 : if (this.install_realmd) {
212 2 : return install_dialog("realmd")
213 2 : .then(() => {
214 2 : this.install_realmd = false;
215 2 : this.initProxy();
216 2 : return true;
217 2 : })
218 0 : .catch(() => false); // dialog cancelled
219 2 : }
220 :
221 5 : return null;
222 5 : }
223 92 : }
224 :
225 : /*
226 : * The realmd dbus interface has an a(ss) Details
227 : * property. Lookup the right value for the given
228 : * field key.
229 : */
230 2 : function find_detail(realm, field) {
231 2 : let result = null;
232 2 : if (realm && realm.Details) {
233 2 : realm.Details.forEach(value => {
234 2 : if (value[0] === field)
235 2 : result = value[1];
236 2 : });
237 2 : }
238 2 : return result;
239 2 : }
240 :
241 2 : const LeaveDialog = ({ realmd_client }) => {
242 2 : const Dialogs = useDialogs();
243 2 : const [expanded, setExpanded] = useState(false);
244 2 : const [pending, setPending] = useState(false);
245 2 : const [error, setError] = useState(null);
246 2 : const realm = realmd_client.joined[0];
247 :
248 2 : const onLeave = () => {
249 2 : setPending(true);
250 2 : realmd_client.leave(realm)
251 2 : .then(Dialogs.close)
252 0 : .catch(err => {
253 0 : console.warn("Failed to leave domain:", err.toString());
254 0 : setPending(false);
255 0 : setError(err);
256 0 : });
257 2 : };
258 :
259 2 : return (
260 2 : <Modal id="realms-leave-dialog" isOpen position="top" variant="medium"
261 2 : onClose={Dialogs.close}
262 : >
263 2 : <ModalHeader title={ _("dialog-title", "Domain") } />
264 2 : <ModalBody>
265 2 : <DescriptionList isHorizontal>
266 2 : <DescriptionListGroup>
267 2 : <DescriptionListTerm>{ _("Domain") }</DescriptionListTerm>
268 2 : <DescriptionListDescription id="realms-op-info-domain">
269 2 : { realm && realm.Name }
270 2 : </DescriptionListDescription>
271 2 : </DescriptionListGroup>
272 2 : <DescriptionListGroup>
273 2 : <DescriptionListTerm>{ _("Login format") }</DescriptionListTerm>
274 2 : <DescriptionListDescription id="realms-op-info-login-format">{
275 2 : realm && realm.LoginFormats && realm.LoginFormats.length > 0
276 2 : ? realm.LoginFormats[0].replace("%U", "username")
277 0 : : null
278 2 : }</DescriptionListDescription>
279 2 : </DescriptionListGroup>
280 2 : <DescriptionListGroup>
281 2 : <DescriptionListTerm>{ _("Server software") }</DescriptionListTerm>
282 2 : <DescriptionListDescription id="realms-op-info-server-sw">
283 2 : { find_detail(realm, "server-software") }
284 2 : </DescriptionListDescription>
285 2 : </DescriptionListGroup>
286 2 : <DescriptionListGroup>
287 2 : <DescriptionListTerm>{ _("Client software") }</DescriptionListTerm>
288 2 : <DescriptionListDescription id="realms-op-info-client-sw">
289 2 : { find_detail(realm, "client-software") }
290 2 : </DescriptionListDescription>
291 2 : </DescriptionListGroup>
292 2 : </DescriptionList>
293 :
294 2 : <ExpandableSection toggleText={ _("Leave domain") } isExpanded={expanded}
295 2 : onToggle={ e => setExpanded(e) }>
296 2 : <Alert variant="warning" isInline
297 0 : title={ realm && realm.Name ? cockpit.format(_("Leave $0"), realm.Name) : _("Leave domain") }
298 2 : actionLinks={
299 2 : <Button variant="danger" id="realms-op-leave" isDisabled={pending} onClick={onLeave}>{ _("Leave domain") }</Button>
300 : }>
301 2 : { _("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.") }
302 2 : </Alert>
303 2 : </ExpandableSection>
304 2 : </ModalBody>
305 2 : <ModalFooter>
306 0 : { error && <Alert variant="danger" isInline className="realms-op-error" title={error.toString()} /> }
307 2 : <Button variant="secondary" isDisabled={pending} onClick={Dialogs.close}>{ _("Close") }</Button>
308 2 : </ModalFooter>
309 2 : </Modal>);
310 2 : };
311 :
312 92 : let domainValidateTimeout;
313 :
314 5 : const JoinDialog = ({ realmd_client }) => {
315 5 : const Dialogs = useDialogs();
316 5 : const [pending, setPending] = useState(false);
317 5 : const [address, setAddress] = useState("");
318 5 : const [addressValid, setAddressValid] = useState(null); // success, error, unsupported, default (for pending check)
319 5 : const [admin, setAdmin] = useState("");
320 5 : const [adminPassword, setAdminPassword] = useState("");
321 5 : const [realm, setRealm] = useState(null);
322 5 : const [kerberosMembership, setKerberosMembership] = useState(null);
323 5 : const [error, setError] = useState(null);
324 5 : const [diagnosticsExpanded, setDiagnosticsExpanded] = useState(false);
325 :
326 5 : const checkAddress = name => {
327 5 : setAddressValid("default");
328 :
329 5 : realmd_client.checkRealm(name)
330 4 : .then(reply => {
331 3 : if (reply.result) {
332 3 : setRealm(reply.realm);
333 :
334 : // handle initial auto-detection
335 3 : if (name == "") {
336 3 : if (!address)
337 3 : setAddress(reply.realm.Name);
338 3 : }
339 :
340 2 : if (reply.kerberos_membership && reply.kerberos_membership.valid) {
341 2 : setAddressValid("success");
342 2 : if (!admin && reply.kerberos_membership.SuggestedAdministrator)
343 2 : setAdmin(reply.kerberos_membership.SuggestedAdministrator);
344 2 : setKerberosMembership(reply.kerberos_membership);
345 0 : } else {
346 1 : setAddressValid("unsupported");
347 1 : }
348 2 : } else {
349 : // error_detect will not show the validation error, but trigger data-discover=done
350 0 : setAddressValid(name ? "error" : "error_detect");
351 3 : }
352 4 : })
353 1 : .catch(err => console.error("checkRealm failed", JSON.stringify(err)));
354 5 : };
355 :
356 3 : const validateAddress = value => {
357 3 : setAddress(value);
358 3 : setAddressValid(null);
359 3 : window.clearTimeout(domainValidateTimeout);
360 3 : if (value)
361 3 : domainValidateTimeout = window.setTimeout(() => checkAddress(value), 1000);
362 3 : };
363 :
364 2 : const onJoin = () => {
365 2 : setError(null);
366 2 : setDiagnosticsExpanded(null);
367 2 : setPending(true);
368 2 : realmd_client.join(realm, kerberosMembership, admin, adminPassword)
369 2 : .then(Dialogs.close)
370 2 : .catch(err => {
371 2 : setPending(false);
372 2 : setError(err);
373 2 : });
374 2 : };
375 :
376 : // initial auto-detection of domain name
377 5 : useEffect(() => checkAddress(""), []); // eslint-disable-line react-hooks/exhaustive-deps
378 :
379 2 : const join_disabled = pending || addressValid !== "success" || !admin || !kerberosMembership;
380 :
381 5 : const DOMAIN_VALID_HELPER_TEXT = {
382 5 : default: _("Validating address"),
383 5 : success: _("Contacted domain"),
384 5 : error: _("Domain could not be contacted"),
385 5 : unsupported: _("Domain is not supported"),
386 5 : };
387 :
388 5 : const DOMAIN_VALID_HELPER_ICON = {
389 5 : default: <InProgressIcon />,
390 5 : success: <CheckIcon />,
391 5 : error: <ExclamationCircleIcon />,
392 5 : unsupported: <ExclamationCircleIcon />,
393 5 : };
394 :
395 5 : const domainHelperText = DOMAIN_VALID_HELPER_TEXT[addressValid];
396 5 : const domainHelperIcon = DOMAIN_VALID_HELPER_ICON[addressValid];
397 :
398 5 : let errorAlert;
399 2 : if (error) {
400 2 : const details = (error.diagnostics && diagnosticsExpanded)
401 2 : ? <CodeBlockCode className="realms-op-diagnostics">{error.diagnostics}</CodeBlockCode>
402 2 : : null;
403 2 : const actionLink = (error.diagnostics && !diagnosticsExpanded)
404 2 : ? <AlertActionLink onClick={ () => setDiagnosticsExpanded(true) }>{ _("Details") }</AlertActionLink>
405 2 : : null;
406 2 : errorAlert = <Alert variant="danger" isInline className="realms-op-error" actionLinks={actionLink} title={error.toString()}>{details}</Alert>;
407 2 : }
408 :
409 5 : return (
410 5 : <Modal id="realms-join-dialog" isOpen position="top" variant="medium"
411 5 : onClose={Dialogs.close}
412 : >
413 5 : <ModalHeader title={ _("dialog-title", "Join a domain") } />
414 5 : <ModalBody>
415 5 : <Form isHorizontal onSubmit={onJoin}>
416 5 : <FormGroup label={ _("Domain address") } fieldId="realms-op-address" validated={addressValid}>
417 5 : <TextInput id="realms-op-address" placeholder="domain.example.com"
418 4 : data-discover={ (!addressValid || addressValid == "default") ? null : "done" }
419 5 : autoComplete="url"
420 3 : value={address} onChange={(_event, value) => validateAddress(value)} isDisabled={pending} />
421 5 : </FormGroup>
422 :
423 5 : <FormGroup label={ _("Domain administrator name") } fieldId="realms-op-admin">
424 1 : <TextInput id="realms-op-admin" placeholder="admin" value={admin} autoComplete="username" onChange={(_event, value) => setAdmin(value)} isDisabled={pending} />
425 5 : </FormGroup>
426 5 : <FormGroup label={ _("Domain administrator password") } fieldId="realms-op-admin-password">
427 3 : <TextInput id="realms-op-admin-password" type="password" value={adminPassword} autoComplete="current-password" onChange={(_event, value) => setAdminPassword(value)} isDisabled={pending} />
428 5 : </FormGroup>
429 2 : <FormHelper fieldId="realms-op-address" helperText={domainHelperText} helperTextInvalid={addressValid == "error" && domainHelperText} icon={domainHelperIcon} />
430 5 : </Form>
431 5 : </ModalBody>
432 5 : <ModalFooter>
433 5 : { errorAlert }
434 5 : <Button variant="primary"
435 5 : isDisabled={join_disabled}
436 5 : onClick={onJoin}
437 5 : isLoading={pending}
438 2 : spinnerAriaValueText={ pending ? _("Joining") : null }>
439 5 : { _("Join") }
440 5 : </Button>
441 5 : <Button variant="link" isDisabled={pending} onClick={Dialogs.close}>{ _("Cancel") }</Button>
442 2 : { pending && <span className="realms-op-wait-message">{ _("This may take a while") }</span> }
443 5 : </ModalFooter>
444 5 : </Modal>);
445 5 : };
446 :
447 92 : export const RealmButton = ({ realmd_client }) => {
448 92 : const Dialogs = useDialogs();
449 92 : useEvent(realmd_client, "changed");
450 92 : useEvent(superuser, "changed");
451 :
452 65 : const buttonTooltip = superuser.allowed ? realmd_client.error : _("Not permitted to configure realms");
453 2 : const buttonText = !realmd_client.install_realmd ? (realmd_client.joined.length ? realmd_client.joined.map(r => r.Name).join(", ") : _("Join domain")) : _("Install realmd support");
454 18 : const buttonDisabled = !superuser.allowed || (realmd_client.error && !realmd_client.install_realmd);
455 :
456 5 : const onClicked = () => {
457 : // handle on-demand realmd package install
458 5 : const install_promise = realmd_client.installPackage();
459 2 : if (install_promise) {
460 : // after installation, proceed to join dialog
461 2 : install_promise.then(success => success && onClicked());
462 2 : return null;
463 2 : }
464 5 : realmd_client.joined.length > 0
465 2 : ? Dialogs.show(<LeaveDialog realmd_client={realmd_client} />)
466 5 : : Dialogs.show(<JoinDialog realmd_client={realmd_client} />);
467 5 : };
468 :
469 92 : return (
470 65 : <Privileged allowed={ superuser.allowed && !realmd_client.error }
471 92 : tooltipId="system_information_domain_tooltip"
472 92 : excuse={ buttonTooltip }>
473 92 : <Button id="system_information_domain_button" variant="link"
474 92 : onClick={onClicked}
475 92 : isInline isDisabled={buttonDisabled} aria-label={buttonText}>
476 92 : { buttonText }
477 92 : </Button>
478 92 : </Privileged>);
479 92 : };
|