diff --git a/README.md b/README.md index 7f1b47db..9b5bb0c5 100644 --- a/README.md +++ b/README.md @@ -276,9 +276,11 @@ Run the installer as described in the installation section and choose "Host only The URL is the same as what is needed to connect to the web interface. Then start the host, the first time it starts it prints a code and waits for the host to be approved: - Waiting for approval. Approve "my-host" with code ABC-DEF on the Hosts page of https://example.com/, or run: npx clusterioctl host approve ABC-DEF + Waiting for approval of "my-host" with code ABC-DEF, open this link to approve it: + https://example.com/hosts?approve=ABC-DEF + Or run: npx clusterioctl host approve ABC-DEF -Approve it in the web interface by clicking Add host on the Hosts page and approving the request with the same code, or with `npx clusterioctl host approve `. +Open the link to approve it in the web interface, where you can also change its name, or click Add host on the Hosts page and approve the request with the same code, or run `npx clusterioctl host approve `. The host then saves its connection details and starts up, it should show up in the Hosts list and be available for assigning and running instances on. Only approve a request if its code matches the one shown by the host, approving gives the host full access to the cluster. Approving requires the `core.host.approve` permission, which the Cluster Admin role has. diff --git a/packages/create/README.md b/packages/create/README.md index afbb84b2..7d8ed1d1 100644 --- a/packages/create/README.md +++ b/packages/create/README.md @@ -59,9 +59,12 @@ A new host has to be approved before it can join the cluster: 2. Start the host with `run-host.cmd`, `run-host.sh` or the systemd service in the `systemd` folder. The first time it starts it prints a code and waits for the host to be approved: - Waiting for approval. Approve "my-host" with code ABC-DEF on the Hosts page of https://example.com/, or run: npx clusterioctl host approve ABC-DEF + Waiting for approval of "my-host" with code ABC-DEF, open this link to approve it: + https://example.com/hosts?approve=ABC-DEF + Or run: npx clusterioctl host approve ABC-DEF -3. Approve the request by clicking Add host on the Hosts page of the web interface, or run `npx clusterioctl host approve ABC-DEF`. +3. Open the link to approve the request in the web interface, where its name can be changed first. + It can also be approved under Add host on the Hosts page, or with `npx clusterioctl host approve ABC-DEF`. Only approve a request if its code matches the one shown by the host. Approving requires the `core.host.approve` permission. diff --git a/packages/host/src/pairing.ts b/packages/host/src/pairing.ts index 6d320f4c..8bd41bc2 100644 --- a/packages/host/src/pairing.ts +++ b/packages/host/src/pairing.ts @@ -275,10 +275,11 @@ export async function ensurePaired( token: options.userToken, signal: options.signal, onPending: code => { - logger.info( - `Waiting for approval. Approve "${name}" with code ${code} on the Hosts page of ` + - `${controllerUrl}, or run: npx clusterioctl host approve ${code}` - ); + const approveUrl = new URL(`hosts?approve=${code}`, controllerUrl).href; + // The link is logged on its own so terminals can make it clickable without it wrapping. + logger.info(`Waiting for approval of "${name}" with code ${code}, open this link to approve it:`); + logger.info(approveUrl); + logger.info(`Or run: npx clusterioctl host approve ${code}`); }, onRetry: (err, delayMs) => { logger.warn(`Requesting to join failed: ${err.message}, trying again in ${Math.ceil(delayMs / 1000)}s`); diff --git a/packages/web_ui/src/components/HostsPage.tsx b/packages/web_ui/src/components/HostsPage.tsx index e31095a0..9be610d8 100644 --- a/packages/web_ui/src/components/HostsPage.tsx +++ b/packages/web_ui/src/components/HostsPage.tsx @@ -1,5 +1,8 @@ import React, { useContext, useState, useEffect } from "react"; -import { Badge, Button, Flex, Input, Modal, Space, Switch, Table, Tooltip, Typography } from "antd"; +import { useSearchParams } from "react-router-dom"; +import { + Badge, Button, Descriptions, Flex, Input, Modal, Result, Space, Spin, Switch, Table, Tooltip, Typography, +} from "antd"; import { ExclamationCircleOutlined, CopyOutlined, PlusOutlined } from "@ant-design/icons"; import * as lib from "@clusterio/lib"; @@ -200,6 +203,120 @@ function AddHostModal(props: { open: boolean, onClose: () => void }) { ; } +/** Format a code like the controller does, matching any case and without the dash */ +function normaliseCode(code: string) { + const plain = code.toUpperCase().replace(/[^0-9A-Z]/g, ""); + return `${plain.slice(0, 3)}-${plain.slice(3)}`; +} + +/** Dialog opened by the link a waiting host prints, for approving that host */ +function ApproveHostModal(props: { code: string, onClose: () => void }) { + const control = useContext(ControlContext); + const account = useAccount(); + const [requests, synced] = useHostPairings(); + const code = normaliseCode(props.code); + const request = requests.get(code); + const [name, setName] = useState(); + const [busy, setBusy] = useState(false); + const now = useNow(true); + const permitted = account.hasPermission("core.host.approve"); + const hostName = (name ?? request?.name ?? "").trim(); + + function approve() { + if (!request) { + return; + } + setBusy(true); + control.send( + new lib.HostPairingApproveRequest(request.code, hostName !== request.name ? hostName : undefined) + ).then(() => { + notify(`Approved host ${hostName}`, "success"); + props.onClose(); + }).catch( + notifyErrorHandler(`Error approving host ${request.name}`) + ).finally(() => { + setBusy(false); + }); + } + + function reject() { + if (!request) { + return; + } + setBusy(true); + control.send(new lib.HostPairingRejectRequest(request.code)).then(() => { + notify(`Rejected host ${request.name}`); + props.onClose(); + }).catch( + notifyErrorHandler(`Error rejecting host ${request.name}`) + ).finally(() => { + setBusy(false); + }); + } + + let content; + if (!permitted) { + content = ; + } else if (!request && !synced) { + content = ; + } else if (!request) { + content = No host is waiting with code {code}} + subTitle={ + "It may have been approved or rejected already, or stopped waiting. " + + "A host that is still waiting shows a new code." + } + />; + } else { + content = <> + + A host is asking to join the cluster. + Only approve it if the code below matches the code shown by the host, + an approved host has full access to the cluster. + + + {request.code} + + + {request.remoteAddress} + {request.version && {request.version}} + + {formatDuration(Math.max(0, now - request.createdAtMs))} + + + Name + { setName(event.target.value); }} + onPressEnter={approve} + style={{ margin: "4px 0 24px" }} + /> + + + + + ; + } + + return + {content} + ; +} + function AddHostButton() { const [open, setOpen] = useState(false); const [requests] = useHostPairings(); @@ -266,8 +383,19 @@ export default function HostsPage() { }); const nameSearch = useColumnSearch(tableState, "name", host => host.name, "Search hosts"); const rowNav = useRowNavigation(); + const [params, setParams] = useSearchParams(); + const approveCode = params.get("approve"); + + function closeApprove() { + setParams(current => { + const next = new URLSearchParams(current); + next.delete("approve"); + return next; + }, { replace: true }); + } return + {approveCode !== null && } : undefined}