mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 23:04:52 +09:00
allow to revoke host tokens from web ui (#511)
* allow to revoke / restore host access from web ui * refector to what is expected * remove core.admin check on HostViewPage.tsx * update core.host.revoke_token description
This commit is contained in:
1 parent
838170a9ff
commit
e2dea35e02
11 files changed
+120
-10
No files matched your search
@@ -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<lib.HostDetails[]> {
|
||||
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;
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof this.jsonSchema>) {
|
||||
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<typeof this.jsonSchema>) {
|
||||
return new this(json.hostId);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
host.ControllerConnectionEvent,
|
||||
host.PrepareControllerDisconnectRequest,
|
||||
host.SyncUserListsEvent,
|
||||
host.HostRevokeTokensRequest,
|
||||
|
||||
instance.InstanceDetailsGetRequest,
|
||||
instance.InstanceDetailsListRequest,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader title={host.name || String(hostId)} />
|
||||
let hostButtons = <Space> {
|
||||
account.hasPermission("core.host.revoke_access") &&
|
||||
<Popconfirm
|
||||
title={`Revoke tokens of ${host.name}?`}
|
||||
placement="bottomRight"
|
||||
okText="Revoke Tokens"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
control.send(new lib.HostRevokeTokensRequest(host.id!))
|
||||
.then(() => notify("Host tokens revoked"))
|
||||
.catch(notifyErrorHandler(`Error revoking tokens for host id:${host.id}`))
|
||||
}}
|
||||
>
|
||||
<Button danger>
|
||||
Revoke tokens
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
}
|
||||
</Space>
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader
|
||||
title={host.name || String(hostId)}
|
||||
extra={hostButtons}
|
||||
/>
|
||||
<Descriptions bordered size="small" column={{ xs: 1, sm: 2, xl: 4 }}>
|
||||
<Descriptions.Item label="Name">{host["name"]}</Descriptions.Item>
|
||||
<Descriptions.Item label="Connected">
|
||||
@@ -46,6 +74,12 @@ export default function HostViewPage() {
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Agent">{host["agent"]}</Descriptions.Item>
|
||||
<Descriptions.Item label="Version">{host["version"]}</Descriptions.Item>
|
||||
{
|
||||
host.tokenValidAfter &&
|
||||
<Descriptions.Item label="Tokens valid after:">
|
||||
{formatTimestamp(host.tokenValidAfter*1000)}
|
||||
</Descriptions.Item>
|
||||
}
|
||||
</Descriptions>
|
||||
{account.hasPermission("core.instance.list") && <>
|
||||
<Title level={5} style={{ marginTop: 16 }}>Instances</Title>
|
||||
|
||||
@@ -229,7 +229,7 @@ export default function HostsPage() {
|
||||
{host.connected ? "Connected" : "Disconnected"}
|
||||
</Tag>,
|
||||
sorter: (a, b) => Number(a.connected) - Number(b.connected),
|
||||
},
|
||||
}
|
||||
]}
|
||||
dataSource={hostList}
|
||||
rowKey={host => host.id}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user