mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
This commit is contained in:
1 parent
d2ff5fc78f
commit
3b2c849514
59 files changed
+31807
-2977
No files matched your search
@@ -395,6 +395,90 @@ export interface PluginSettings {
|
||||
readCore: (key: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
/** A host's non-secret fields, for listing, tag matching and display. */
|
||||
export interface PluginHostSummary {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string | null;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
/** Comma-separated, as ssh_data stores them. Split on "," to match tags. */
|
||||
tags: string | null;
|
||||
folder: string | null;
|
||||
authType: string;
|
||||
}
|
||||
|
||||
/** The same shape canAccessHost returns internally, without secrets. */
|
||||
export interface PluginHostAccess {
|
||||
hasAccess: boolean;
|
||||
isOwner: boolean;
|
||||
isShared: boolean;
|
||||
permissionLevel?: "connect" | "view" | "edit" | "manage";
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
export type PluginHostShareLevel = "connect" | "view" | "edit" | "manage";
|
||||
|
||||
export interface PluginShareTarget {
|
||||
type: "user" | "role";
|
||||
id: string | number;
|
||||
}
|
||||
|
||||
export interface PluginHostShareResult {
|
||||
hostId: number;
|
||||
shared: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** A user or role a plugin can offer as a share target. No secrets, no roles' permission lists. */
|
||||
export interface PluginShareableUser {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface PluginShareableRole {
|
||||
id: number;
|
||||
name: string;
|
||||
displayName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts the acting user can see, and the RBAC + sharing operations a plugin
|
||||
* needs to build a feature (like fleets) on top of hosts it does not own.
|
||||
*
|
||||
* list/get/checkAccess need hosts:read. share, and the user/role pickers a
|
||||
* share UI needs, need hosts:write: sharing changes who can reach a host,
|
||||
* which is a write on that host's access, even though the plugin owns
|
||||
* neither the host nor the grant.
|
||||
*/
|
||||
export interface PluginHosts {
|
||||
/** Every host the acting user owns or was shared, without secrets. */
|
||||
list: () => Promise<PluginHostSummary[]>;
|
||||
/** One host the acting user can see, or null if it does not exist or they cannot. */
|
||||
get: (hostId: number) => Promise<PluginHostSummary | null>;
|
||||
/** The same access rule ctx.ssh.connect enforces, but naming the level, not just yes/no. */
|
||||
checkAccess: (
|
||||
hostId: number,
|
||||
level: PluginHostShareLevel,
|
||||
) => Promise<PluginHostAccess>;
|
||||
/**
|
||||
* Grants access to a host the caller manages, snapshotting shared secrets
|
||||
* for each target the same way the host editor's own share action does.
|
||||
* Refuses a host the caller does not hold "manage" on.
|
||||
*/
|
||||
share: (
|
||||
hostId: number,
|
||||
targets: PluginShareTarget[],
|
||||
permissionLevel: PluginHostShareLevel,
|
||||
durationHours?: number,
|
||||
) => Promise<PluginHostShareResult>;
|
||||
/** Users the caller may pick as a share target, for building a picker UI. */
|
||||
listUsers: () => Promise<PluginShareableUser[]>;
|
||||
/** Non-system roles the caller may pick as a share target. */
|
||||
listRoles: () => Promise<PluginShareableRole[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A host as the SSH pipeline reads it: resolved by core, with secrets filled
|
||||
* in for the acting user. Plugins get one from core and hand it back; they
|
||||
@@ -814,6 +898,8 @@ export interface PluginContext {
|
||||
readonly rbac: PluginRbac;
|
||||
readonly settings: PluginSettings;
|
||||
readonly disposables: PluginDisposables;
|
||||
/** Hosts the actor can see, and host-sharing operations. Needs hosts:read / hosts:write. */
|
||||
readonly hosts: PluginHosts;
|
||||
/** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */
|
||||
readonly ssh: PluginSsh;
|
||||
/** Login methods, second factors and SSH auth types. Needs auth:provide. */
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Generic host command helpers: platform detection, package manager actions
|
||||
* and the shell-argument validators that guard them.
|
||||
*
|
||||
* Pure functions and thin ssh2 wrappers, no core imports. fleets and
|
||||
* host-metrics both build their platform/package features on this instead of
|
||||
* duplicating it or reaching into core.
|
||||
*
|
||||
* A plugin passes the `Client` it got from ctx.ssh.connect/withConnection.
|
||||
*/
|
||||
|
||||
export type { Client, ClientChannel } from "ssh2";
|
||||
import type { Client, ClientChannel } from "ssh2";
|
||||
|
||||
export interface ExecResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
}
|
||||
|
||||
/** Runs one non-interactive command over an existing ssh2 client. */
|
||||
export function execCommand(
|
||||
client: Client,
|
||||
command: string,
|
||||
timeoutMs = 30000,
|
||||
): Promise<ExecResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let stream: ClientChannel | null = null;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
if (stream) {
|
||||
try {
|
||||
stream.removeAllListeners();
|
||||
if (stream.stderr) {
|
||||
stream.stderr.removeAllListeners();
|
||||
}
|
||||
stream.destroy();
|
||||
} catch {
|
||||
// expected - cleanup errors ignored
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
client.exec(command, { pty: false }, (err, _stream) => {
|
||||
if (err) {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
stream = _stream;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let exitCode: number | null = null;
|
||||
|
||||
stream
|
||||
.on("close", (code: number | undefined) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
exitCode = typeof code === "number" ? code : null;
|
||||
cleanup();
|
||||
resolve({ stdout, stderr, code: exitCode });
|
||||
}
|
||||
})
|
||||
.on("data", (data: Buffer) => {
|
||||
stdout += data.toString("utf8");
|
||||
})
|
||||
.on("error", (streamErr: Error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(streamErr);
|
||||
}
|
||||
});
|
||||
|
||||
if (stream.stderr) {
|
||||
stream.stderr
|
||||
.on("data", (data: Buffer) => {
|
||||
stderr += data.toString("utf8");
|
||||
})
|
||||
.on("error", (stderrErr: Error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(stderrErr);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export type PackageManager = "apt" | "dnf" | "yum" | "pacman" | null;
|
||||
|
||||
export interface PlatformInfo {
|
||||
hasSystemd: boolean;
|
||||
pkg: PackageManager;
|
||||
hasCertbot: boolean;
|
||||
hasAcmeSh: boolean;
|
||||
hasDocker: boolean;
|
||||
osPrettyName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
|
||||
* so PATH additions from installers like Homebrew or OrbStack are missing.
|
||||
* Extend PATH with their common install locations before probing.
|
||||
*/
|
||||
export const EXTRA_PATH_DIRS =
|
||||
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
|
||||
|
||||
const DOCKER_PROBE =
|
||||
`command -v docker >/dev/null 2>&1` +
|
||||
` || [ -S /var/run/docker.sock ]` +
|
||||
` || [ -S "$HOME/.orbstack/run/docker.sock" ]`;
|
||||
|
||||
/**
|
||||
* Single probe that reports which tooling is available. Each line is
|
||||
* "key=value" so the parser is trivial and order-independent.
|
||||
*/
|
||||
export const PLATFORM_PROBE_COMMAND = [
|
||||
`PATH="${EXTRA_PATH_DIRS}:$PATH"`,
|
||||
"echo systemd=$(command -v systemctl >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
"echo apt=$(command -v apt-get >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
"echo dnf=$(command -v dnf >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
"echo yum=$(command -v yum >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
"echo pacman=$(command -v pacman >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
"echo certbot=$(command -v certbot >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
'echo acmesh=$( { command -v acme.sh >/dev/null 2>&1 || [ -x "$HOME/.acme.sh/acme.sh" ]; } && echo 1 || echo 0)',
|
||||
`echo docker=$(${DOCKER_PROBE} && echo 1 || echo 0)`,
|
||||
'echo os=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME")',
|
||||
].join("; ");
|
||||
|
||||
export function parsePlatformProbe(output: string): PlatformInfo {
|
||||
const map = new Map<string, string>();
|
||||
for (const line of output.split("\n")) {
|
||||
const idx = line.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
map.set(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
|
||||
}
|
||||
const on = (k: string) => map.get(k) === "1";
|
||||
|
||||
// Prefer dnf over yum when both exist (dnf is the modern front-end).
|
||||
let pkg: PackageManager = null;
|
||||
if (on("apt")) pkg = "apt";
|
||||
else if (on("dnf")) pkg = "dnf";
|
||||
else if (on("yum")) pkg = "yum";
|
||||
else if (on("pacman")) pkg = "pacman";
|
||||
|
||||
const os = map.get("os");
|
||||
return {
|
||||
hasSystemd: on("systemd"),
|
||||
pkg,
|
||||
hasCertbot: on("certbot"),
|
||||
hasAcmeSh: on("acmesh"),
|
||||
hasDocker: on("docker"),
|
||||
osPrettyName: os && os.length > 0 ? os : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function detectPlatform(client: Client): Promise<PlatformInfo> {
|
||||
const { stdout } = await execCommand(client, PLATFORM_PROBE_COMMAND, 15000);
|
||||
return parsePlatformProbe(stdout);
|
||||
}
|
||||
|
||||
export type HostPlatform = "darwin" | "linux" | "windows" | "other";
|
||||
|
||||
// Windows OpenSSH's default shell is usually cmd.exe (occasionally
|
||||
// PowerShell), so scripts are sent base64-encoded via -EncodedCommand -
|
||||
// that avoids every cmd.exe quoting/escaping pitfall entirely, since the
|
||||
// argument ends up being plain alphanumeric text with no shell metacharacters.
|
||||
export function encodePowerShellCommand(script: string): string {
|
||||
return Buffer.from(script, "utf16le").toString("base64");
|
||||
}
|
||||
|
||||
export function execPowerShell(
|
||||
client: Client,
|
||||
script: string,
|
||||
timeoutMs = 30000,
|
||||
): Promise<ExecResult> {
|
||||
const encoded = encodePowerShellCommand(script);
|
||||
return execCommand(
|
||||
client,
|
||||
`powershell -NoProfile -NonInteractive -EncodedCommand ${encoded}`,
|
||||
timeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
export async function detectHostPlatform(
|
||||
client: Client,
|
||||
): Promise<HostPlatform> {
|
||||
try {
|
||||
const { stdout, code } = await execCommand(client, "uname -s", 10000);
|
||||
const kernel = stdout.trim().toLowerCase();
|
||||
if (kernel === "darwin") return "darwin";
|
||||
if (kernel === "linux") return "linux";
|
||||
if (code === 0 && kernel) return "other";
|
||||
} catch {
|
||||
// expected on hosts with no POSIX shell (e.g. Windows via cmd.exe)
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execPowerShell(client, "'WIN_OK'", 10000);
|
||||
if (stdout.includes("WIN_OK")) return "windows";
|
||||
} catch {
|
||||
// expected on hosts with no PowerShell available
|
||||
}
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
export interface UpgradablePackage {
|
||||
name: string;
|
||||
currentVersion?: string;
|
||||
newVersion?: string;
|
||||
}
|
||||
|
||||
export function buildListUpgradableCommand(pkg: PackageManager): string | null {
|
||||
switch (pkg) {
|
||||
case "apt":
|
||||
return "apt list --upgradable 2>/dev/null | tail -n +2";
|
||||
case "dnf":
|
||||
return "dnf -q check-update 2>/dev/null || true";
|
||||
case "yum":
|
||||
return "yum -q check-update 2>/dev/null || true";
|
||||
case "pacman":
|
||||
return "pacman -Qu 2>/dev/null || true";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseUpgradable(
|
||||
pkg: PackageManager,
|
||||
output: string,
|
||||
): UpgradablePackage[] {
|
||||
const out: UpgradablePackage[] = [];
|
||||
const lines = output
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (pkg === "apt") {
|
||||
for (const line of lines) {
|
||||
// name/suite newver arch [upgradable from: oldver]
|
||||
const m = line.match(
|
||||
/^([^/\s]+)\/\S+\s+(\S+)\s+\S+(?:\s+\[upgradable from:\s+(\S+)\])?/,
|
||||
);
|
||||
if (m) out.push({ name: m[1], newVersion: m[2], currentVersion: m[3] });
|
||||
}
|
||||
} else if (pkg === "dnf" || pkg === "yum") {
|
||||
for (const line of lines) {
|
||||
if (/^(Last metadata|Obsoleting|Security:)/i.test(line)) continue;
|
||||
const m = line.match(/^(\S+)\s+(\S+)\s+\S+$/);
|
||||
if (m && m[1].includes(".")) out.push({ name: m[1], newVersion: m[2] });
|
||||
}
|
||||
} else if (pkg === "pacman") {
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^(\S+)\s+(\S+)\s+->\s+(\S+)$/);
|
||||
if (m) out.push({ name: m[1], currentVersion: m[2], newVersion: m[3] });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type PackageAction = "upgrade-all" | "install" | "upgrade";
|
||||
|
||||
export function buildPackageActionCommand(
|
||||
pkg: PackageManager,
|
||||
action: PackageAction,
|
||||
name?: string,
|
||||
): string | null {
|
||||
const target = name ? ` ${name}` : "";
|
||||
switch (pkg) {
|
||||
case "apt":
|
||||
if (action === "upgrade-all")
|
||||
return "DEBIAN_FRONTEND=noninteractive apt-get -y upgrade";
|
||||
return `DEBIAN_FRONTEND=noninteractive apt-get -y install${target}`;
|
||||
case "dnf":
|
||||
return action === "upgrade-all"
|
||||
? "dnf -y upgrade"
|
||||
: `dnf -y install${target}`;
|
||||
case "yum":
|
||||
return action === "upgrade-all"
|
||||
? "yum -y update"
|
||||
: `yum -y install${target}`;
|
||||
case "pacman":
|
||||
return action === "upgrade-all"
|
||||
? "pacman -Syu --noconfirm"
|
||||
: `pacman -S --noconfirm${target}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPackageRemoveCommand(
|
||||
pkg: PackageManager,
|
||||
name: string,
|
||||
): string | null {
|
||||
switch (pkg) {
|
||||
case "apt":
|
||||
return `DEBIAN_FRONTEND=noninteractive apt-get -y remove ${name}`;
|
||||
case "dnf":
|
||||
return `dnf -y remove ${name}`;
|
||||
case "yum":
|
||||
return `yum -y remove ${name}`;
|
||||
case "pacman":
|
||||
return `pacman -R --noconfirm ${name}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const PACKAGE_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]*$/;
|
||||
|
||||
export function isValidPackageName(pkg: unknown): pkg is string {
|
||||
return typeof pkg === "string" && pkg.length <= 128 && PACKAGE_RE.test(pkg);
|
||||
}
|
||||
|
||||
export type ElevationErrorCode = "SUDO_REQUIRED" | "SUDO_FAILED" | "NOT_SUDOER";
|
||||
|
||||
export class ElevationError extends Error {
|
||||
code: ElevationErrorCode;
|
||||
constructor(code: ElevationErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = "ElevationError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ElevatedResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
usedSudo: boolean;
|
||||
}
|
||||
|
||||
const PERMISSION_DENIED = [
|
||||
"permission denied",
|
||||
"operation not permitted",
|
||||
"must be run as root",
|
||||
"must be superuser",
|
||||
"you need to be root",
|
||||
"are you root",
|
||||
"access denied",
|
||||
];
|
||||
|
||||
/**
|
||||
* Phrases sudo itself prints to STDERR when authentication or authorization
|
||||
* fails. Matched only against sudo's own stderr (never command output), so a
|
||||
* command that happens to print "permission denied" on stdout is not mistaken
|
||||
* for a sudo failure.
|
||||
*/
|
||||
const SUDO_AUTH_FAILED = [
|
||||
"incorrect password",
|
||||
"a password is required",
|
||||
"a terminal is required",
|
||||
"no tty present",
|
||||
"sorry, try again",
|
||||
"no password was provided",
|
||||
"1 incorrect password attempt",
|
||||
];
|
||||
|
||||
const SUDO_NOT_SUDOER = [
|
||||
"is not in the sudoers file",
|
||||
"not allowed to run sudo",
|
||||
"not allowed to execute",
|
||||
];
|
||||
|
||||
/**
|
||||
* Marker printed only after sudo has successfully authenticated and started
|
||||
* the inner shell. Its presence on stdout is the authoritative "elevation
|
||||
* worked" signal; its absence (together with sudo stderr) means auth failed.
|
||||
*/
|
||||
const SUDO_OK_MARKER = "__TX_SUDO_OK__";
|
||||
const SUDO_OK_LINE_RE = new RegExp(`^${SUDO_OK_MARKER}\\r?\\n?`);
|
||||
|
||||
/** Escape a value for single-quoted shell context. */
|
||||
export function shellSingleQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the elevated command string:
|
||||
* echo '<pw>' | sudo -S -p '' sh -c 'echo __TX_SUDO_OK__; <command>'
|
||||
*
|
||||
* `-p ''` suppresses the prompt. stderr is NOT merged into stdout, so the
|
||||
* command's own output stays clean and sudo's auth errors stay on stderr. The
|
||||
* marker is echoed by the inner shell once sudo authenticates, letting us
|
||||
* tell a genuine auth failure from command output that merely contains scary
|
||||
* words.
|
||||
*/
|
||||
export function buildSudoCommand(
|
||||
command: string,
|
||||
sudoPassword: string,
|
||||
): string {
|
||||
const pw = shellSingleQuote(sudoPassword);
|
||||
const inner = shellSingleQuote(`echo ${SUDO_OK_MARKER}; ${command}`);
|
||||
return `echo ${pw} | sudo -S -p '' sh -c ${inner}`;
|
||||
}
|
||||
|
||||
function includesAny(text: string, needles: string[]): boolean {
|
||||
const lower = text.toLowerCase();
|
||||
return needles.some((n) => lower.includes(n));
|
||||
}
|
||||
|
||||
function looksLikePermissionDenied(text: string): boolean {
|
||||
return includesAny(text, PERMISSION_DENIED);
|
||||
}
|
||||
|
||||
/** Strip the success marker line from the front of stdout. */
|
||||
function stripMarker(stdout: string): string {
|
||||
return stdout.replace(SUDO_OK_LINE_RE, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command on a client, elevating with the host's stored sudo password
|
||||
* only when needed (or when forced). Throws a typed `ElevationError` when
|
||||
* elevation is required but unavailable/incorrect.
|
||||
*/
|
||||
export async function execElevated(
|
||||
client: Client,
|
||||
command: string,
|
||||
sudoPassword: string | undefined,
|
||||
opts: { forceSudo?: boolean; timeoutMs?: number } = {},
|
||||
): Promise<ElevatedResult> {
|
||||
const timeoutMs = opts.timeoutMs ?? 30000;
|
||||
|
||||
if (!opts.forceSudo) {
|
||||
const direct = await execCommand(client, command, timeoutMs);
|
||||
if (direct.code === 0) {
|
||||
return { ...direct, usedSudo: false };
|
||||
}
|
||||
// Only escalate when the failure looks like a privilege problem. A
|
||||
// permission-denied phrase on stderr (not arbitrary stdout) is the signal.
|
||||
if (!looksLikePermissionDenied(direct.stderr)) {
|
||||
return { ...direct, usedSudo: false };
|
||||
}
|
||||
if (!sudoPassword) {
|
||||
throw new ElevationError(
|
||||
"SUDO_REQUIRED",
|
||||
"This action requires elevated privileges. Set a sudo password for this host to continue.",
|
||||
);
|
||||
}
|
||||
} else if (!sudoPassword) {
|
||||
throw new ElevationError(
|
||||
"SUDO_REQUIRED",
|
||||
"This action requires elevated privileges. Set a sudo password for this host to continue.",
|
||||
);
|
||||
}
|
||||
|
||||
const sudoCmd = buildSudoCommand(command, sudoPassword as string);
|
||||
const result = await execCommand(client, sudoCmd, timeoutMs);
|
||||
const authenticated = result.stdout.includes(SUDO_OK_MARKER);
|
||||
|
||||
if (!authenticated) {
|
||||
// Elevation never started: diagnose from sudo's stderr only.
|
||||
const notSudoer = includesAny(result.stderr, SUDO_NOT_SUDOER);
|
||||
const authFailed = includesAny(result.stderr, SUDO_AUTH_FAILED);
|
||||
if (notSudoer) {
|
||||
throw new ElevationError(
|
||||
"NOT_SUDOER",
|
||||
"The connected user is not permitted to use sudo on this host.",
|
||||
);
|
||||
}
|
||||
if (authFailed) {
|
||||
throw new ElevationError(
|
||||
"SUDO_FAILED",
|
||||
"Elevation failed. Check the host's sudo password.",
|
||||
);
|
||||
}
|
||||
// No marker and no recognizable sudo error: treat as a generic failure
|
||||
// but keep the original output so the caller can surface it.
|
||||
throw new ElevationError(
|
||||
"SUDO_FAILED",
|
||||
"Elevation failed. Check the host's sudo password.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
stdout: stripMarker(result.stdout),
|
||||
stderr: result.stderr,
|
||||
code: result.code,
|
||||
usedSudo: true,
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,11 @@ import type {
|
||||
PluginSecondFactor,
|
||||
PluginSshAuthProvider,
|
||||
PluginSshHost,
|
||||
PluginHostSummary,
|
||||
PluginHostAccess,
|
||||
PluginHostShareResult,
|
||||
PluginShareableUser,
|
||||
PluginShareableRole,
|
||||
} from "./backend.js";
|
||||
import type { SyncEntityRegistration } from "./backend.js";
|
||||
import type { PluginDatabase } from "./backend.js";
|
||||
@@ -48,6 +53,11 @@ export interface FakeContextOptions {
|
||||
* when omitted every check passes, so tests that do not care stay simple.
|
||||
*/
|
||||
permissions?: string[];
|
||||
/** Hosts ctx.hosts.list/get/checkAccess answer with. */
|
||||
hosts?: PluginHostSummary[];
|
||||
/** Users and roles ctx.hosts.listUsers/listRoles answer with. */
|
||||
shareableUsers?: PluginShareableUser[];
|
||||
shareableRoles?: PluginShareableRole[];
|
||||
}
|
||||
|
||||
export interface FakeAuthRegistrations {
|
||||
@@ -82,6 +92,13 @@ export interface FakePluginContext {
|
||||
coreSettings: Map<string, string>;
|
||||
/** Every ctx.ssh.connect and withConnection call, in order. */
|
||||
sshConnections: Array<{ host: number | PluginSshHost; pool?: string }>;
|
||||
/** Every ctx.hosts.share call, in order. */
|
||||
hostShares: Array<{
|
||||
hostId: number;
|
||||
targets: unknown[];
|
||||
permissionLevel: string;
|
||||
durationHours?: number;
|
||||
}>;
|
||||
/** Everything registered through ctx.auth. */
|
||||
auth: FakeAuthRegistrations;
|
||||
/** Changes the acting user, as core's request middleware would. */
|
||||
@@ -153,6 +170,10 @@ export function createFakeContext(
|
||||
);
|
||||
const settingsListeners = new Map<string, Set<(value: unknown) => void>>();
|
||||
const sshConnections: FakePluginContext["sshConnections"] = [];
|
||||
const hostShares: FakePluginContext["hostShares"] = [];
|
||||
const hostsById = new Map<number, PluginHostSummary>(
|
||||
(options.hosts ?? []).map((h) => [h.id, h]),
|
||||
);
|
||||
const services = new Map<string, object>();
|
||||
const auth: FakeAuthRegistrations = {
|
||||
sshAuthProviders: [],
|
||||
@@ -385,6 +406,34 @@ export function createFakeContext(
|
||||
|
||||
disposables,
|
||||
|
||||
hosts: {
|
||||
list: async () => [...hostsById.values()],
|
||||
get: async (hostId) => hostsById.get(hostId) ?? null,
|
||||
checkAccess: async (hostId): Promise<PluginHostAccess> => {
|
||||
const found = hostsById.get(hostId);
|
||||
if (!found)
|
||||
return { hasAccess: false, isOwner: false, isShared: false };
|
||||
const isOwner = found.userId === actor;
|
||||
return {
|
||||
hasAccess: true,
|
||||
isOwner,
|
||||
isShared: !isOwner,
|
||||
permissionLevel: "manage",
|
||||
};
|
||||
},
|
||||
share: async (
|
||||
hostId,
|
||||
targets,
|
||||
permissionLevel,
|
||||
durationHours,
|
||||
): Promise<PluginHostShareResult> => {
|
||||
hostShares.push({ hostId, targets, permissionLevel, durationHours });
|
||||
return { hostId, shared: true };
|
||||
},
|
||||
listUsers: async () => options.shareableUsers ?? [],
|
||||
listRoles: async () => options.shareableRoles ?? [],
|
||||
},
|
||||
|
||||
ssh: {
|
||||
connect: async (host) => {
|
||||
sshConnections.push({ host });
|
||||
@@ -473,6 +522,7 @@ export function createFakeContext(
|
||||
settings,
|
||||
coreSettings,
|
||||
sshConnections,
|
||||
hostShares,
|
||||
auth,
|
||||
setActor: (userId) => {
|
||||
actor = userId;
|
||||
@@ -643,6 +693,33 @@ export function createMockCtx(
|
||||
},
|
||||
},
|
||||
|
||||
hosts: {
|
||||
list: async () => {
|
||||
require("hosts:read");
|
||||
return ctx.hosts.list();
|
||||
},
|
||||
get: async (hostId) => {
|
||||
require("hosts:read");
|
||||
return ctx.hosts.get(hostId);
|
||||
},
|
||||
checkAccess: async (hostId, level) => {
|
||||
require("hosts:read");
|
||||
return ctx.hosts.checkAccess(hostId, level);
|
||||
},
|
||||
share: async (hostId, targets, permissionLevel, durationHours) => {
|
||||
require("hosts:write");
|
||||
return ctx.hosts.share(hostId, targets, permissionLevel, durationHours);
|
||||
},
|
||||
listUsers: async () => {
|
||||
require("hosts:write");
|
||||
return ctx.hosts.listUsers();
|
||||
},
|
||||
listRoles: async () => {
|
||||
require("hosts:write");
|
||||
return ctx.hosts.listRoles();
|
||||
},
|
||||
},
|
||||
|
||||
ssh: {
|
||||
...ctx.ssh,
|
||||
connect: async (host, connectOptions) => {
|
||||
|
||||
Reference in new issue
Block a user