Address review of host pairing

Revoking a host now unassigns its instances instead of being refused
while it has any, and removes its system metrics.

Approving and revoking no longer force a save. Hosts are saved by
autosave and on exit like everything else, which also removes the
approving state from pairing requests.

The approval code and link are logged as warnings so they show up in
the systemd service, which only prints warnings and above. The pairing
limits are now the controller.host_pairing_timeout and
controller.host_pairing_max_pending config values, 0 removes the limit.

Subscribers take an optional permitted function, used for the pairing
requests instead of overrides in a subclass.
This commit is contained in:
Danielv123 committed 2026-10-31 16:31:45 +01:00
1 parent e5701a8dae
commit 49a0b27d29
29 files changed
+356 -418

No files matched your search

+2 -2
View File
@@ -287,8 +287,8 @@ Only approve a request if its code matches the one shown by the host, approving
Approving requires the `core.host.approve` permission, which the Cluster Admin role has.
If you have that permission you can skip the wait by starting the host with `npx clusteriohost run --user-token <token>` and your own authentication token, it is only used to approve the host and is not saved.
To remove a host from the cluster, unassign its instances and click Revoke host access on the page for the host in the web interface, or run `npx clusterioctl host revoke <host>`.
This disconnects the host and its saved connection details stop working.
To remove a host from the cluster, click Revoke host access on the page for the host in the web interface, or run `npx clusterioctl host revoke <host>`.
This disconnects the host, its instances become unassigned and its saved connection details stop working.
To add the computer back to the cluster start the host again, it asks for approval again.
+18 -1
View File
@@ -48,6 +48,23 @@ Must be greater than 0.
Defaults to 60.
### controller.host_pairing_timeout
Time in seconds a host can wait for approval before its request expires, after which the host requests again with a new code.
Set to 0 for no limit.
Defaults to 3600.
### controller.host_pairing_max_pending
Maximum number of hosts that can wait for approval at the same time.
Hosts over the limit keep trying until there is room.
Set to 0 for no limit.
Defaults to 50.
### controller.http_port
Port to host HTTP server on.
@@ -369,7 +386,7 @@ Defaults to "http://localhost:8080/".
Access token used for authenticating with the controller.
The host saves it together with host.id when it is approved, see [Setting up remote hosts](/README.md#setting-up-remote-hosts).
The token only works while the host exists on the controller, if the host is revoked it asks to be approved again the next time it starts.
Set it to `enter token here` to make the host ask to join again, for example after changing host.controller_url to another controller.
Set it to `enter token here` to make the host ask to join again.
The value can only be read and changed from the host itself.
Defaults to "enter token here".
+2 -2
View File
@@ -69,7 +69,7 @@ A host joins the cluster by having the code it shows when it first starts approv
- `HostGenerateTokenRequest` and `HostConfigCreateRequest` are removed.
Pending requests are listed with `HostPairingListRequest`, approved with `HostPairingApproveRequest` and rejected with `HostPairingRejectRequest`, and changes to them are sent to subscribers of `HostPairingUpdatesEvent`.
- `HostRevokeTokensRequest` is replaced by `HostRevokeRequest`, which deletes the host instead of only invalidating its tokens.
It throws `ConflictError` while instances are assigned to the host.
Instances assigned to the host become unassigned.
- The permissions `core.host.generate_token`, `core.host.revoke_token` and `core.host.create_config` are removed.
Check `core.host.approve` and `core.host.revoke` instead.
- `clusteriocontroller bootstrap generate-host-token` is removed, scripts that set up hosts can start the host with `--user-token` to approve it with a user token.
@@ -78,4 +78,4 @@ Host records are no longer created when a host connects, only by approving it, a
Revoking a host deletes its record and note.
Plugins that keep data per host should remove it when the host is deleted.
On the controller the `update` event of `controller.hosts` gives the record with `isDeleted` set to true, and subscribers of `HostUpdatesEvent` receive the host with `isDeleted` set to true.
A host that is revoked and installed again gets a new id.
A host that is revoked and approved again gets a new id.
+4 -4
View File
@@ -61,7 +61,7 @@ If a user token is sent and the user has the `core.host.approve` permission the
An invalid token gives 401 and a user without the permission gives 403, a token is never ignored to create a pending request instead.
A missing or too long name gives 400.
Too many pending requests from the same address or in total gives 429 with a `Retry-After` header in seconds.
Too many pending requests from the same address, or in total as set by [controller.host_pairing_max_pending](/docs/configuration.md#controllerhost_pairing_max_pending), gives 429 with a `Retry-After` header in seconds.
### `POST /api/host-pairing/poll`
@@ -72,7 +72,7 @@ Once approved it responds with 200 and `{"hostId": integer, "token": string, "na
Rejected requests give 403, and unknown or expired requests give 404.
The result of an approved or rejected request is only returned once, after which the request is gone.
A pending request expires 30 seconds after it was last polled, and after an hour of waiting.
A pending request expires 30 seconds after it was last polled, and after waiting for the time set by [controller.host_pairing_timeout](/docs/configuration.md#controllerhost_pairing_timeout).
The host polls every 2 seconds, and makes a new request when the previous one has expired.
### `POST /api/host-pairing/cancel`
@@ -177,8 +177,8 @@ Host handshake for establishing a new connection session.
Object mapping plugin names to plugin version for plugins that are available on the host.
The controller closes the connection with code 3000 (Unauthorized) if the token is invalid, was issued for another id, or if there is no host with this id, see [Host pairing](#host-pairing).
If this happens when the host starts it exits with code 8, and the run scripts created by the installer do not restart it.
A host that has been rejected also exits with code 8.
The host checks its token before connecting and requests to join again if it is not accepted, so this normally only happens to a host that is already running.
A host that has been rejected exits with code 8, and the run scripts created by the installer do not restart it.
A host that is revoked while connected is disconnected, is rejected when it tries to connect again, and shuts down.
#### `registerControl`
+3 -3
View File
@@ -17,14 +17,14 @@ The easiest way to do this is to run `npx clusteriocontroller bootstrap create-c
Lists all hosts.
Hosts are added to the cluster by running the installer on the computer the host runs on and approving the code it shows, see [Setting up remote hosts](/README.md#setting-up-remote-hosts).
Listing, approving and rejecting installers waiting for approval needs the `core.host.approve` permission.
Listing, approving and rejecting hosts waiting for approval needs the `core.host.approve` permission.
### List pending hosts
ctl> host pending
Lists hosts waiting for approval with the code they show, the name they asked for, the address they connected from and how long they have been waiting.
A request goes away when its host stops waiting, and after waiting for an hour, after which the host asks again with a new code.
A request goes away when its host stops waiting, and after the time set by `controller.host_pairing_timeout`, an hour by default, after which the host asks again with a new code.
### Approve host
@@ -47,7 +47,7 @@ Rejects the host showing the code, the host then exits with an error.
Specify the host by name or ID.
Removes the host from the cluster, disconnects it and makes its token stop working.
Instances assigned to the host have to be unassigned first.
Instances assigned to the host become unassigned, their files stay on the computer.
Requires the `core.host.revoke` permission.
To add the computer back to the cluster, start the host again and approve it.
+13 -14
View File
@@ -255,26 +255,25 @@ export default class ControlConnection extends BaseConnection {
throw new lib.NotFoundError(`Unknown host id (${request.hostId})`);
}
const assigned = [...this._controller.instances.values()].filter(
instance => instance.config.get("instance.assigned_host") === request.hostId
);
if (assigned.length) {
const names = assigned.map(instance => instance.config.get("instance.name")).join(", ");
throw new lib.ConflictError(
`Cannot revoke host ${host.name} while instances are assigned to it: ${names}`
);
}
this._controller.hosts.delete(host);
this._controller.deleteNote("host", request.hostId, this.user.name);
const system = this._controller.systems.getMutable(request.hostId);
if (system) {
this._controller.systems.delete(system);
}
// Disconnect before saving so the host is cut off even if the save fails.
// Disconnected first so unassigning the instances does not depend on the host responding.
const hostConnection = this._controller.wsServer.hostConnections.get(request.hostId);
if (hostConnection) {
hostConnection.connector.terminate();
}
const assigned = [...this._controller.instances.values()].filter(
instance => instance.config.get("instance.assigned_host") === request.hostId
);
for (const instance of assigned) {
await this._controller.instances.unassignInstance(instance.id);
}
logger.audit(`User ${this.user.name} revoked host ${host.name} (${host.id})`);
await this._controller.hosts.save();
}
async handleHostListRequest(): Promise<lib.HostDetails[]> {
@@ -283,11 +282,11 @@ export default class ControlConnection extends BaseConnection {
async handleHostPairingListRequest(): Promise<lib.HostPairingDetails[]> {
this._controller.hostPairing.prune();
return [...this._controller.hostPairing.requests.values()];
return [...this._controller.hostPairing.records.values()];
}
async handleHostPairingApproveRequest(request: lib.HostPairingApproveRequest) {
return await this._controller.hostPairing.approve(request.code, request.name, this.user.name);
return this._controller.hostPairing.approve(request.code, request.name, this.user.name);
}
async handleHostPairingRejectRequest(request: lib.HostPairingRejectRequest) {
+3 -3
View File
@@ -323,7 +323,7 @@ export default class Controller {
// Handle updates for datastores
this.systems.on("update", this.systemsUpdated.bind(this));
this.hosts.on("update", this.hostsUpdated.bind(this));
this.hostPairing.requests.on("update", this.hostPairingRequestsUpdated.bind(this));
this.hostPairing.records.on("update", this.hostPairingRequestsUpdated.bind(this));
this.instances.records.on("update", this.instanceUpdated.bind(this));
this.saves.on("update", this.savesUpdated.bind(this));
this.modPacks.on("update", this.modPacksUpdated.bind(this));
@@ -373,7 +373,6 @@ export default class Controller {
async _startInternal(args: ControllerArgs) {
this.logDirectory = args.logDirectory;
this.hostPairing.start();
this.clusterLogIndex = await lib.LogIndex.load(path.join(this.logDirectory, "cluster"));
this.clusterLogBuildInterval = setInterval(() => {
@@ -496,6 +495,7 @@ export default class Controller {
logger.info(`Listening for HTTPS on port ${(this.httpsServer.address() as AddressInfo).port}`);
}
this.hostPairing.start();
this.onAutosaveIntervalChanged();
this.onSystemMetricsIntervalChanged();
@@ -1062,7 +1062,7 @@ export default class Controller {
if (request.action === "unsubscribe") {
return null;
}
const requests = [...this.hostPairing.requests.values()].filter(
const requests = [...this.hostPairing.records.values()].filter(
pairing => pairing.updatedAtMs > request.lastRequestTimeMs,
);
return requests.length ? new lib.HostPairingUpdatesEvent(requests) : null;
+23 -48
View File
@@ -14,10 +14,6 @@ const codeLength = 6;
/** Time a request is kept after the last poll from the host */
export const pollTimeoutMs = 30e3;
/** Maximum time a request can stay pending */
export const maxPendingMs = 60 * 60e3;
/** Maximum number of pending requests in total */
export const maxPending = 50;
/** Maximum number of pending requests from one address */
export const maxPendingPerAddress = 3;
/** Maximum length of a host name */
@@ -44,16 +40,16 @@ export class HostPairingError extends Error {
}
}
interface PairingRequest {
type PairingRequest = {
/** Secret the host polls with, never shown to users */
secret: string,
details: lib.HostPairingDetails,
/** Address with IPv6 addresses grouped by /64, used for limits */
addressKey: string,
lastPollMs: number,
state: "pending" | "approving" | "approved" | "rejected",
result?: HostPairingResult,
}
} & (
{ state: "pending" | "rejected" } | { state: "approved", result: HostPairingResult }
);
/**
* Strips the IPv4 mapped prefix and groups IPv6 addresses by their /64
@@ -105,7 +101,7 @@ function generateCode() {
*/
export default class HostPairingManager {
/** Pending requests by code, broadcast to subscribed control links */
requests = new lib.SubscribableDatastore<lib.HostPairingDetails>();
records = new lib.SubscribableDatastore<lib.HostPairingDetails>();
private _bySecret = new Map<string, PairingRequest>();
private _byCode = new Map<string, PairingRequest>();
private _pruneInterval?: ReturnType<typeof setInterval>;
@@ -148,28 +144,19 @@ export default class HostPairingManager {
/**
* Create an approved host with a new id and token
*
* The host record is saved to disk before this returns.
*
* @param name - Name of the host.
* @returns the id, token and name of the new host.
*/
async createHost(name: string): Promise<HostPairingResult> {
createHost(name: string): HostPairingResult {
const hosts = this._controller.hosts;
let hostId;
do {
hostId = crypto.randomInt(1, 2 ** 31);
} while (hosts.has(hostId));
const record = new HostRecord(
hosts.set(new HostRecord(
hostId, name, "", new Map(), false, "", "", Math.floor(Date.now() / 1000),
);
hosts.set(record);
try {
await hosts.save();
} catch (err) {
hosts.delete(record);
throw err;
}
));
return { hostId, token: this._controller.generateHostToken(hostId), name };
}
@@ -191,7 +178,8 @@ export default class HostPairingManager {
if (pendingFromAddress >= maxPendingPerAddress) {
throw new HostPairingError("Too many pending requests from this address", 429, 30);
}
if (this.requests.size >= maxPending) {
const maxPending = this._controller.config.get("controller.host_pairing_max_pending");
if (maxPending && this.records.size >= maxPending) {
throw new HostPairingError("Too many pending requests", 429, 30);
}
@@ -209,7 +197,7 @@ export default class HostPairingManager {
};
this._bySecret.set(request.secret, request);
this._byCode.set(code, request);
this.requests.set(request.details);
this.records.set(request.details);
logger.verbose(`Host pairing request ${code} for "${name}" from ${remoteAddress}`);
return { requestId: request.secret, code };
}
@@ -229,7 +217,7 @@ export default class HostPairingManager {
request.lastPollMs = Date.now();
if (request.state === "approved") {
this._remove(request);
return request.result!;
return request.result;
}
if (request.state === "rejected") {
this._remove(request);
@@ -268,7 +256,7 @@ export default class HostPairingManager {
* @param approvedBy - Name of the user approving it, for logging.
* @returns the id of the new host.
*/
async approve(code: string, name: string | undefined, approvedBy: string) {
approve(code: string, name: string | undefined, approvedBy: string) {
const request = this._pending(code);
let hostName;
try {
@@ -277,18 +265,9 @@ export default class HostPairingManager {
throw new lib.ValidationError(err.message);
}
request.state = "approving";
let result;
try {
result = await this.createHost(hostName);
} catch (err) {
request.state = "pending";
throw err;
}
request.state = "approved";
request.result = result;
this.requests.delete(request.details);
const result = this.createHost(hostName);
Object.assign(request, { state: "approved", result });
this.records.delete(request.details);
logger.audit(
`User ${approvedBy} approved host ${result.name} (${result.hostId}) with code ` +
`${request.details.code} from ${request.details.remoteAddress}`
@@ -305,7 +284,7 @@ export default class HostPairingManager {
reject(code: string, rejectedBy: string) {
const request = this._pending(code);
request.state = "rejected";
this.requests.delete(request.details);
this.records.delete(request.details);
logger.audit(
`User ${rejectedBy} rejected host ${request.details.name} with code ` +
`${request.details.code} from ${request.details.remoteAddress}`
@@ -315,26 +294,22 @@ export default class HostPairingManager {
private _remove(request: PairingRequest) {
this._bySecret.delete(request.secret);
this._byCode.delete(request.details.code);
if (this.requests.has(request.details.code)) {
this.requests.delete(request.details);
}
this.records.delete(request.details);
}
/** Remove requests the host stopped polling for or that waited too long */
prune() {
const now = Date.now();
const timeoutMs = this._controller.config.get("controller.host_pairing_timeout") * 1000;
for (const request of this._bySecret.values()) {
if (request.state === "approving") {
continue;
}
const expired = now - request.lastPollMs > pollTimeoutMs
|| (request.state === "pending" && now - request.details.createdAtMs > maxPendingMs);
if (!expired) {
const timedOut = request.state === "pending" && timeoutMs > 0
&& now - request.details.createdAtMs > timeoutMs;
if (!timedOut && now - request.lastPollMs <= pollTimeoutMs) {
continue;
}
if (request.state === "approved") {
logger.warn(
`Approved host ${request.result!.name} (${request.result!.hostId}) was never picked up ` +
`Approved host ${request.result.name} (${request.result.hostId}) was never picked up ` +
"by the host, revoke it on the hosts page if it is not needed"
);
}
+6 -6
View File
@@ -256,7 +256,7 @@ function checkHostPairingRequest(req: Request, res: Response, needsRequestId: bo
}
// Request from a host for a host to join the cluster.
async function createHostPairing(req: Request, res: Response) {
function createHostPairing(req: Request, res: Response) {
if (!checkHostPairingRequest(req, res, false)) {
return;
}
@@ -279,10 +279,13 @@ async function createHostPairing(req: Request, res: Response) {
try {
user.checkPermission("core.host.approve");
} catch (err) {
logger.audit(
`User ${user.name} tried to approve host ${name} from ${remoteAddress} without permission`
);
res.status(403).json({ error: `User ${user.name} is not allowed to approve hosts` });
return;
}
const result = await controller.hostPairing.createHost(name);
const result = controller.hostPairing.createHost(name);
logger.audit(
`User ${user.name} approved host ${result.name} (${result.hostId}) from ${remoteAddress} ` +
"with their token"
@@ -783,10 +786,7 @@ export function addRouteHandlers(app: Application) {
next();
};
const pairingJson = express.json({ limit: "4kb" });
app.post("/api/host-pairing",
noStore, pairingJson,
(req:Request, res:Response, next:any) => createHostPairing(req, res).catch(next)
);
app.post("/api/host-pairing", noStore, pairingJson, createHostPairing);
app.post("/api/host-pairing/poll", noStore, pairingJson, pollHostPairing);
app.post("/api/host-pairing/cancel", noStore, pairingJson, cancelHostPairing);
app.get("/api/host-pairing/check", noStore, validateHostToken, (req: Request, res: Response) => {
+11 -9
View File
@@ -20,7 +20,6 @@ The installer runs without prompts when all the questions for the mode are answe
## Modes
- `standalone`: controller and host on this computer. The host is approved like any other host the first time it starts.
The host is added to the controller directly and does not need to be approved.
- `controller`: controller only.
- `host`: host only, joins the cluster of an existing controller, see [Setting up a host](#setting-up-a-host).
- `ctl`: the clusterioctl command line interface only.
@@ -33,21 +32,24 @@ The installer runs without prompts when all the questions for the mode are answe
| Option | Modes | Description |
| --- | --- | --- |
| `--mode <mode>` | all | Operating mode to install. |
| `--admin <name>` | standalone, controller | Name of the admin account to create. |
| `--http-port <port>` | standalone, controller | Port the controller web interface listens on, defaults to 8080. |
| `--admin <name>` | controller | Name of the admin account to create. |
| `--http-port <port>` | controller | Port the controller web interface listens on, defaults to 8080. |
| `--host-name <name>` | host | Name to request for the host, defaults to the name of the computer. |
| `--controller-url <url>` | host, ctl | URL of the controller, the same as for its web interface. |
| `--controller-token <token>` | ctl | Authentication token saved for clusterioctl. |
| `--public-address <address>` | standalone, host | DNS name or IP address players connect to this computer with. |
| `--factorio-dir <path>` | standalone, host | Path to the Factorio installation. |
| `--self-signed-cert <path>` | standalone, host | Certificate to trust for a controller using a self-signed certificate. It is added to the run scripts. |
| `--remote-npm`, `--no-remote-npm` | standalone, controller, host | Allow or deny remote updates, plugin updates and plugin installs from the web interface. |
| `--public-address <address>` | host | DNS name or IP address players connect to this computer with. |
| `--factorio-dir <path>` | host | Path to the Factorio installation. |
| `--self-signed-cert <path>` | host | Certificate to trust for a controller using a self-signed certificate. It is added to the run scripts. |
| `--remote-npm`, `--no-remote-npm` | controller, host | Allow or deny remote updates, plugin updates and plugin installs from the web interface. |
| `--plugins <package...>` | all | Plugins to install, pass `--plugins` without packages to install none. |
| `--plugin-template <template...>`, `--plugin-name <name>` | plugin-template | Templates to use and name of the new plugin. |
| `--log-level <level>` | all | Log level to print, defaults to info. |
| `--allow-install-as-root` | all | Linux only, allow installing as root (not recommended). |
| `--migrate-rename` | | Migrate an installation from before the slave/master rename. |
Options for the `controller` and `host` modes also work in the `standalone` mode, except for `--controller-url` as the host connects to the controller on the same computer.
`--host-name` defaults to `local` in the `standalone` mode.
## Setting up a host
@@ -84,8 +86,8 @@ The installer adds it to the run scripts.
When a host is revoked in the web interface or with `npx clusterioctl host revoke <host>` its token stops working and a running host shuts down.
Starting it again requests to join the cluster again, and the host gets a new id once approved.
To move a host to another controller, change `host.controller_url` with `npx clusteriohost config set host.controller_url <url>` and set `host.controller_token` back to `enter token here`.
The host then requests to join that controller the next time it starts.
To move a host to another controller, change `host.controller_url` with `npx clusteriohost config set host.controller_url <url>`.
The new controller does not accept the token of the host, so the host requests to join it the next time it starts.
## Docker
+12 -11
View File
@@ -161,6 +161,9 @@ async function execCtl(args) {
return await execFile(path.join("node_modules", ".bin", `clusterioctl${scriptExt}`), args);
}
// Same characters as the controller refuses in host names.
const hostNameControlCharacters = /[\p{Cc}\u202a-\u202e\u2066-\u2069]/u;
function validateHostName(input) {
if (!input || !input.trim()) {
return "Host name may not be empty";
@@ -174,9 +177,6 @@ function validateHostName(input) {
return true;
}
// Same characters as the controller refuses in host names.
const hostNameControlCharacters = /[\p{Cc}\u202a-\u202e\u2066-\u2069]/u;
function validateControllerUrl(input) {
let url;
try {
@@ -594,6 +594,14 @@ async function inquirerMissingArgs(args) {
], answers);
}
if (answers.mode === "standalone") {
answers.hostName = args.hostName ?? "local";
const result = validateHostName(answers.hostName);
if (result !== true) {
throw new InstallError(result);
}
}
if (answers.mode === "host") {
if (args.hostName) { answers.hostName = args.hostName; }
if (args.controllerUrl) { answers.controllerUrl = args.controllerUrl; }
@@ -783,7 +791,7 @@ async function main() {
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
})
.option("host-name", {
nargs: 1, describe: "Host name [host]", type: "string",
nargs: 1, describe: "Host name [standalone/host]", type: "string",
})
.option("controller-url", {
nargs: 1, describe: "Controller URL [host/ctl]", type: "string",
@@ -869,7 +877,6 @@ async function main() {
if (answers.mode === "standalone") {
// Default to localhost on correct port for host in standalone mode
answers.hostName = "local";
answers.controllerUrl = `http://localhost:${answers.httpPort}/`;
}
@@ -895,12 +902,6 @@ async function main() {
/* eslint-disable no-console */
console.log(`Successfully installed ${answers.mode}`);
if (["standalone", "host"].includes(answers.mode)) {
console.log(
`Start the host with run-host${process.platform === "win32" ? ".cmd" : ".sh"}, then approve it on the ` +
`Hosts page of ${answers.controllerUrl}`
);
}
if (adminToken) {
console.log(`Admin authentication token: ${adminToken}`);
}
+3 -2
View File
@@ -132,8 +132,9 @@ Lists up all configuration entries with their currently configured values.
### `run`
Runs the host.
The host has to be approved on the controller before it can connect, which is done by running the installer in the host directory, see Setting up remote hosts in the readme of the main repository.
If the controller rejects the token of the host when it starts, for example because the host was revoked, it exits with code 8 and the run scripts created by the installer do not restart it.
A host that has not been approved prints a code and a link and waits for approval before it connects, see [Setting up a host](https://github.com/clusterio/clusterio/blob/master/packages/create/README.md#setting-up-a-host).
If the controller no longer accepts the token of the host when it starts, for example because the host was revoked, it asks to be approved again.
If its request is rejected it exits with code 8 and the run scripts created by the installer do not restart it.
A host that is revoked while it runs shuts down.
+26 -15
View File
@@ -111,10 +111,14 @@ function retryDelay(response: Response, defaultMs: number) {
*
* @returns the result if approved, or null if the controller no longer knows the request.
*/
async function poll(requestId: string, options: Required<Pick<PairHostOptions, "pollIntervalMs">> & PairHostOptions) {
let failing = false;
async function poll(
requestId: string,
options: Required<Pick<PairHostOptions, "pollIntervalMs" | "retryDelayMs">> & PairHostOptions,
) {
let delayMs = options.pollIntervalMs;
while (true) {
await sleep(options.pollIntervalMs, undefined, { signal: options.signal });
await sleep(delayMs, undefined, { signal: options.signal });
delayMs = options.pollIntervalMs;
let response;
try {
response = await request(options.controllerUrl, "api/host-pairing/poll", {
@@ -125,20 +129,22 @@ async function poll(requestId: string, options: Required<Pick<PairHostOptions, "
throw err;
}
// Keep polling through network errors, the request is made again if it expired meanwhile.
if (!failing) {
options.onRetry?.(err, options.pollIntervalMs);
failing = true;
}
delayMs = options.retryDelayMs;
options.onRetry?.(err, delayMs);
continue;
}
failing = false;
if (response.status === 200) {
return pairingResult(response);
}
if (response.status === 404) {
return null;
}
if (response.status !== 202 && !isTemporary(response.status)) {
if (isTemporary(response.status)) {
delayMs = retryDelay(response, options.retryDelayMs);
options.onRetry?.(responseError(response), delayMs);
continue;
}
if (response.status !== 202) {
throw responseError(response);
}
}
@@ -221,11 +227,12 @@ export async function pairHost(options: PairHostOptions): Promise<PairingResult>
*
* @param controllerUrl - URL of the controller, ending with a slash.
* @param token - Host token to check.
* @param signal - Signal to cancel the check with.
* @returns true if the token is valid and its host is registered.
* @throws {PairingError} if the controller gives an unexpected response.
*/
export async function checkHostToken(controllerUrl: string, token: string) {
const response = await request(controllerUrl, "api/host-pairing/check", { token });
export async function checkHostToken(controllerUrl: string, token: string, signal?: AbortSignal) {
const response = await request(controllerUrl, "api/host-pairing/check", { token, signal });
if (response.status === 200) {
return true;
}
@@ -254,8 +261,11 @@ export async function ensurePaired(
if (token && token !== unpairedToken) {
let valid;
try {
valid = await checkHostToken(controllerUrl, token);
valid = await checkHostToken(controllerUrl, token, options.signal);
} catch (err: any) {
if (options.signal?.aborted) {
throw err;
}
// Connecting reports the problem, or authenticates if the controller predates pairing.
logger.verbose(`Unable to check if the controller accepts this host: ${err.message}`);
return;
@@ -276,10 +286,11 @@ export async function ensurePaired(
signal: options.signal,
onPending: code => {
const approveUrl = new URL(`hosts?approve=${code}`, controllerUrl).href;
// Logged as warnings for the systemd service, which only prints warnings and above.
// 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}`);
logger.warn(`Waiting for approval of "${name}" with code ${code}, open this link to approve it:`);
logger.warn(approveUrl);
logger.warn(`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`);
+16
View File
@@ -37,6 +37,8 @@ export interface ControllerConfigFields {
"controller.default_mod_pack_id": number | null;
"controller.default_role_id": number | null;
"controller.autosave_interval": number;
"controller.host_pairing_timeout": number;
"controller.host_pairing_max_pending": number;
"controller.mod_portal_cache_duration_minutes": number;
"controller.mod_portal_page_size": number;
"controller.allow_remote_updates": boolean;
@@ -98,6 +100,20 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
initialValue: 60,
validator: validators.greaterThanZero,
},
"controller.host_pairing_timeout": {
title: "Host Pairing Timeout",
description: "Time in seconds a host can wait for approval before it has to request again, 0 for no limit.",
type: "number",
initialValue: 3600,
validator: validators.greaterThanEqualZero,
},
"controller.host_pairing_max_pending": {
title: "Host Pairing Max Pending",
description: "Maximum number of hosts that can wait for approval at the same time, 0 for no limit.",
type: "number",
initialValue: 50,
validator: validators.all(validators.integer, validators.greaterThanEqualZero),
},
"controller.http_port": {
title: "HTTP Port",
description: "Port to listen for HTTP connections on, set to null to not listen for HTTP connections.",
+2 -2
View File
@@ -183,7 +183,7 @@ export abstract class Datastore<
// Save the datastore to the provider
async save() {
// Saves run one at a time so an older snapshot can't be written after a newer one.
const save = this.saving.then(async () => {
const save = this.saving.catch(() => {}).then(async () => {
if (!this.dirty) {
return;
}
@@ -196,7 +196,7 @@ export abstract class Datastore<
throw err;
}
});
this.saving = save.catch(() => {});
this.saving = save;
await save;
}
+3 -1
View File
@@ -480,6 +480,8 @@ export class EventSubscriber<E, S = null> {
protected Event: EventClass<E>,
public control: Link,
protected filteredHandler?: EventSubscriberCallback<E>,
/** Nothing is sent to the controller while this returns false, for accounts without permission */
protected permitted: () => boolean = () => true,
) {
control.handle(Event, this._handleEvent.bind(this));
// The events below exist only on websocket connectors, we can't early return because of test mocking
@@ -670,7 +672,7 @@ export class EventSubscriber<E, S = null> {
* Update the subscription with the controller based on current handler counts
*/
private async _sendRequest(action: SubscriptionAction, filters: SubscriptionFilters) {
if (!this.control.connector.valid) {
if (!this.control.connector.valid || !this.permitted()) {
return;
}
@@ -104,16 +104,11 @@ function RevokeHostButton({ host, instances }: RevokeHostButtonProps) {
const navigate = useNavigate();
const assigned = [...instances.values()].map(instance => instance.name);
if (assigned.length) {
return <Tooltip title={`Unassign or delete the instances on this host first: ${assigned.join(", ")}`}>
<Button danger disabled>Revoke host access</Button>
</Tooltip>;
}
return <Popconfirm
title={`Revoke access for ${host.name}?`}
description={<>
The host is removed from the cluster and disconnected.<br />
{assigned.length > 0 && <>Its instances become unassigned: {assigned.join(", ")}<br /></>}
To add it back, start the host again and approve it.
</>}
placement="bottomRight"
+2 -8
View File
@@ -1,6 +1,5 @@
import { useCallback, useContext, useSyncExternalStore } from "react";
import ControlContext from "../components/ControlContext";
import { useAccount } from "./account";
export function useHost(id?: number) {
const [hosts, synced] = useHosts();
@@ -13,14 +12,9 @@ export function useHosts() {
return useSyncExternalStore(subscribe, () => control.hosts.getSnapshot());
}
/** Requests from host installers waiting to be approved, empty if the account may not approve hosts */
/** Requests from hosts waiting to be approved, empty if the account may not approve hosts */
export function useHostPairings() {
const control = useContext(ControlContext);
const account = useAccount();
const permitted = Boolean(account.hasPermission("core.host.approve"));
const subscribe = useCallback(
(callback: () => void) => (permitted ? control.hostPairings.subscribe(callback) : () => {}),
[control, permitted],
);
const subscribe = useCallback((callback: () => void) => control.hostPairings.subscribe(callback), [control]);
return useSyncExternalStore(subscribe, () => control.hostPairings.getSnapshot());
}
+5 -35
View File
@@ -1,19 +1,13 @@
import * as lib from "@clusterio/lib";
/**
* Subscriber for the requests from host installers waiting to be approved
* Subscriber for the requests from hosts waiting to be approved
*
* Only accounts that may approve hosts can subscribe to the requests, so
* this does not resubscribe on connect or account changes for other
* accounts, which would fail with a permission error. The requests only
* live in the controller's memory and it does not tell a new session about
* requests removed before it started, so the requests are cleared when a
* new session starts and fetched again. The same applies to requests removed
* while nothing was subscribed, so they are also cleared when subscribing
* again after the last handler unsubscribed.
* The requests only live in the controller's memory and it does not tell
* subscribers about requests removed while nothing was subscribed, so they
* are cleared when subscribing again after the last handler unsubscribed.
*/
export class HostPairingSubscriber extends lib.MapSubscriber<lib.HostPairingUpdatesEvent> {
private _permitted: () => boolean;
private _handlers = 0;
/**
@@ -21,31 +15,7 @@ export class HostPairingSubscriber extends lib.MapSubscriber<lib.HostPairingUpda
* @param permitted - Returns true if the account may approve hosts.
*/
constructor(control: lib.Link, permitted: () => boolean) {
super(lib.HostPairingUpdatesEvent, control);
this._permitted = permitted;
}
override handleConnectionEvent(event: "connect" | "drop" | "resume" | "close") {
if (event === "connect") {
this.values.clear();
this.lastUpdatedMs = -1;
}
if (event === "connect" || event === "resume") {
// The account of a new session is reported after the connect event.
queueMicrotask(() => {
if (this._permitted()) {
super.handleConnectionEvent(event);
}
});
return;
}
super.handleConnectionEvent(event);
}
override handleAccountEvent() {
if (this._permitted()) {
super.handleAccountEvent();
}
super(lib.HostPairingUpdatesEvent, control, undefined, permitted);
}
override subscribe(handler: lib.EventSubscriberCallback<lib.HostPairingUpdatesEvent>) {
+22 -34
View File
@@ -326,16 +326,9 @@ describe("controller/src/ControlConnection", function() {
await connection.handleHostRevokeRequest(new lib.HostRevokeRequest(hostId));
}
it("deletes and saves the host record", async function() {
let savedWithoutHost;
const originalSave = controller.hosts.save;
controller.hosts.save = async function() {
savedWithoutHost = !this.has(10);
return await originalSave.call(this);
};
it("deletes the host record", async function() {
await revoke();
assert.equal(controller.hosts.has(10), false);
assert.equal(savedWithoutHost, true, "hosts were not saved after deleting the record");
assert.deepEqual(broadcasts, [{ id: 10, isDeleted: true }]);
assert.equal(audits.length, 1);
assert.match(audits[0], /User admin revoked host host-10 \(10\)/);
@@ -356,15 +349,6 @@ describe("controller/src/ControlConnection", function() {
await revoke();
assert.equal(terminated, true);
});
it("terminates the connection of the host when saving fails", async function() {
let terminated = false;
controller.wsServer.hostConnections.set(10, { connector: { terminate() { terminated = true; } } });
controller.hosts.save = async () => { throw new Error("EPERM: operation not permitted"); };
await assert.rejects(revoke(), /EPERM/);
assert.equal(terminated, true, "revoked host kept its connection");
assert.equal(controller.hosts.has(10), false);
assert.equal(controller.notes.get("host/10"), undefined);
});
it("does not leave a host record behind when the connection closes", async function() {
const connector = new MockConnector(addr("controller"), addr({ hostId: 10 }));
connector.terminate = () => { connector.emit("close"); };
@@ -378,27 +362,31 @@ describe("controller/src/ControlConnection", function() {
assert.equal(controller.hosts.has(10), false, "host record was recreated");
assert.deepEqual(broadcasts.at(-1), { id: 10, isDeleted: true });
});
it("refuses to revoke a host with assigned instances", async function() {
controller.setNote("host", "10", "rack 3", "someone");
let terminated = false;
controller.wsServer.hostConnections.set(10, { connector: { terminate() { terminated = true; } } });
it("unassigns the instances of the host without waiting for it", async function() {
let closing = false;
controller.wsServer.hostConnections.set(10, {
connector: { terminate() { closing = true; }, get closing() { return closing; } },
send: () => new Promise(() => {}),
});
assignInstance(101, "alpha", 10);
assignInstance(102, "beta", 10);
assignInstance(201, "gamma", 20);
await assert.rejects(
revoke(),
new lib.ConflictError("Cannot revoke host host-10 while instances are assigned to it: alpha, beta"),
);
assert.equal(controller.hosts.get(10), record);
assert.equal(controller.notes.get("host/10").isDeleted, false);
assert.equal(terminated, false);
assert.deepEqual(broadcasts, []);
});
it("ignores instances assigned to other hosts", async function() {
assignInstance(201, "gamma", 20);
await revoke();
assert.equal(controller.hosts.has(10), false);
for (const id of [101, 102]) {
const instance = controller.instances.get(id);
assert.equal(instance.config.get("instance.assigned_host"), null);
assert.equal(instance.status, "unassigned");
}
assert.equal(controller.instances.get(201).config.get("instance.assigned_host"), 20);
});
it("removes the system metrics of the host", async function() {
controller.systems.set(await lib.gatherSystemInfo(10, false, false, 0));
controller.systems.set(await lib.gatherSystemInfo("controller", false, false, 0));
await revoke();
assert.equal(controller.systems.has(10), false);
assert.equal(controller.systems.has("controller"), true);
});
it("throws NotFoundError for an unknown host", async function() {
await assert.rejects(revoke(99), new lib.NotFoundError("Unknown host id (99)"));
@@ -421,7 +409,7 @@ describe("controller/src/ControlConnection", function() {
const rejected = controller.hostPairing.create("Host C", "", "10.0.0.3");
const pending = controller.hostPairing.create("Host D", "", "10.0.0.4");
controller.hostPairing._bySecret.get(expired.requestId).lastPollMs -= 60e3;
await controller.hostPairing.approve(approved.code, undefined, "admin");
controller.hostPairing.approve(approved.code, undefined, "admin");
controller.hostPairing.reject(rejected.code, "admin");
const list = await connection.handleHostPairingListRequest(new lib.HostPairingListRequest());
@@ -440,7 +428,7 @@ describe("controller/src/ControlConnection", function() {
it("rejects a request", async function() {
const { requestId, code } = controller.hostPairing.create("Host A", "", "10.0.0.1");
await connection.handleHostPairingRejectRequest(new lib.HostPairingRejectRequest(code.toLowerCase()));
assert.equal(controller.hostPairing.requests.has(code), false);
assert.equal(controller.hostPairing.records.has(code), false);
assert.throws(() => controller.hostPairing.poll(requestId), /Request was rejected/);
assert.equal(controller.hosts.size, 0);
assert(audits.some(msg => msg.startsWith(`User admin rejected host Host A with code ${code}`)));
+70 -115
View File
@@ -5,8 +5,7 @@ import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
import { Controller, HostPairingManager, HostPairingError, HostRecord } from "@clusterio/controller";
import {
pollTimeoutMs, maxPendingMs, maxPending, maxPendingPerAddress,
maxNameLength,
pollTimeoutMs, maxPendingPerAddress, maxNameLength,
} from "@clusterio/controller/dist/node/src/HostPairingManager.js";
import "../setup_logging.js";
@@ -31,12 +30,6 @@ function stubRandomInt(values) {
return () => { crypto.randomInt = original; };
}
function deferred() {
let resolve, reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
function assertPairingError(fn, status, message) {
assert.throws(fn, err => {
assert(err instanceof HostPairingError, `expected HostPairingError, got ${err}`);
@@ -95,7 +88,7 @@ describe("controller/src/HostPairingManager", function() {
controller = makeController();
manager = controller.hostPairing;
updates = [];
manager.requests.on("update", changed => {
manager.records.on("update", changed => {
updates.push(...changed.map(details => ({ code: details.code, isDeleted: details.isDeleted })));
});
restoreRandomInt = undefined;
@@ -111,15 +104,15 @@ describe("controller/src/HostPairingManager", function() {
return manager._bySecret.get(requestId);
}
// Pretend the installer last polled the request ms milliseconds earlier.
// Pretend the host last polled the request ms milliseconds earlier.
function idle(requestId, ms) {
internal(requestId).lastPollMs -= ms;
}
describe(".createHost()", function() {
it("should create a host record with a valid token", async function() {
it("should create a host record with a valid token", function() {
const before = Math.floor(Date.now() / 1000);
const result = await manager.createHost("Host A");
const result = manager.createHost("Host A");
assert.equal(result.name, "Host A");
assert(Number.isInteger(result.hostId), "hostId is not an integer");
@@ -136,31 +129,10 @@ describe("controller/src/HostPairingManager", function() {
assert.equal(payload.host, result.hostId);
assert(payload.iat >= record.tokenValidAfter, "token is not valid for the new record");
});
it("should save the hosts before returning", async function() {
const save = deferred();
let saveCalls = 0;
controller.hosts.save = () => { saveCalls += 1; return save.promise; };
let done = false;
const promise = manager.createHost("Host A").then(result => { done = true; return result; });
await new Promise(resolve => setImmediate(resolve));
assert.equal(saveCalls, 1);
assert.equal(done, false, "createHost returned before the hosts were saved");
save.resolve();
const result = await promise;
assert(controller.hosts.has(result.hostId));
});
it("should remove the record if saving fails", async function() {
restoreRandomInt = stubRandomInt([1234]);
controller.hosts.save = async () => { throw new Error("disk full"); };
await assert.rejects(manager.createHost("Host A"), /disk full/);
assert.equal(controller.hosts.has(1234), false);
assert.equal(controller.hosts.size, 0);
});
it("should not reuse the id of an existing host", async function() {
it("should not reuse the id of an existing host", function() {
controller.hosts.set(new HostRecord(5, "existing", "", new Map()));
restoreRandomInt = stubRandomInt([5, 5, 6]);
const result = await manager.createHost("Host A");
const result = manager.createHost("Host A");
assert.equal(result.hostId, 6);
assert.equal(controller.hosts.get(5).name, "existing");
assert.equal(controller.hosts.get(6).name, "Host A");
@@ -177,7 +149,7 @@ describe("controller/src/HostPairingManager", function() {
assert.match(code, codePattern);
assert.notEqual(requestId, code);
const details = manager.requests.get(code);
const details = manager.records.get(code);
assert(details instanceof lib.HostPairingDetails);
assert.equal(details.code, code);
assert.equal(details.name, "Host A");
@@ -191,23 +163,23 @@ describe("controller/src/HostPairingManager", function() {
});
it("should create unique codes", function() {
const codes = new Set();
for (let i = 0; i < maxPending; i++) {
for (let i = 0; i < 50; i++) {
const { code } = manager.create(`Host ${i}`, "", `10.1.${i}.1`);
assert.match(code, codePattern);
codes.add(code);
}
assert.equal(codes.size, maxPending);
assert.equal(codes.size, 50);
});
it("should not reuse the code of a pending request", function() {
restoreRandomInt = stubRandomInt([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1]);
assert.equal(manager.create("Host A", "", "10.0.0.1").code, "222-222");
assert.equal(manager.create("Host B", "", "10.0.0.2").code, "333-333");
});
it("should not reuse the code of an approved request not yet picked up", async function() {
it("should not reuse the code of an approved request not yet picked up", function() {
restoreRandomInt = stubRandomInt([0, 0, 0, 0, 0, 0, 1234, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1]);
const { code } = manager.create("Host A", "", "10.0.0.1");
assert.equal(code, "222-222");
assert.equal(await manager.approve(code, undefined, "admin"), 1234);
assert.equal(manager.approve(code, undefined, "admin"), 1234);
assert.equal(manager.create("Host B", "", "10.0.0.2").code, "333-333");
});
});
@@ -227,7 +199,7 @@ describe("controller/src/HostPairingManager", function() {
});
// Other addresses are not affected
manager.create("Host", "", "10.0.0.2");
assert.equal(manager.requests.size, maxPendingPerAddress + 1);
assert.equal(manager.records.size, maxPendingPerAddress + 1);
});
it("should count IPv4 mapped addresses as the IPv4 address", function() {
manager.create("Host", "", "10.0.0.1");
@@ -245,7 +217,7 @@ describe("controller/src/HostPairingManager", function() {
);
manager.create("Host", "", "2001:db8:0:2::1");
});
it("should free the slot when a request is no longer pending", async function() {
it("should free the slot when a request is no longer pending", function() {
const first = manager.create("Host", "", "10.0.0.1");
const second = manager.create("Host", "", "10.0.0.1");
const third = manager.create("Host", "", "10.0.0.1");
@@ -256,14 +228,15 @@ describe("controller/src/HostPairingManager", function() {
assertPairingError(() => manager.create("Host", "", "10.0.0.1"), 429);
// Approved and rejected results waiting to be picked up do not count
await manager.approve(second.code, undefined, "admin");
manager.approve(second.code, undefined, "admin");
manager.reject(third.code, "admin");
manager.create("Host", "", "10.0.0.1");
manager.create("Host", "", "10.0.0.1");
assertPairingError(() => manager.create("Host", "", "10.0.0.1"), 429);
assert(manager.requests.has(fourth.code));
assert(manager.records.has(fourth.code));
});
it("should limit the total number of pending requests", function() {
const maxPending = controller.config.get("controller.host_pairing_max_pending");
assert.equal(maxPending, 50);
for (let i = 0; i < maxPending; i++) {
manager.create(`Host ${i}`, "", `10.1.${i}.1`);
@@ -275,7 +248,14 @@ describe("controller/src/HostPairingManager", function() {
assert.equal(err.message, "Too many pending requests");
return true;
});
assert.equal(manager.requests.size, maxPending);
assert.equal(manager.records.size, maxPending);
});
it("should not limit the total number of pending requests when set to 0", function() {
controller.config.set("controller.host_pairing_max_pending", 0);
for (let i = 0; i < 60; i++) {
manager.create(`Host ${i}`, "", `10.1.${i}.1`);
}
assert.equal(manager.records.size, 60);
});
});
@@ -292,9 +272,9 @@ describe("controller/src/HostPairingManager", function() {
const { code } = manager.create("Host A", "", "10.0.0.1");
assertPairingError(() => manager.poll(code), 404);
});
it("should hand over an approved result once", async function() {
it("should hand over an approved result once", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const hostId = await manager.approve(code, undefined, "admin");
const hostId = manager.approve(code, undefined, "admin");
const result = manager.poll(requestId);
assert.deepEqual(Object.keys(result).sort(), ["hostId", "name", "token"]);
assert.equal(result.hostId, hostId);
@@ -317,7 +297,7 @@ describe("controller/src/HostPairingManager", function() {
it("should remove a pending request", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
manager.cancel(requestId);
assert.equal(manager.requests.has(code), false);
assert.equal(manager.records.has(code), false);
assertPairingError(() => manager.poll(requestId), 404);
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
assert.throws(() => manager.reject(code, "admin"), lib.NotFoundError);
@@ -325,9 +305,9 @@ describe("controller/src/HostPairingManager", function() {
it("should ignore unknown requests", function() {
manager.cancel("invalid");
});
it("should not throw away an approved result", async function() {
it("should not throw away an approved result", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const hostId = await manager.approve(code, undefined, "admin");
const hostId = manager.approve(code, undefined, "admin");
manager.cancel(requestId);
assert.equal(manager.poll(requestId).hostId, hostId);
});
@@ -340,92 +320,58 @@ describe("controller/src/HostPairingManager", function() {
});
describe(".approve()", function() {
it("should create the host with the requested name", async function() {
it("should create the host with the requested name", function() {
const { code } = manager.create("Host A", "", "10.0.0.1");
const hostId = await manager.approve(code, undefined, "admin");
const hostId = manager.approve(code, undefined, "admin");
assert.equal(controller.hosts.get(hostId).name, "Host A");
assert.equal(manager.requests.has(code), false);
assert.equal(manager.records.has(code), false);
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
});
it("should accept codes in any case and without the dash", async function() {
it("should accept codes in any case and without the dash", function() {
const { code } = manager.create("Host A", "", "10.0.0.1");
const hostId = await manager.approve(code.toLowerCase().replace("-", ""), undefined, "admin");
const hostId = manager.approve(code.toLowerCase().replace("-", ""), undefined, "admin");
assert(controller.hosts.has(hostId));
});
it("should use and trim the name given", async function() {
it("should use and trim the name given", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const hostId = await manager.approve(code, " Renamed ", "admin");
const hostId = manager.approve(code, " Renamed ", "admin");
assert.equal(controller.hosts.get(hostId).name, "Renamed");
assert.equal(manager.poll(requestId).name, "Renamed");
});
it("should reject an invalid name and keep the request pending", async function() {
it("should reject an invalid name and keep the request pending", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
await assert.rejects(
manager.approve(code, "", "admin"),
assert.throws(
() => manager.approve(code, "", "admin"),
new lib.ValidationError("Host name is required"),
);
await assert.rejects(
manager.approve(code, "x".repeat(maxNameLength + 1), "admin"),
assert.throws(
() => manager.approve(code, "x".repeat(maxNameLength + 1), "admin"),
new lib.ValidationError("Host name must be at most 100 characters"),
);
assert.equal(controller.hosts.size, 0);
assert.equal(manager.poll(requestId), null);
await manager.approve(code, undefined, "admin");
manager.approve(code, undefined, "admin");
assert.equal(controller.hosts.size, 1);
});
it("should throw NotFoundError for an unknown code", async function() {
await assert.rejects(
manager.approve("ZZZ-ZZZ", undefined, "admin"),
it("should throw NotFoundError for an unknown code", function() {
assert.throws(
() => manager.approve("ZZZ-ZZZ", undefined, "admin"),
new lib.NotFoundError("No pending host request with code ZZZ-ZZZ"),
);
});
it("should only approve a request once", async function() {
it("should only approve a request once", function() {
const { code } = manager.create("Host A", "", "10.0.0.1");
await manager.approve(code, undefined, "admin");
await assert.rejects(manager.approve(code, undefined, "admin"), lib.NotFoundError);
manager.approve(code, undefined, "admin");
assert.throws(() => manager.approve(code, undefined, "admin"), lib.NotFoundError);
assert.equal(controller.hosts.size, 1);
});
it("should not create two hosts when approved twice at the same time", async function() {
const save = deferred();
controller.hosts.save = () => save.promise;
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const first = manager.approve(code, undefined, "admin");
await assert.rejects(manager.approve(code, undefined, "admin"), lib.NotFoundError);
assert.throws(() => manager.reject(code, "admin"), lib.NotFoundError);
// The request stays while approving, the installer sees it as pending
manager.cancel(requestId);
idle(requestId, pollTimeoutMs + 1);
manager.prune();
assert.equal(manager.poll(requestId), null);
assert(manager.requests.has(code), "request removed while approving");
save.resolve();
const hostId = await first;
assert.equal(controller.hosts.size, 1);
assert.equal(manager.poll(requestId).hostId, hostId);
});
it("should keep the request pending if creating the host fails", async function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const originalSave = controller.hosts.save;
controller.hosts.save = async () => { throw new Error("disk full"); };
await assert.rejects(manager.approve(code, undefined, "admin"), /disk full/);
assert.equal(controller.hosts.size, 0);
assert(manager.requests.has(code));
assert.equal(manager.poll(requestId), null);
controller.hosts.save = originalSave;
const hostId = await manager.approve(code, undefined, "admin");
assert.equal(manager.poll(requestId).hostId, hostId);
});
});
describe(".reject()", function() {
it("should remove the request from the pending list", function() {
const { code } = manager.create("Host A", "", "10.0.0.1");
manager.reject(code.toLowerCase(), "admin");
assert.equal(manager.requests.has(code), false);
assert.equal(manager.records.has(code), false);
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
assert.equal(controller.hosts.size, 0);
});
@@ -435,9 +381,9 @@ describe("controller/src/HostPairingManager", function() {
new lib.NotFoundError("No pending host request with code ZZZ-ZZZ"),
);
});
it("should not reject an approved request", async function() {
it("should not reject an approved request", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const hostId = await manager.approve(code, undefined, "admin");
const hostId = manager.approve(code, undefined, "admin");
assert.throws(() => manager.reject(code, "admin"), lib.NotFoundError);
assert.equal(manager.poll(requestId).hostId, hostId);
});
@@ -455,11 +401,11 @@ describe("controller/src/HostPairingManager", function() {
lib.logger.warn = originalWarn;
});
it("should expire a request the installer stopped polling", function() {
it("should expire a request the host stopped polling", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
idle(requestId, pollTimeoutMs + 1);
manager.prune();
assert.equal(manager.requests.has(code), false);
assert.equal(manager.records.has(code), false);
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
assertPairingError(() => manager.poll(requestId), 404);
assert.throws(() => manager.reject(code, "admin"), lib.NotFoundError);
@@ -472,22 +418,31 @@ describe("controller/src/HostPairingManager", function() {
assert.equal(manager.poll(requestId), null);
idle(requestId, pollTimeoutMs - 1000);
manager.prune();
assert(manager.requests.has(code));
assert(manager.records.has(code));
});
it("should expire a request that has been pending for too long", function() {
assert.equal(maxPendingMs, 60 * 60e3);
const timeoutMs = controller.config.get("controller.host_pairing_timeout") * 1000;
assert.equal(timeoutMs, 60 * 60e3);
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
internal(requestId).details.createdAtMs -= maxPendingMs - 1000;
internal(requestId).details.createdAtMs -= timeoutMs - 1000;
manager.prune();
assert.equal(manager.poll(requestId), null);
internal(requestId).details.createdAtMs -= 1001;
manager.prune();
assert.equal(manager.requests.has(code), false);
assert.equal(manager.records.has(code), false);
assertPairingError(() => manager.poll(requestId), 404);
});
it("should drop and warn about an approved host never picked up", async function() {
it("should not limit how long a request is pending when the timeout is 0", function() {
controller.config.set("controller.host_pairing_timeout", 0);
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const hostId = await manager.approve(code, undefined, "admin");
internal(requestId).details.createdAtMs -= 365 * 24 * 60 * 60e3;
manager.prune();
assert(manager.records.has(code));
assert.equal(manager.poll(requestId), null);
});
it("should drop and warn about an approved host never picked up", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const hostId = manager.approve(code, undefined, "admin");
idle(requestId, pollTimeoutMs + 1);
manager.prune();
assertPairingError(() => manager.poll(requestId), 404);
@@ -526,7 +481,7 @@ describe("controller/src/HostPairingManager", function() {
assert(event instanceof lib.HostPairingUpdatesEvent);
assert.deepEqual(event.updates.map(details => details.code), [code]);
const lastUpdate = manager.requests.get(code).updatedAtMs;
const lastUpdate = manager.records.get(code).updatedAtMs;
assert.equal(await controller.handleHostPairingSubscription(
new lib.SubscriptionRequest(lib.HostPairingUpdatesEvent.name, true, lastUpdate)
), null);
+13 -13
View File
@@ -254,7 +254,7 @@ describe("controller/src/routes", function() {
}
function assertNothingCreated() {
assert.equal(controller.hostPairing.requests.size, 0, "a pending request was created");
assert.equal(controller.hostPairing.records.size, 0, "a pending request was created");
assert.equal(controller.hosts.size, 0, "a host was created");
}
@@ -267,7 +267,7 @@ describe("controller/src/routes", function() {
assert.deepEqual(Object.keys(body).sort(), ["code", "requestId"]);
assert.match(body.code, /^[2-9A-HJKMNP-Z]{3}-[2-9A-HJKMNP-Z]{3}$/);
const details = controller.hostPairing.requests.get(body.code);
const details = controller.hostPairing.records.get(body.code);
assert.equal(details.name, "Host A");
assert.equal(details.version, "2.0.0");
assert.match(details.remoteAddress, /127\.0\.0\.1|::1/);
@@ -275,12 +275,12 @@ describe("controller/src/routes", function() {
});
it("should trim the name and limit the version", async function() {
let { code } = await (await post("", { name: " Host A ", version: "x".repeat(100) })).json();
let details = controller.hostPairing.requests.get(code);
let details = controller.hostPairing.records.get(code);
assert.equal(details.name, "Host A");
assert.equal(details.version, "x".repeat(64));
({ code } = await (await post("", { name: "Host B", version: 5 })).json());
details = controller.hostPairing.requests.get(code);
details = controller.hostPairing.records.get(code);
assert.equal(details.version, "");
});
it("should respond with 400 for a missing or invalid name", async function() {
@@ -316,7 +316,7 @@ describe("controller/src/routes", function() {
const response = await post("", { name: "Host A" });
assert.equal(response.headers.get("retry-after"), "30");
await assertResponse(response, 429, { error: "Too many pending requests from this address" });
assert.equal(controller.hostPairing.requests.size, 3);
assert.equal(controller.hostPairing.records.size, 3);
});
it("should respond with 503 in recovery mode", async function() {
controller.recoveryMode = true;
@@ -344,7 +344,7 @@ describe("controller/src/routes", function() {
assert.equal(payload.host, body.hostId);
}
assert.equal(controller.hosts.size, 3);
assert.equal(controller.hostPairing.requests.size, 0, "a pending request was created");
assert.equal(controller.hostPairing.records.size, 0, "a pending request was created");
});
it("should validate the name before approving directly", async function() {
await assertResponse(
@@ -393,7 +393,7 @@ describe("controller/src/routes", function() {
});
it("should hand over the result of an approved request once", async function() {
const { requestId, code } = await createRequest();
const hostId = await controller.hostPairing.approve(code, "Renamed", "test");
const hostId = controller.hostPairing.approve(code, "Renamed", "test");
const response = await post("/poll", { requestId });
assert.equal(response.status, 200);
@@ -438,14 +438,14 @@ describe("controller/src/routes", function() {
it("should cancel a pending request", async function() {
const { requestId, code } = await createRequest();
await assertResponse(await post("/cancel", { requestId }), 204);
assert.equal(controller.hostPairing.requests.has(code), false);
assert.equal(controller.hostPairing.records.has(code), false);
await assertResponse(await post("/poll", { requestId }), 404, { error: "Request not found" });
});
it("should respond with 204 for an unknown request", async function() {
const { code } = await createRequest();
await assertResponse(await post("/cancel", { requestId: "invalid" }), 204);
await assertResponse(await post("/cancel", { requestId: code }), 204);
assert(controller.hostPairing.requests.has(code), "cancelled by the code");
assert(controller.hostPairing.records.has(code), "cancelled by the code");
});
});
@@ -470,7 +470,7 @@ describe("controller/src/routes", function() {
describe("GET /api/host-pairing/check", function() {
it("should respond with 200 for a valid host token", async function() {
const { hostId, token } = await controller.hostPairing.createHost("Host A");
const { hostId, token } = controller.hostPairing.createHost("Host A");
for (const headers of [
{ "Authorization": `Bearer ${token}` },
{ "X-Access-Token": token },
@@ -480,18 +480,18 @@ describe("controller/src/routes", function() {
assert(controller.hosts.has(hostId));
});
it("should respond with 200 for a host token from before the slave rename", async function() {
const { hostId } = await controller.hostPairing.createHost("Host A");
const { hostId } = controller.hostPairing.createHost("Host A");
const token = jwt.sign({ aud: "slave", slave: hostId }, controller.authSecret);
await assertResponse(await check({ "Authorization": `Bearer ${token}` }), 200, { valid: true });
});
it("should respond with 401 without a valid host token", async function() {
await controller.hostPairing.createHost("Host A");
controller.hostPairing.createHost("Host A");
assert.equal((await check({})).status, 401);
assert.equal((await check({ "Authorization": "Bearer invalid" })).status, 401);
assert.equal((await check({ "Authorization": `Bearer ${adminToken}` })).status, 401);
});
it("should respond with 401 once the host is revoked", async function() {
const { hostId, token } = await controller.hostPairing.createHost("Host A");
const { hostId, token } = controller.hostPairing.createHost("Host A");
await assertResponse(await check({ "Authorization": `Bearer ${token}` }), 200, { valid: true });
controller.hosts.delete(controller.hosts.get(hostId));
assert.equal((await check({ "Authorization": `Bearer ${token}` })).status, 401);
+9 -2
View File
@@ -179,7 +179,6 @@ describe("create installer CLI", function() {
it("writes the host config and leaves joining the cluster to the host", async function() {
const { stdout } = await run(hostArgs());
assert.match(stdout, /Successfully installed host/);
assert.match(stdout, /Start the host with run-host/);
const recorded = await commands();
assert.match(recorded[0], /^npm(?:\.cmd)? install @clusterio\/host$/);
@@ -208,7 +207,6 @@ describe("create installer CLI", function() {
"--factorio-dir", "factorio", "--remote-npm", "--plugins",
]);
assert.match(stdout, /Successfully installed standalone/);
assert.match(stdout, /Start the host with run-host/);
const recorded = await commands();
for (const expected of [
@@ -225,5 +223,14 @@ describe("create installer CLI", function() {
}
assert(!recorded.some(command => command.includes("host.controller_token")));
});
it("uses the host name given for a standalone install", async function() {
await fs.writeFile(path.join(directory, "config-controller.json"), "{}");
await run([
"--mode", "standalone", "--admin", "admin", "--http-port", "8123", "--public-address", "localhost",
"--factorio-dir", "factorio", "--remote-npm", "--plugins", "--host-name", "Test Host",
]);
assert((await commands()).includes(hostSet("host.name", "Test Host")));
});
});
});
+40 -1
View File
@@ -162,6 +162,22 @@ describe("host/src/pairing", function() {
stub = await startStub();
});
it("keeps polling while the controller is busy", async function() {
const handler = approveAfter(0);
let busy = 2;
stub.handler = entry => {
if (entry.url === "/api/host-pairing/poll" && busy > 0) {
busy -= 1;
return [503, { error: "Recovery mode" }];
}
return handler(entry);
};
const retries = [];
const result = await pair({ onRetry: err => { retries.push(err.message); } });
assert.deepEqual(result, approved);
assert.deepEqual(retries, ["Recovery mode", "Recovery mode"]);
});
it("throws when the request is rejected", async function() {
const handler = approveAfter(Infinity);
stub.handler = entry => (entry.url === "/api/host-pairing/poll" && stub.requests.length > 2
@@ -248,8 +264,17 @@ describe("host/src/pairing", function() {
it("waits for approval and saves the host when not paired", async function() {
this.timeout(5000); // Polls at the default interval of 2 seconds
stub.handler = approveAfter(0);
await ensurePaired(config, { version: "2.1.0" });
const warnings = [];
const originalWarn = lib.logger.warn;
lib.logger.warn = message => { warnings.push(message); };
try {
await ensurePaired(config, { version: "2.1.0" });
} finally {
lib.logger.warn = originalWarn;
}
assert.deepEqual(stub.requests[0].body, { name: "My Host", version: "2.1.0" });
// The systemd service created by the installer only prints warnings and above.
assert(warnings.includes(`${stub.url}hosts?approve=ABC-DE1`), "approval link is not a warning");
const saved = await savedConfig();
assert.equal(saved["host.id"], 1234);
assert.equal(saved["host.name"], "Approved");
@@ -284,6 +309,20 @@ describe("host/src/pairing", function() {
assert(!JSON.stringify(saved).includes("user-token"), "user token must not be saved");
});
it("stops when aborted while checking the saved token", async function() {
config.set("host.controller_token", "some-token");
const abort = new AbortController();
stub.handler = () => {
abort.abort();
return new Promise(() => {});
};
await assert.rejects(
ensurePaired(config, { version: "2.1.0", signal: abort.signal }),
{ name: "AbortError" },
);
assert.equal(config.get("host.controller_token"), "some-token");
});
it("keeps the saved token when it can't be checked", async function() {
config.set("host.controller_token", "some-token");
stub.handler = () => [404, undefined];
+9 -11
View File
@@ -851,7 +851,7 @@ describe("Integration of Clusterio", function() {
assert(!notes.some(note => note.id === `host/${pairing.hostId}`), "Note of the host was not removed");
});
it("should refuse while instances are assigned to the host", async function() {
it("should unassign the instances of the host", async function() {
const pairing = await pairTestHost("assigned-host");
// Instances can only be assigned to connected hosts, a link that accepts the assignment is enough.
const connector = new TestHostConnector(url, 0.2);
@@ -863,19 +863,17 @@ describe("Integration of Clusterio", function() {
await execCtl("instance create revoke-assigned --id 45");
try {
await execCtl(`instance assign revoke-assigned ${pairing.hostId}`);
await assert.rejects(
execCtl(`host revoke ${pairing.hostId}`),
/Cannot revoke host assigned-host while instances are assigned to it: revoke-assigned/,
);
connector.setClosing(); // Stops it from reconnecting after being revoked
await execCtl(`host revoke ${pairing.hostId}`);
const hosts = await getControl().send(new lib.HostListRequest());
assert(!hosts.some(host => host.id === pairing.hostId), "Host was not removed");
const instance = await getControl().send(new lib.InstanceDetailsGetRequest(45));
assert.equal(instance.assignedHost, undefined);
assert.equal(instance.status, "unassigned");
} finally {
await connector.disconnect();
await execCtl("instance assign revoke-assigned");
await execCtl("instance delete revoke-assigned");
}
await execCtl(`host revoke ${pairing.hostId}`);
const hosts = await getControl().send(new lib.HostListRequest());
assert(!hosts.some(host => host.id === pairing.hostId), "Host was not removed");
});
it("should stop a running host and have it ask to join again when started", async function() {
+5 -8
View File
@@ -311,22 +311,19 @@ export function spawn(name, cmd, waitFor, options = {}) {
let process = child_process.spawn(parts[0], parts.slice(1), childOptions(options));
let stdout = new LineSplitter({ readableObjectMode: true });
let stderr = new LineSplitter({ readableObjectMode: true });
let onDataOut = line => {
let onData = line => {
line = line.toString("utf8");
if (waitFor.test(line)) {
if (silent) {
stdout.off("data", onDataOut);
stderr.off("data", onDataErr);
stdout.off("data", onData);
stderr.off("data", onData);
}
resolve(process);
}
log(name, line);
};
let onDataErr = line => {
log(name, line.toString("utf8"));
};
stdout.on("data", onDataOut);
stderr.on("data", onDataErr);
stdout.on("data", onData);
stderr.on("data", onData);
process.stdout.pipe(stdout);
process.stderr.pipe(stderr);
});
+30
View File
@@ -1250,6 +1250,36 @@ describe("lib/subscriptions", function() {
});
});
describe("permitted", function() {
let permitted;
let subscriber;
beforeEach(function() {
mockControl = new MockControl(new MockConnector(addr({ controlId: 0 }), addr("controller")));
permitted = false;
subscriber = new lib.EventSubscriber(RegisteredEvent, mockControl, undefined, () => permitted);
});
it("should send nothing while not permitted", async function() {
subscriber.subscribe(() => {});
mockControl.connector.emit("connect");
subscriber.handleConnectionEvent("resume");
subscriber.handleAccountEvent();
await new Promise(r => setImmediate(r));
assertNoMessageSent();
});
it("should subscribe when the account becomes permitted", async function() {
subscriber.subscribe(() => {});
permitted = true;
subscriber.handleAccountEvent();
await onceConnectorSend();
assertLastMessage(new lib.SubscriptionRequest(
RegisteredEvent.name, "replace", -1, lib.SubscriptionFilters.all()
));
});
});
describe(".handleAccountEvent()", function() {
it("should send replace(all) if subscribed", async function() {
eventSubscriber.subscribe(() => {});
+2
View File
@@ -176,6 +176,8 @@ export class MockController {
["controller.auth_secret", "TestSecretDoNotUse"],
["controller.proxy_stream_timeout", 1],
["controller.metrics_timeout", 1],
["controller.host_pairing_timeout", 3600],
["controller.host_pairing_max_pending", 50],
]);
this.config = {
get: (name) => {
+1 -62
View File
@@ -12,13 +12,11 @@ const addr = lib.Address.fromShorthand;
describe("web_ui/util/host_pairing", function() {
describe("class HostPairingSubscriber", function() {
let mockControl;
let permitted;
let subscriber;
beforeEach(function() {
mockControl = new MockControl(new MockConnector(addr({ controlId: 0 }), addr("controller")));
permitted = true;
subscriber = new HostPairingSubscriber(mockControl, () => permitted);
subscriber = new HostPairingSubscriber(mockControl, () => true);
});
function pairing(code, updatedAtMs) {
@@ -33,65 +31,6 @@ describe("web_ui/util/host_pairing", function() {
return mockControl.connector.sentMessages.filter(message => message.name === "SubscriptionRequest");
}
it("should not send requests on connect or account updates without permission", async function() {
permitted = false;
mockControl.connector.emit("connect");
subscriber.handleConnectionEvent("resume");
subscriber.handleAccountEvent();
await settle();
assert.deepEqual(sentRequests(), []);
});
it("should resubscribe on connect with permission", async function() {
subscriber.subscribe(() => {});
await events.once(mockControl.connector, "send");
mockControl.connector.sentMessages.length = 0;
mockControl.connector.emit("connect");
await settle();
assert.equal(sentRequests().length, 1);
assert.deepEqual(sentRequests()[0].data, new lib.SubscriptionRequest(
lib.HostPairingUpdatesEvent.name, "replace", -1, lib.SubscriptionFilters.all()
));
});
it("should resubscribe on account updates with permission", async function() {
subscriber.subscribe(() => {});
await events.once(mockControl.connector, "send");
mockControl.connector.sentMessages.length = 0;
permitted = false;
subscriber.handleAccountEvent();
await settle();
assert.deepEqual(sentRequests(), []);
permitted = true;
subscriber.handleAccountEvent();
await settle();
assert.equal(sentRequests().length, 1);
});
it("should clear requests when a new session starts", async function() {
await subscriber._handleEvent(new lib.HostPairingUpdatesEvent([pairing("ABC-DEF", 10)]));
assert.equal(subscriber.values.size, 1);
const [before] = subscriber.getSnapshot();
mockControl.connector.emit("connect");
assert.equal(subscriber.values.size, 0);
assert.equal(subscriber.lastUpdatedMs, -1);
const [after] = subscriber.getSnapshot();
assert.notEqual(after, before);
assert.equal(after.size, 0);
});
it("should keep requests when a session is resumed", async function() {
await subscriber._handleEvent(new lib.HostPairingUpdatesEvent([pairing("ABC-DEF", 10)]));
subscriber.handleConnectionEvent("drop");
subscriber.handleConnectionEvent("resume");
assert.equal(subscriber.values.size, 1);
assert.equal(subscriber.lastUpdatedMs, 10);
});
it("should clear requests when subscribing again after the last unsubscribe", async function() {
const unsubscribe = subscriber.subscribe(() => {});
await events.once(mockControl.connector, "send");