diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index b87e0255..487c4c54 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -87,6 +87,7 @@ export default class ControlConnection extends BaseConnection { this.handle(lib.ControllerConfigGetRequest, this.handleControllerConfigGetRequest.bind(this)); this.handle(lib.ControllerConfigSetFieldRequest, this.handleControllerConfigSetFieldRequest.bind(this)); this.handle(lib.ControllerConfigSetPropRequest, this.handleControllerConfigSetPropRequest.bind(this)); + this.handle(lib.HostRevokeTokensRequest, this.handleHostRevokeTokensRequest.bind(this)); this.handle(lib.HostListRequest, this.handleHostListRequest.bind(this)); this.handle(lib.HostSetSubscriptionsRequest, this.handleHostSetSubscriptionsRequest.bind(this)); this.handle(lib.HostGenerateTokenRequest, this.handleHostGenerateTokenRequest.bind(this)); @@ -201,6 +202,22 @@ export default class ControlConnection extends BaseConnection { this._controller.config.setProp(field, prop, value, "control"); } + async handleHostRevokeTokensRequest(request: lib.HostRevokeTokensRequest) { + const host = this._controller.hosts?.get(request.hostId); + if (!host) { + throw new Error(`Unknown host id (${request.hostId})`); + } + + host.token_valid_after = Math.floor(Date.now() / 1000); + + const hostConnection = this._controller.wsServer.hostConnections.get(request.hostId); + if (hostConnection) { + hostConnection.connector.terminate(); + } + + this._controller.hostUpdated(host as HostInfo); + } + async handleHostListRequest(): Promise { let list = []; for (let host of this._controller.hosts!.values()) { @@ -211,6 +228,7 @@ export default class ControlConnection extends BaseConnection { host.id, this._controller.wsServer.hostConnections.has(host.id), host.public_address, + host.token_valid_after, )); } return list; diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index f69b102b..b6493704 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -573,6 +573,7 @@ export default class Controller { host.id, this.wsServer.hostConnections.has(host.id), host.public_address === null ? undefined : host.public_address, + host.token_valid_after, ); for (let controlConnection of this.wsServer.controlConnections.values()) { diff --git a/packages/controller/src/HostConnection.ts b/packages/controller/src/HostConnection.ts index b1f83e69..be4c4413 100644 --- a/packages/controller/src/HostConnection.ts +++ b/packages/controller/src/HostConnection.ts @@ -15,6 +15,7 @@ export interface HostInfo { version: string; plugins: [number, any][]; public_address: string | undefined; + token_valid_after?: number; } /** @@ -45,6 +46,8 @@ export default class HostConnection extends BaseConnection { this.plugins = new Map(Object.entries(registerData.plugins)); this._checkPluginVersions(); + let currentHostInfo = this._controller.hosts!.get(this._id) + this._controller.hosts!.set(this._id, { agent: this._agent, id: this._id, @@ -52,6 +55,7 @@ export default class HostConnection extends BaseConnection { version: this._version, public_address: registerData.publicAddress, plugins: registerData.plugins, + token_valid_after: currentHostInfo?.token_valid_after, }); for (let event of ["connect", "drop", "resume", "close"] as const) { diff --git a/packages/controller/src/WsServer.ts b/packages/controller/src/WsServer.ts index 75b8f73d..e52e267e 100644 --- a/packages/controller/src/WsServer.ts +++ b/packages/controller/src/WsServer.ts @@ -240,6 +240,11 @@ ${err.stack}` if ((tokenPayload.host !== undefined ? tokenPayload.host : tokenPayload.slave) !== data.id) { throw new Error("missmatched host id"); } + + let host = this.controller.hosts!.get(data.id); + if (!tokenPayload.iat || tokenPayload.iat < (host?.token_valid_after??0)) { + throw new Error("invalid token"); + } } catch (err: any) { logger.verbose(`WsServer | authentication failed for ${req.socket.remoteAddress}`); wsRejectedConnectionsCounter.inc(); diff --git a/packages/controller/src/routes.ts b/packages/controller/src/routes.ts index 0a9d0e95..4e506313 100644 --- a/packages/controller/src/routes.ts +++ b/packages/controller/src/routes.ts @@ -147,12 +147,22 @@ function validateHostToken(req: Request, res: Response, next: any) { } try { - jwt.verify( + const tokenPayload = jwt.verify( token, Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"), { audience: "host" } ); + if (typeof tokenPayload === "string") { + throw new Error("unexpected JsonWebToken type"); + } + let host = (req.app.locals.controller as Controller).hosts!.get(tokenPayload.host); + if (!host) { + throw new Error("invalid host"); + } + if (!tokenPayload.iat || tokenPayload.iat < (host.token_valid_after??0)) { + throw new Error("invalid token"); + } } catch (err) { res.sendStatus(401); return; diff --git a/packages/lib/src/data/messages_host.ts b/packages/lib/src/data/messages_host.ts index c271a3b9..8f34ae35 100644 --- a/packages/lib/src/data/messages_host.ts +++ b/packages/lib/src/data/messages_host.ts @@ -1,6 +1,5 @@ import { Type, Static } from "@sinclair/typebox"; -import { jsonArray } from "./composites"; - +import { jsonArray, StringEnum } from "./composites"; export class HostDetails { constructor( @@ -10,6 +9,7 @@ export class HostDetails { public id: number, public connected: boolean, public publicAddress?: string, + public tokenValidAfter?: number, ) { } static jsonSchema = Type.Object({ @@ -19,10 +19,11 @@ export class HostDetails { "id": Type.Integer(), "connected": Type.Boolean(), "publicAddress": Type.Optional(Type.String()), + "tokenValidAfter": Type.Optional(Type.Number()), }); static fromJSON(json: Static) { - return new this(json.agent, json.version, json.name, json.id, json.connected, json.publicAddress); + return new this(json.agent, json.version, json.name, json.id, json.connected, json.publicAddress, json.tokenValidAfter); } } @@ -155,3 +156,23 @@ export class SyncUserListsEvent { }; } } + +export class HostRevokeTokensRequest { + declare["constructor"]: typeof HostRevokeTokensRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.host.revoke_token" as const; + + constructor( + public hostId: number, + ) { } + + static jsonSchema = Type.Object({ + "hostId": Type.Number(), + }); + + static fromJSON(json: Static) { + return new this(json.hostId); + } +} diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index b6161e10..3c3400d6 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -34,6 +34,7 @@ export const dataClasses: (RequestClass | EventClass) host.ControllerConnectionEvent, host.PrepareControllerDisconnectRequest, host.SyncUserListsEvent, + host.HostRevokeTokensRequest, instance.InstanceDetailsGetRequest, instance.InstanceDetailsListRequest, diff --git a/packages/lib/src/users.ts b/packages/lib/src/users.ts index fd2469fc..5990db08 100644 --- a/packages/lib/src/users.ts +++ b/packages/lib/src/users.ts @@ -375,6 +375,11 @@ definePermission({ title: "Generate host token", description: "Generate tokens for hosts to connect to the cluster with.", }); +definePermission({ + name: "core.host.revoke_token", + title: "Revoke host token", + description: "Revoke access token and terminate existing connection for hosts.", +}); definePermission({ name: "core.host.create_config", title: "Create host config", diff --git a/packages/web_ui/src/components/HostViewPage.tsx b/packages/web_ui/src/components/HostViewPage.tsx index 5feee10b..1d2524db 100644 --- a/packages/web_ui/src/components/HostViewPage.tsx +++ b/packages/web_ui/src/components/HostViewPage.tsx @@ -1,21 +1,27 @@ -import React from "react"; -import { Descriptions, Spin, Tag, Typography } from "antd"; +import React, { useContext } from "react"; +import { Descriptions, Spin, Tag, Typography, Button, Space, Modal, Popconfirm } from "antd"; import { useParams } from "react-router-dom"; +import * as lib from "@clusterio/lib"; +import notify, { notifyErrorHandler } from "../util/notify"; +import type { Control } from "../util/websocket"; +import ControlContext from "./ControlContext"; import InstanceList from "./InstanceList"; import LogConsole from "./LogConsole"; import { useAccount } from "../model/account"; import { useInstanceList } from "../model/instance"; -import { useHost } from "../model/host"; +import { HostState, useHost } from "../model/host"; import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; +import { formatTimestamp } from "../util/time_format"; const { Title } = Typography; export default function HostViewPage() { let params = useParams(); let hostId = Number(params.id); + let control = useContext(ControlContext); let account = useAccount(); let [instanceList] = useInstanceList(); let [host] = useHost(hostId); @@ -34,9 +40,31 @@ export default function HostViewPage() { ; } - return - + let hostButtons = { + account.hasPermission("core.host.revoke_access") && + { + control.send(new lib.HostRevokeTokensRequest(host.id!)) + .then(() => notify("Host tokens revoked")) + .catch(notifyErrorHandler(`Error revoking tokens for host id:${host.id}`)) + }} + > + + + } + + return + {host["name"]} @@ -46,6 +74,12 @@ export default function HostViewPage() { {host["agent"]} {host["version"]} + { + host.tokenValidAfter && + + {formatTimestamp(host.tokenValidAfter*1000)} + + } {account.hasPermission("core.instance.list") && <> Instances diff --git a/packages/web_ui/src/components/HostsPage.tsx b/packages/web_ui/src/components/HostsPage.tsx index 29b987c8..c6123351 100644 --- a/packages/web_ui/src/components/HostsPage.tsx +++ b/packages/web_ui/src/components/HostsPage.tsx @@ -229,7 +229,7 @@ export default function HostsPage() { {host.connected ? "Connected" : "Disconnected"} , sorter: (a, b) => Number(a.connected) - Number(b.connected), - }, + } ]} dataSource={hostList} rowKey={host => host.id} diff --git a/packages/web_ui/src/util/time_format.ts b/packages/web_ui/src/util/time_format.ts index 86eef98b..e071a6ec 100644 --- a/packages/web_ui/src/util/time_format.ts +++ b/packages/web_ui/src/util/time_format.ts @@ -30,3 +30,14 @@ export function formatDuration(ms: number): string { result += formatSecond(Math.round(ms / 1e3)); return result; } + +export function formatTimestamp(timestamp: number): string { + const options = { + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + } as const; + return new Intl.DateTimeFormat(undefined, options).format(new Date(timestamp)); +}